//! Dashboard contribution calendar: the last 53 weeks of daily contribution counts, one column per //! week (Monday first), with the four summary stats, month labels and the motivating line. //! //! `build` is pure date and streak maths over the counts from the store. `contribution_calendar` //! draws it with classes only (CSP): the grid placement comes from CSS (`grid-auto-flow: column` //! with seven rows), so every cell is an element and the blank slots keep the columns aligned. use std::collections::HashMap; use topcoat::Result; use topcoat::view::{View, component, view}; use super::civil_from_days; use crate::i18n::Lang; use crate::i18n::misc::{self as g, K}; /// Columns in the calendar: the week of today plus the 52 weeks before it. pub const WEEKS: usize = 53; /// Class of a cell at each level 0-4. Level 0 is "no contributions". const LEVEL_CLASS: [&str; 5] = ["cell l0", "cell l1", "cell l2", "cell l3", "cell l4"]; /// One day in the calendar. `date` is `(year, month, day)`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Day { pub date: (i64, i64, i64), pub count: u64, pub level: u8, } /// The calendar as the component draws it. `weeks[c][r]` is the day in column `c` (a week, /// Monday first) and row `r` (0 = Monday, 6 = Sunday); `None` for the days after today. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Calendar { pub weeks: Vec<[Option; 7]>, /// Column and month number (1-12) where a month label starts: the first column that holds /// the 1st of that month. pub months: Vec<(usize, i64)>, /// First day shown (the Monday that starts column 0). pub first_day: (i64, i64, i64), /// Today, the last day shown. pub today: (i64, i64, i64), /// Contributions in the window. pub total: u64, /// Consecutive days with at least one contribution, ending today; or ending yesterday when /// today has none (so an unfinished day does not break the streak). pub current_streak: u64, /// The longest run of days with at least one contribution in the window. pub longest_streak: u64, /// The highest count on a single day. pub best_day: u64, /// Today's count. pub today_count: u64, } /// Level for a day's count: 0 for none, then fixed buckets 1-3 (1), 4-7 (2), 8-12 (3) and 13 or /// more (4). Fixed buckets, rather than quartiles of the year, so a colour means the same count /// on every page load. pub fn level(count: u64) -> u8 { match count { 0 => 0, 1..=3 => 1, 4..=7 => 2, 8..=12 => 3, _ => 4, } } /// Builds the calendar for the 53 weeks ending with today's week. `counts` holds /// `(YYYY-MM-DD, count)` pairs, ascending, as the store returns them; dates outside the window /// and after today are ignored, and malformed dates are skipped. `today` is `(year, month, day)`. pub fn build(counts: &[(String, u32)], today: (i64, i64, i64)) -> Calendar { let today_days = days_from_civil(today.0, today.1, today.2); let by_day: HashMap = counts .iter() .filter_map(|(date, count)| parse_date(date).map(|days| (days, u64::from(*count)))) .collect(); let count_on = |days: i64| by_day.get(&days).copied().unwrap_or(0); // Column 0 starts on the Monday that is WEEKS - 1 weeks before the Monday of today's week. let this_monday = today_days - i64::from(weekday(today_days)); let first_days = this_monday - 7 * (WEEKS as i64 - 1); let mut weeks = Vec::with_capacity(WEEKS); let mut months = Vec::new(); let (mut total, mut best_day, mut longest, mut run) = (0, 0, 0, 0); for column in 0..WEEKS { let mut week: [Option; 7] = Default::default(); for (row, slot) in week.iter_mut().enumerate() { let days = first_days + (column * 7 + row) as i64; if days > today_days { continue; } let count = count_on(days); let date = civil_from_days(days); if date.2 == 1 { months.push((column, date.1)); } total += count; best_day = best_day.max(count); run = if count > 0 { run + 1 } else { 0 }; longest = longest.max(run); *slot = Some(Day { date, count, level: level(count), }); } weeks.push(week); } // The current streak starts at today, or at yesterday when today has no contributions yet. let today_count = count_on(today_days); let mut cursor = if today_count > 0 { today_days } else { today_days - 1 }; let mut current_streak = 0; while cursor >= first_days && count_on(cursor) > 0 { current_streak += 1; cursor -= 1; } Calendar { weeks, months, first_day: civil_from_days(first_days), today, total, current_streak, longest_streak: longest, best_day, today_count, } } /// Parses a strict `YYYY-MM-DD` date into days since 1970-01-01. Rejects impossible dates such as /// 2027-02-29, by converting back and comparing. fn parse_date(text: &str) -> Option { let bytes = text.as_bytes(); if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { return None; } let year: i64 = text.get(0..4)?.parse().ok()?; let month: i64 = text.get(5..7)?.parse().ok()?; let day: i64 = text.get(8..10)?.parse().ok()?; let days = days_from_civil(year, month, day); (civil_from_days(days) == (year, month, day)).then_some(days) } /// Days since 1970-01-01 for `(year, month, day)`. Howard Hinnant's days-from-civil algorithm, the /// inverse of `civil_from_days`. fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { let y = if month <= 2 { year - 1 } else { year }; let era = y.div_euclid(400); let yoe = y - era * 400; let mp = (month + 9) % 12; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468 } /// Monday = 0 through Sunday = 6. 1970-01-01 was a Thursday. fn weekday(days: i64) -> u8 { (days + 3).rem_euclid(7) as u8 } /// One grid slot as the view draws it. struct CellView { class: &'static str, title: String, } /// The contribution card: heading and date range, the four stats, the month labels and the /// 53-week grid, then the motivating line and the Less/More legend. #[component] pub async fn contribution_calendar(calendar: Calendar, lang: Lang) -> Result { let range = { let from = g::date_text( lang, calendar.first_day.0, calendar.first_day.1, calendar.first_day.2, ); let to = g::date_text(lang, calendar.today.0, calendar.today.1, calendar.today.2); g::date_range(lang, &from, &to) }; let summary = g::heatmap_summary( lang, calendar.total, calendar.current_streak, calendar.longest_streak, ); let motivation = g::heatmap_motivation(lang, calendar.today_count, calendar.current_streak); let total = g::thousands(lang, calendar.total); let current = g::streak_days(lang, calendar.current_streak); let longest = g::streak_days(lang, calendar.longest_streak); let best = g::contributions_count(lang, calendar.best_day); let mon = g::t(lang, K::MonLabel); let wed = g::t(lang, K::WedLabel); let fri = g::t(lang, K::FriLabel); let less = g::t(lang, K::Less); let more = g::t(lang, K::More); // Month labels: one slot per column, empty where no label starts. let mut month_slots = vec![String::new(); WEEKS]; for (column, month) in &calendar.months { month_slots[*column] = g::month_abbrev(lang, *month).to_owned(); } // Cells in column order (7 per column), so auto-flow puts each day in its row. let cells: Vec = calendar .weeks .iter() .flat_map(|week| week.iter()) .map(|slot| match slot { Some(day) => CellView { class: LEVEL_CLASS[usize::from(day.level)], title: g::contributions_on( lang, day.count, &g::date_text(lang, day.date.0, day.date.1, day.date.2), ), }, None => CellView { class: "cell blank", title: String::new(), }, }) .collect(); Ok(view! {

