forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.5 KiB
1//! Dashboard contribution calendar: the last 53 weeks of daily contribution counts, one column per
2//! week (Monday first), with the four summary stats, month labels and the motivating line.
3//!
4//! `build` is pure date and streak maths over the counts from the store. `contribution_calendar`
5//! draws it with classes only (CSP): the grid placement comes from CSS (`grid-auto-flow: column`
6//! with seven rows), so every cell is an element and the blank slots keep the columns aligned.
7
8use std::collections::HashMap;
9
10use topcoat::Result;
11use topcoat::view::{View, component, view};
12
13use super::civil_from_days;
14use crate::i18n::Lang;
15use crate::i18n::misc::{self as g, K};
16
17/// Columns in the calendar: the week of today plus the 52 weeks before it.
18pub const WEEKS: usize = 53;
19
20/// Class of a cell at each level 0-4. Level 0 is "no contributions".
21const LEVEL_CLASS: [&str; 5] = ["cell l0", "cell l1", "cell l2", "cell l3", "cell l4"];
22
23/// One day in the calendar. `date` is `(year, month, day)`.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Day {
26 pub date: (i64, i64, i64),
27 pub count: u64,
28 pub level: u8,
29}
30
31/// The calendar as the component draws it. `weeks[c][r]` is the day in column `c` (a week,
32/// Monday first) and row `r` (0 = Monday, 6 = Sunday); `None` for the days after today.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Calendar {
35 pub weeks: Vec<[Option<Day>; 7]>,
36 /// Column and month number (1-12) where a month label starts: the first column that holds
37 /// the 1st of that month.
38 pub months: Vec<(usize, i64)>,
39 /// First day shown (the Monday that starts column 0).
40 pub first_day: (i64, i64, i64),
41 /// Today, the last day shown.
42 pub today: (i64, i64, i64),
43 /// Contributions in the window.
44 pub total: u64,
45 /// Consecutive days with at least one contribution, ending today; or ending yesterday when
46 /// today has none (so an unfinished day does not break the streak).
47 pub current_streak: u64,
48 /// The longest run of days with at least one contribution in the window.
49 pub longest_streak: u64,
50 /// The highest count on a single day.
51 pub best_day: u64,
52 /// Today's count.
53 pub today_count: u64,
54}
55
56/// Level for a day's count: 0 for none, then fixed buckets 1-3 (1), 4-7 (2), 8-12 (3) and 13 or
57/// more (4). Fixed buckets, rather than quartiles of the year, so a colour means the same count
58/// on every page load.
59pub fn level(count: u64) -> u8 {
60 match count {
61 0 => 0,
62 1..=3 => 1,
63 4..=7 => 2,
64 8..=12 => 3,
65 _ => 4,
66 }
67}
68
69/// Builds the calendar for the 53 weeks ending with today's week. `counts` holds
70/// `(YYYY-MM-DD, count)` pairs, ascending, as the store returns them; dates outside the window
71/// and after today are ignored, and malformed dates are skipped. `today` is `(year, month, day)`.
72pub fn build(counts: &[(String, u32)], today: (i64, i64, i64)) -> Calendar {
73 let today_days = days_from_civil(today.0, today.1, today.2);
74 let by_day: HashMap<i64, u64> = counts
75 .iter()
76 .filter_map(|(date, count)| parse_date(date).map(|days| (days, u64::from(*count))))
77 .collect();
78 let count_on = |days: i64| by_day.get(&days).copied().unwrap_or(0);
79
80 // Column 0 starts on the Monday that is WEEKS - 1 weeks before the Monday of today's week.
81 let this_monday = today_days - i64::from(weekday(today_days));
82 let first_days = this_monday - 7 * (WEEKS as i64 - 1);
83
84 let mut weeks = Vec::with_capacity(WEEKS);
85 let mut months = Vec::new();
86 let (mut total, mut best_day, mut longest, mut run) = (0, 0, 0, 0);
87 for column in 0..WEEKS {
88 let mut week: [Option<Day>; 7] = Default::default();
89 for (row, slot) in week.iter_mut().enumerate() {
90 let days = first_days + (column * 7 + row) as i64;
91 if days > today_days {
92 continue;
93 }
94 let count = count_on(days);
95 let date = civil_from_days(days);
96 if date.2 == 1 {
97 months.push((column, date.1));
98 }
99 total += count;
100 best_day = best_day.max(count);
101 run = if count > 0 { run + 1 } else { 0 };
102 longest = longest.max(run);
103 *slot = Some(Day {
104 date,
105 count,
106 level: level(count),
107 });
108 }
109 weeks.push(week);
110 }
111
112 // The current streak starts at today, or at yesterday when today has no contributions yet.
113 let today_count = count_on(today_days);
114 let mut cursor = if today_count > 0 {
115 today_days
116 } else {
117 today_days - 1
118 };
119 let mut current_streak = 0;
120 while cursor >= first_days && count_on(cursor) > 0 {
121 current_streak += 1;
122 cursor -= 1;
123 }
124
125 Calendar {
126 weeks,
127 months,
128 first_day: civil_from_days(first_days),
129 today,
130 total,
131 current_streak,
132 longest_streak: longest,
133 best_day,
134 today_count,
135 }
136}
137
138/// Parses a strict `YYYY-MM-DD` date into days since 1970-01-01. Rejects impossible dates such as
139/// 2027-02-29, by converting back and comparing.
140fn parse_date(text: &str) -> Option<i64> {
141 let bytes = text.as_bytes();
142 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
143 return None;
144 }
145 let year: i64 = text.get(0..4)?.parse().ok()?;
146 let month: i64 = text.get(5..7)?.parse().ok()?;
147 let day: i64 = text.get(8..10)?.parse().ok()?;
148 let days = days_from_civil(year, month, day);
149 (civil_from_days(days) == (year, month, day)).then_some(days)
150}
151
152/// Days since 1970-01-01 for `(year, month, day)`. Howard Hinnant's days-from-civil algorithm, the
153/// inverse of `civil_from_days`.
154fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
155 let y = if month <= 2 { year - 1 } else { year };
156 let era = y.div_euclid(400);
157 let yoe = y - era * 400;
158 let mp = (month + 9) % 12;
159 let doy = (153 * mp + 2) / 5 + day - 1;
160 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
161 era * 146_097 + doe - 719_468
162}
163
164/// Monday = 0 through Sunday = 6. 1970-01-01 was a Thursday.
165fn weekday(days: i64) -> u8 {
166 (days + 3).rem_euclid(7) as u8
167}
168
169/// One grid slot as the view draws it.
170struct CellView {
171 class: &'static str,
172 title: String,
173}
174
175/// The contribution card: heading and date range, the four stats, the month labels and the
176/// 53-week grid, then the motivating line and the Less/More legend.
177#[component]
178pub async fn contribution_calendar(calendar: Calendar, lang: Lang) -> Result<impl View> {
179 let range = {
180 let from = g::date_text(
181 lang,
182 calendar.first_day.0,
183 calendar.first_day.1,
184 calendar.first_day.2,
185 );
186 let to = g::date_text(lang, calendar.today.0, calendar.today.1, calendar.today.2);
187 g::date_range(lang, &from, &to)
188 };
189 let summary = g::heatmap_summary(
190 lang,
191 calendar.total,
192 calendar.current_streak,
193 calendar.longest_streak,
194 );
195 let motivation = g::heatmap_motivation(lang, calendar.today_count, calendar.current_streak);
196 let total = g::thousands(lang, calendar.total);
197 let current = g::streak_days(lang, calendar.current_streak);
198 let longest = g::streak_days(lang, calendar.longest_streak);
199 let best = g::contributions_count(lang, calendar.best_day);
200 let mon = g::t(lang, K::MonLabel);
201 let wed = g::t(lang, K::WedLabel);
202 let fri = g::t(lang, K::FriLabel);
203 let less = g::t(lang, K::Less);
204 let more = g::t(lang, K::More);
205
206 // Month labels: one slot per column, empty where no label starts.
207 let mut month_slots = vec![String::new(); WEEKS];
208 for (column, month) in &calendar.months {
209 month_slots[*column] = g::month_abbrev(lang, *month).to_owned();
210 }
211
212 // Cells in column order (7 per column), so auto-flow puts each day in its row.
213 let cells: Vec<CellView> = calendar
214 .weeks
215 .iter()
216 .flat_map(|week| week.iter())
217 .map(|slot| match slot {
218 Some(day) => CellView {
219 class: LEVEL_CLASS[usize::from(day.level)],
220 title: g::contributions_on(
221 lang,
222 day.count,
223 &g::date_text(lang, day.date.0, day.date.1, day.date.2),
224 ),
225 },
226 None => CellView {
227 class: "cell blank",
228 title: String::new(),
229 },
230 })
231 .collect();
232
233 Ok(view! {
234 <section class="heat" aria-labelledby="contributions-h">
235 <div class="heat-head">
236 <div>
237 <h2 id="contributions-h">(g::t(lang, K::ContributionsTitle))</h2>
238 <p class="muted small">(range.as_str())</p>
239 </div>
240 <div class="heat-stats">
241 <div class="heat-stat">
242 <b>(total.as_str())</b>
243 <span>(g::t(lang, K::ContributionsLastYear))</span>
244 </div>
245 <div class="heat-stat">
246 <b class="accent">(current.as_str())</b>
247 <span>(g::t(lang, K::CurrentStreak))</span>
248 </div>
249 <div class="heat-stat">
250 <b>(longest.as_str())</b>
251 <span>(g::t(lang, K::LongestStreak))</span>
252 </div>
253 <div class="heat-stat">
254 <b>(best.as_str())</b>
255 <span>(g::t(lang, K::BestDay))</span>
256 </div>
257 </div>
258 </div>
259 <div class="heat-scroll">
260 <div class="heat-body">
261 <div class="heat-days" aria-hidden="true">
262 <span>(mon)</span>
263 <span></span>
264 <span>(wed)</span>
265 <span></span>
266 <span>(fri)</span>
267 <span></span>
268 <span></span>
269 </div>
270 <div>
271 <div class="heat-months" aria-hidden="true">
272 for label in month_slots.into_iter() {
273 <span>(label.as_str())</span>
274 }
275 </div>
276 <div class="heat-grid" role="img" aria-label=(summary.as_str())>
277 for cell in cells.into_iter() {
278 <span class=(cell.class) title=(cell.title.as_str())></span>
279 }
280 </div>
281 </div>
282 </div>
283 </div>
284 <div class="heat-foot">
285 <p class="muted small">(motivation.as_str())</p>
286 <div class="legend" aria-hidden="true">
287 <span>(less)</span>
288 <span class="cell l0"></span>
289 <span class="cell l1"></span>
290 <span class="cell l2"></span>
291 <span class="cell l3"></span>
292 <span class="cell l4"></span>
293 <span>(more)</span>
294 </div>
295 </div>
296 </section>
297 })
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 const TODAY: (i64, i64, i64) = (2026, 10, 16);
305
306 fn counts(pairs: &[(&str, u32)]) -> Vec<(String, u32)> {
307 pairs.iter().map(|(d, n)| ((*d).to_owned(), *n)).collect()
308 }
309
310 fn day(cal: &Calendar, date: (i64, i64, i64)) -> Day {
311 cal.weeks
312 .iter()
313 .flatten()
314 .flatten()
315 .find(|d| d.date == date)
316 .copied()
317 .expect("day is in the window")
318 }
319
320 #[test]
321 fn empty_input_gives_an_empty_calendar() {
322 let cal = build(&[], TODAY);
323 assert_eq!(cal.weeks.len(), WEEKS);
324 assert_eq!(cal.total, 0);
325 assert_eq!(cal.current_streak, 0);
326 assert_eq!(cal.longest_streak, 0);
327 assert_eq!(cal.best_day, 0);
328 assert_eq!(cal.today_count, 0);
329 assert!(cal.weeks.iter().flatten().flatten().all(|d| d.level == 0));
330 }
331
332 #[test]
333 fn streak_ending_today_counts_today() {
334 let cal = build(
335 &counts(&[("2026-10-14", 2), ("2026-10-15", 1), ("2026-10-16", 5)]),
336 TODAY,
337 );
338 assert_eq!(cal.current_streak, 3);
339 assert_eq!(cal.longest_streak, 3);
340 assert_eq!(cal.today_count, 5);
341 assert_eq!(cal.total, 8);
342 }
343
344 #[test]
345 fn streak_ending_yesterday_survives_an_empty_today() {
346 let cal = build(
347 &counts(&[("2026-10-13", 1), ("2026-10-14", 1), ("2026-10-15", 1)]),
348 TODAY,
349 );
350 assert_eq!(cal.today_count, 0);
351 assert_eq!(cal.current_streak, 3);
352 }
353
354 #[test]
355 fn a_gap_ends_the_current_streak_but_not_the_longest() {
356 let cal = build(
357 &counts(&[
358 ("2026-09-20", 1),
359 ("2026-09-21", 1),
360 ("2026-09-22", 1),
361 ("2026-09-23", 1),
362 ("2026-09-24", 1),
363 ("2026-10-15", 2),
364 ("2026-10-16", 2),
365 ]),
366 TODAY,
367 );
368 assert_eq!(cal.longest_streak, 5);
369 assert_eq!(cal.current_streak, 2);
370 }
371
372 #[test]
373 fn an_old_run_is_not_the_current_streak() {
374 let cal = build(&counts(&[("2026-10-01", 3), ("2026-10-02", 3)]), TODAY);
375 assert_eq!(cal.current_streak, 0);
376 assert_eq!(cal.longest_streak, 2);
377 }
378
379 #[test]
380 fn best_day_is_the_highest_single_count() {
381 let cal = build(
382 &counts(&[("2026-10-01", 4), ("2026-10-02", 23), ("2026-10-03", 9)]),
383 TODAY,
384 );
385 assert_eq!(cal.best_day, 23);
386 assert_eq!(cal.total, 36);
387 }
388
389 #[test]
390 fn levels_follow_the_fixed_buckets() {
391 assert_eq!(
392 [0, 1, 3, 4, 7, 8, 12, 13, 500].map(level),
393 [0, 1, 1, 2, 2, 3, 3, 4, 4]
394 );
395 let cal = build(&counts(&[("2026-10-16", 8), ("2026-10-15", 1)]), TODAY);
396 assert_eq!(day(&cal, TODAY).level, 3);
397 assert_eq!(day(&cal, (2026, 10, 15)).level, 1);
398 assert_eq!(day(&cal, (2026, 10, 14)).level, 0);
399 }
400
401 #[test]
402 fn window_is_53_monday_first_weeks_ending_this_week() {
403 let cal = build(&[], TODAY);
404 // Today is a Friday: the last column holds Mon..Fri and the two weekend slots are blank.
405 assert_eq!(cal.first_day, (2025, 10, 13));
406 let first = cal.weeks[0][0].expect("first Monday is shown");
407 assert_eq!(first.date, (2025, 10, 13));
408 assert_eq!(weekday(days_from_civil(2025, 10, 13)), 0);
409 let last = &cal.weeks[WEEKS - 1];
410 assert_eq!(last[4].expect("today").date, TODAY);
411 assert!(last[5].is_none() && last[6].is_none());
412 assert_eq!(last[0].expect("Monday of this week").date, (2026, 10, 12));
413 }
414
415 #[test]
416 fn counts_outside_the_window_and_in_the_future_are_ignored() {
417 let cal = build(
418 &counts(&[("2025-10-12", 7), ("2025-10-13", 2), ("2026-10-17", 9)]),
419 TODAY,
420 );
421 assert_eq!(cal.total, 2);
422 assert_eq!(cal.best_day, 2);
423 assert_eq!(cal.current_streak, 0);
424 }
425
426 #[test]
427 fn malformed_dates_are_skipped() {
428 let cal = build(
429 &counts(&[
430 ("2026-02-30", 4),
431 ("2026/10/16", 4),
432 ("nope", 4),
433 ("2026-10-16", 1),
434 ]),
435 TODAY,
436 );
437 assert_eq!(cal.total, 1);
438 }
439
440 #[test]
441 fn month_labels_start_at_the_first_column_holding_the_first() {
442 let cal = build(&[], TODAY);
443 // 2025-11-01 is 19 days after the first Monday, so column 2; 2026-10-01 is column 50.
444 assert_eq!(cal.months[0], (2, 11));
445 assert!(cal.months.contains(&(50, 10)));
446 assert_eq!(cal.months.len(), 12);
447 }
448
449 #[test]
450 fn leap_day_is_a_real_date() {
451 let cal = build(
452 &counts(&[("2028-02-29", 4), ("2028-03-01", 1)]),
453 (2028, 3, 1),
454 );
455 assert_eq!(cal.total, 5);
456 assert_eq!(cal.current_streak, 2);
457 assert_eq!(
458 days_from_civil(2028, 3, 1) - days_from_civil(2028, 2, 28),
459 2
460 );
461 assert_eq!(parse_date("2027-02-29"), None);
462 assert_eq!(parse_date("2028-02-29"), Some(days_from_civil(2028, 2, 29)));
463 assert_eq!(civil_from_days(days_from_civil(2000, 2, 29)), (2000, 2, 29));
464 }
465
466 #[test]
467 fn days_from_civil_inverts_civil_from_days() {
468 for days in [-1, 0, 1, 10_957, 20_454, 47_000] {
469 let (y, m, d) = civil_from_days(days);
470 assert_eq!(days_from_civil(y, m, d), days);
471 }
472 }
473}