(g::t(lang, K::ContributionsTitle))

(range.as_str())

(total.as_str()) (g::t(lang, K::ContributionsLastYear))
(current.as_str()) (g::t(lang, K::CurrentStreak))
(longest.as_str()) (g::t(lang, K::LongestStreak))
(best.as_str()) (g::t(lang, K::BestDay))

(motivation.as_str())

}) } #[cfg(test)] mod tests { use super::*; const TODAY: (i64, i64, i64) = (2026, 10, 16); fn counts(pairs: &[(&str, u32)]) -> Vec<(String, u32)> { pairs.iter().map(|(d, n)| ((*d).to_owned(), *n)).collect() } fn day(cal: &Calendar, date: (i64, i64, i64)) -> Day { cal.weeks .iter() .flatten() .flatten() .find(|d| d.date == date) .copied() .expect("day is in the window") } #[test] fn empty_input_gives_an_empty_calendar() { let cal = build(&[], TODAY); assert_eq!(cal.weeks.len(), WEEKS); assert_eq!(cal.total, 0); assert_eq!(cal.current_streak, 0); assert_eq!(cal.longest_streak, 0); assert_eq!(cal.best_day, 0); assert_eq!(cal.today_count, 0); assert!(cal.weeks.iter().flatten().flatten().all(|d| d.level == 0)); } #[test] fn streak_ending_today_counts_today() { let cal = build( &counts(&[("2026-10-14", 2), ("2026-10-15", 1), ("2026-10-16", 5)]), TODAY, ); assert_eq!(cal.current_streak, 3); assert_eq!(cal.longest_streak, 3); assert_eq!(cal.today_count, 5); assert_eq!(cal.total, 8); } #[test] fn streak_ending_yesterday_survives_an_empty_today() { let cal = build( &counts(&[("2026-10-13", 1), ("2026-10-14", 1), ("2026-10-15", 1)]), TODAY, ); assert_eq!(cal.today_count, 0); assert_eq!(cal.current_streak, 3); } #[test] fn a_gap_ends_the_current_streak_but_not_the_longest() { let cal = build( &counts(&[ ("2026-09-20", 1), ("2026-09-21", 1), ("2026-09-22", 1), ("2026-09-23", 1), ("2026-09-24", 1), ("2026-10-15", 2), ("2026-10-16", 2), ]), TODAY, ); assert_eq!(cal.longest_streak, 5); assert_eq!(cal.current_streak, 2); } #[test] fn an_old_run_is_not_the_current_streak() { let cal = build(&counts(&[("2026-10-01", 3), ("2026-10-02", 3)]), TODAY); assert_eq!(cal.current_streak, 0); assert_eq!(cal.longest_streak, 2); } #[test] fn best_day_is_the_highest_single_count() { let cal = build( &counts(&[("2026-10-01", 4), ("2026-10-02", 23), ("2026-10-03", 9)]), TODAY, ); assert_eq!(cal.best_day, 23); assert_eq!(cal.total, 36); } #[test] fn levels_follow_the_fixed_buckets() { assert_eq!( [0, 1, 3, 4, 7, 8, 12, 13, 500].map(level), [0, 1, 1, 2, 2, 3, 3, 4, 4] ); let cal = build(&counts(&[("2026-10-16", 8), ("2026-10-15", 1)]), TODAY); assert_eq!(day(&cal, TODAY).level, 3); assert_eq!(day(&cal, (2026, 10, 15)).level, 1); assert_eq!(day(&cal, (2026, 10, 14)).level, 0); } #[test] fn window_is_53_monday_first_weeks_ending_this_week() { let cal = build(&[], TODAY); // Today is a Friday: the last column holds Mon..Fri and the two weekend slots are blank. assert_eq!(cal.first_day, (2025, 10, 13)); let first = cal.weeks[0][0].expect("first Monday is shown"); assert_eq!(first.date, (2025, 10, 13)); assert_eq!(weekday(days_from_civil(2025, 10, 13)), 0); let last = &cal.weeks[WEEKS - 1]; assert_eq!(last[4].expect("today").date, TODAY); assert!(last[5].is_none() && last[6].is_none()); assert_eq!(last[0].expect("Monday of this week").date, (2026, 10, 12)); } #[test] fn counts_outside_the_window_and_in_the_future_are_ignored() { let cal = build( &counts(&[("2025-10-12", 7), ("2025-10-13", 2), ("2026-10-17", 9)]), TODAY, ); assert_eq!(cal.total, 2); assert_eq!(cal.best_day, 2); assert_eq!(cal.current_streak, 0); } #[test] fn malformed_dates_are_skipped() { let cal = build( &counts(&[ ("2026-02-30", 4), ("2026/10/16", 4), ("nope", 4), ("2026-10-16", 1), ]), TODAY, ); assert_eq!(cal.total, 1); } #[test] fn month_labels_start_at_the_first_column_holding_the_first() { let cal = build(&[], TODAY); // 2025-11-01 is 19 days after the first Monday, so column 2; 2026-10-01 is column 50. assert_eq!(cal.months[0], (2, 11)); assert!(cal.months.contains(&(50, 10))); assert_eq!(cal.months.len(), 12); } #[test] fn leap_day_is_a_real_date() { let cal = build( &counts(&[("2028-02-29", 4), ("2028-03-01", 1)]), (2028, 3, 1), ); assert_eq!(cal.total, 5); assert_eq!(cal.current_streak, 2); assert_eq!( days_from_civil(2028, 3, 1) - days_from_civil(2028, 2, 28), 2 ); assert_eq!(parse_date("2027-02-29"), None); assert_eq!(parse_date("2028-02-29"), Some(days_from_civil(2028, 2, 29))); assert_eq!(civil_from_days(days_from_civil(2000, 2, 29)), (2000, 2, 29)); } #[test] fn days_from_civil_inverts_civil_from_days() { for days in [-1, 0, 1, 10_957, 20_454, 47_000] { let (y, m, d) = civil_from_days(days); assert_eq!(days_from_civil(y, m, d), days); } } }