days.rsannotateddays.rssource627 lines · 26.1 KB · raw

The calendar days of her memories: which local day a memory was learned on, what the day is called, and the kind holiday it may be.

The core keeps UTC. The shell supplies, for each moment, the offset of her time zone from UTC at that moment (so a clock change is right), and the day number of today (as for [crate::LocalMoment]; day 0 is 1970-01-01, a Thursday). There is no second time source here: everything is a pure function of those numbers.

A day is called, newest wording first:

The dayIts labelIts subtitle
today"Today"the holiday, if it is one
yesterday"Yesterday"the holiday, if it is one
an older day that is a holidaythe holiday's namenone
one of the five days before yesterdaythe weekday: "Saturday"none
anything older, or a day in the futurethe date: "July 15" (with the year when it is not this year)none

[group_by_day] sorts memories into those days, newest day first and newest memory first inside a day. The date scroller in the app takes its positions from the same groups as the dividers, so the two cannot disagree.

The holidays are the ones a six-year-old knows, each from a fixed rule ([holiday_on]). None of them is about alcohol or money. No two of them can fall on the same day (a test checks every day of 400 years), so a day is one holiday or none.

25use crate::greeting::Weekday;
26use whiskers_icons::IconId;
28const MINUTE_MS: u64 = 60_000;
29const DAY_MINUTES: i64 = 24 * 60;

A calendar date in the proleptic Gregorian calendar.

32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct CivilDate {
34    pub year: i32,

1 to 12.

36    pub month: u8,

1 to 31.

38    pub day: u8,
39}
41impl CivilDate {
42    pub fn new(year: i32, month: u8, day: u8) -> CivilDate {
43        CivilDate { year, month, day }
44    }

The date of a day number (day 0 is 1970-01-01).

47    pub fn of_day(day: i64) -> CivilDate {
48        let z = day + 719_468;
49        let era = z.div_euclid(146_097);
50        let doe = z.rem_euclid(146_097);
51        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
52        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
53        let mp = (5 * doy + 2) / 153;
54        let d = doy - (153 * mp + 2) / 5 + 1;
55        let m = if mp < 10 { mp + 3 } else { mp - 9 };
56        let y = yoe + era * 400 + i64::from(m <= 2);
57        CivilDate { year: y as i32, month: m as u8, day: d as u8 }
58    }

The day number of this date.

61    pub fn day_number(self) -> i64 {
62        let y = i64::from(self.year) - i64::from(self.month <= 2);
63        let era = y.div_euclid(400);
64        let yoe = y.rem_euclid(400);
65        let m = i64::from(self.month);
66        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(self.day) - 1;
67        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
68        era * 146_097 + doe - 719_468
69    }
71    pub fn weekday(self) -> Weekday {
72        weekday_of(self.day_number())
73    }
74
75    fn month_name(self) -> &'static str {
76        const NAMES: [&str; 12] =
77            ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
78        NAMES[usize::from(self.month) - 1]
79    }
80}

The weekday of a day number (day 0 is a Thursday).

83pub fn weekday_of(day: i64) -> Weekday {
84    match (day + 3).rem_euclid(7) {
85        0 => Weekday::Monday,
86        1 => Weekday::Tuesday,
87        2 => Weekday::Wednesday,
88        3 => Weekday::Thursday,
89        4 => Weekday::Friday,
90        5 => Weekday::Saturday,
91        _ => Weekday::Sunday,
92    }
93}
95fn weekday_name(w: Weekday) -> &'static str {
96    match w {
97        Weekday::Monday => "Monday",
98        Weekday::Tuesday => "Tuesday",
99        Weekday::Wednesday => "Wednesday",
100        Weekday::Thursday => "Thursday",
101        Weekday::Friday => "Friday",
102        Weekday::Saturday => "Saturday",
103        Weekday::Sunday => "Sunday",
104    }
105}

The local day number of a moment, given the offset of her time zone from UTC at that moment, in minutes (east of UTC is positive).

109pub fn local_day(at_ms: u64, offset_minutes: i32) -> i64 {
110    ((at_ms / MINUTE_MS) as i64 + i64::from(offset_minutes)).div_euclid(DAY_MINUTES)
111}

A holiday a young child knows.

114#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
115pub enum Holiday {
116    NewYearsDay,
117    MartinLutherKingJrDay,
118    ValentinesDay,
119    PresidentsDay,
120    StPatricksDay,
121    Easter,
122    MothersDay,
123    MemorialDay,
124    FathersDay,
125    IndependenceDay,
126    LaborDay,
127    Halloween,
128    Thanksgiving,
129    ChristmasEve,
130    Christmas,
131    NewYearsEve,
132}
134impl Holiday {

Every holiday, in the order of the year where it has one.

136    pub const ALL: [Holiday; 16] = [
137        Holiday::NewYearsDay,
138        Holiday::MartinLutherKingJrDay,
139        Holiday::ValentinesDay,
140        Holiday::PresidentsDay,
141        Holiday::StPatricksDay,
142        Holiday::Easter,
143        Holiday::MothersDay,
144        Holiday::MemorialDay,
145        Holiday::FathersDay,
146        Holiday::IndependenceDay,
147        Holiday::LaborDay,
148        Holiday::Halloween,
149        Holiday::Thanksgiving,
150        Holiday::ChristmasEve,
151        Holiday::Christmas,
152        Holiday::NewYearsEve,
153    ];

What it is called, as a title.

156    pub fn name(self) -> &'static str {
157        match self {
158            Holiday::NewYearsDay => "New Year's Day",
159            Holiday::MartinLutherKingJrDay => "Martin Luther King Jr. Day",
160            Holiday::ValentinesDay => "Valentine's Day",
161            Holiday::PresidentsDay => "Presidents' Day",
162            Holiday::StPatricksDay => "St. Patrick's Day",
163            Holiday::Easter => "Easter",
164            Holiday::MothersDay => "Mother's Day",
165            Holiday::MemorialDay => "Memorial Day",
166            Holiday::FathersDay => "Father's Day",
167            Holiday::IndependenceDay => "The Fourth of July",
168            Holiday::LaborDay => "Labor Day",
169            Holiday::Halloween => "Halloween",
170            Holiday::Thanksgiving => "Thanksgiving",
171            Holiday::ChristmasEve => "Christmas Eve",
172            Holiday::Christmas => "Christmas",
173            Holiday::NewYearsEve => "New Year's Eve",
174        }
175    }

The admitted pixel icon that stands for it. The set has no flag, firework, turkey or clover, so the Fourth of July is a rocket, Thanksgiving an apple and St. Patrick's Day a rainbow.

179    pub fn icon(self) -> IconId {
180        let name = match self {
181            Holiday::NewYearsDay => "sparkles",
182            Holiday::MartinLutherKingJrDay => "earth",
183            Holiday::ValentinesDay => "heart",
184            Holiday::PresidentsDay => "house",
185            Holiday::StPatricksDay => "rainbow",
186            Holiday::Easter => "egg",
187            Holiday::MothersDay => "rose",
188            Holiday::MemorialDay => "ribbon",
189            Holiday::FathersDay => "sunglasses",
190            Holiday::IndependenceDay => "rocket",
191            Holiday::LaborDay => "truck",
192            Holiday::Halloween => "pumpkin",
193            Holiday::Thanksgiving => "apple",
194            Holiday::ChristmasEve => "xmas-stocking",
195            Holiday::Christmas => "christmas-tree",
196            Holiday::NewYearsEve => "mirror-ball",
197        };
198        IconId::new(name).expect("a holiday's icon is on the allowlist")
199    }
200}

The day number of the nth (1-based) weekday of a month.

203fn nth_weekday(year: i32, month: u8, weekday: Weekday, n: i64) -> i64 {
204    let first = CivilDate::new(year, month, 1).day_number();
205    let ahead = (weekday_index(weekday) - weekday_index(weekday_of(first))).rem_euclid(7);
206    first + ahead + 7 * (n - 1)
207}

The day number of the last weekday of a month.

210fn last_weekday(year: i32, month: u8, weekday: Weekday) -> i64 {
211    let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
212    let last = CivilDate::new(ny, nm, 1).day_number() - 1;
213    last - (weekday_index(weekday_of(last)) - weekday_index(weekday)).rem_euclid(7)
214}
216fn weekday_index(w: Weekday) -> i64 {
217    match w {
218        Weekday::Monday => 0,
219        Weekday::Tuesday => 1,
220        Weekday::Wednesday => 2,
221        Weekday::Thursday => 3,
222        Weekday::Friday => 4,
223        Weekday::Saturday => 5,
224        Weekday::Sunday => 6,
225    }
226}

The date of Easter Sunday in the Gregorian calendar (the anonymous Gregorian computus).

229pub fn easter(year: i32) -> CivilDate {
230    let a = year % 19;
231    let b = year / 100;
232    let c = year % 100;
233    let d = b / 4;
234    let e = b % 4;
235    let f = (b + 8) / 25;
236    let g = (b - f + 1) / 3;
237    let h = (19 * a + b - d - g + 15) % 30;
238    let i = c / 4;
239    let k = c % 4;
240    let l = (32 + 2 * e + 2 * i - h - k) % 7;
241    let m = (a + 11 * h + 22 * l) / 451;
242    let month = (h + l - 7 * m + 114) / 31;
243    let day = (h + l - 7 * m + 114) % 31 + 1;
244    CivilDate::new(year, month as u8, day as u8)
245}

The holiday on a date, if it is one.

248pub fn holiday_on(date: CivilDate) -> Option<Holiday> {
249    let day = date.day_number();
250    let year = date.year;
251    let fixed = match (date.month, date.day) {
252        (1, 1) => Some(Holiday::NewYearsDay),
253        (2, 14) => Some(Holiday::ValentinesDay),
254        (3, 17) => Some(Holiday::StPatricksDay),
255        (7, 4) => Some(Holiday::IndependenceDay),
256        (10, 31) => Some(Holiday::Halloween),
257        (12, 24) => Some(Holiday::ChristmasEve),
258        (12, 25) => Some(Holiday::Christmas),
259        (12, 31) => Some(Holiday::NewYearsEve),
260        _ => None,
261    };
262    if fixed.is_some() {
263        return fixed;
264    }
265    // The days that move: by weekday rule, or from Easter.
266    if date.month == 3 || date.month == 4 {
267        return (easter(year) == date).then_some(Holiday::Easter);
268    }
269    match date.month {
270        1 => (day == nth_weekday(year, 1, Weekday::Monday, 3)).then_some(Holiday::MartinLutherKingJrDay),
271        2 => (day == nth_weekday(year, 2, Weekday::Monday, 3)).then_some(Holiday::PresidentsDay),
272        5 if day == nth_weekday(year, 5, Weekday::Sunday, 2) => Some(Holiday::MothersDay),
273        5 if day == last_weekday(year, 5, Weekday::Monday) => Some(Holiday::MemorialDay),
274        6 => (day == nth_weekday(year, 6, Weekday::Sunday, 3)).then_some(Holiday::FathersDay),
275        9 => (day == nth_weekday(year, 9, Weekday::Monday, 1)).then_some(Holiday::LaborDay),
276        11 => (day == nth_weekday(year, 11, Weekday::Thursday, 4)).then_some(Holiday::Thanksgiving),
277        _ => None,
278    }
279}

What a day is called on a divider.

282#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct DayName {

The big words: "Today", "Yesterday", "Saturday", "July 15", or a holiday's name.

285    pub label: String,

The holiday's name when the label is "Today" or "Yesterday" and the day is a holiday; otherwise none.

287    pub subtitle: Option<String>,

The holiday, whichever way it is worded, for its picture on the scroller.

289    pub holiday: Option<Holiday>,
290}

How many days back a bare weekday name still means this week: the five before yesterday.

293const WEEKDAY_DAYS_BACK: i64 = 6;

The name of local day day as seen on local day today.

296pub fn day_name(day: i64, today: i64) -> DayName {
297    let date = CivilDate::of_day(day);
298    let holiday = holiday_on(date);
299    let back = today - day;
300    let plain = |label: String| DayName { label, subtitle: None, holiday };
301    let near = |label: &str| DayName { label: label.to_owned(), subtitle: holiday.map(|h| h.name().to_owned()), holiday };
302    match back {
303        0 => near("Today"),
304        1 => near("Yesterday"),
305        _ if holiday.is_some() => plain(holiday.map_or_else(String::new, |h| h.name().to_owned())),
306        2..=WEEKDAY_DAYS_BACK => plain(weekday_name(date.weekday()).to_owned()),
307        _ => {
308            let year = if date.year == CivilDate::of_day(today).year { String::new() } else { format!(", {}", date.year) };
309            plain(format!("{} {}{year}", date.month_name(), date.day))
310        }
311    }
312}

One entry of the timeline: a memory's id, a moment she said it (UTC) and the offset of her time zone from UTC at that moment, in minutes. A memory she told on three days is three of these, with one id; a memory is placed on every day she said it, once each.

317#[derive(Clone, Copy, Debug, PartialEq, Eq)]
318pub struct Stamped {
319    pub id: u64,
320    pub said_at_ms: u64,
321    pub offset_minutes: i32,
322}

One local day of memories.

325#[derive(Clone, Debug, PartialEq, Eq)]
326pub struct DayGroup {
327    pub day: i64,
328    pub name: DayName,

The ids said that day, newest first (on the same moment, the larger id first), each once however often she said it.

330    pub ids: Vec<u64>,
331}

Mentions cut into local days, the newest day first, as seen on local day today.

334pub fn group_by_day(memories: &[Stamped], today: i64) -> Vec<DayGroup> {
335    let mut sorted: Vec<(i64, &Stamped)> = memories.iter().map(|m| (local_day(m.said_at_ms, m.offset_minutes), m)).collect();
336    sorted.sort_by(|(da, a), (db, b)| db.cmp(da).then(b.said_at_ms.cmp(&a.said_at_ms)).then(b.id.cmp(&a.id)));
337    let mut groups: Vec<DayGroup> = Vec::new();
338    for (day, m) in sorted {
339        match groups.last_mut() {
340            Some(g) if g.day == day => {
341                if !g.ids.contains(&m.id) {
342                    g.ids.push(m.id);
343                }
344            }
345            _ => groups.push(DayGroup { day, name: day_name(day, today), ids: vec![m.id] }),
346        }
347    }
348    groups
349}
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn d(y: i32, m: u8, day: u8) -> CivilDate {
356        CivilDate::new(y, m, day)
357    }
358
359    fn holiday(y: i32, m: u8, day: u8) -> Option<Holiday> {
360        holiday_on(d(y, m, day))
361    }
362
363    #[test]
364    fn day_numbers_and_dates_agree_both_ways() {
365        assert_eq!(CivilDate::of_day(0), d(1970, 1, 1));
366        assert_eq!(d(1970, 1, 1).day_number(), 0);
367        assert_eq!(d(2026, 10, 5).weekday(), Weekday::Monday);
368        for day in -800..40_000 {
369            assert_eq!(CivilDate::of_day(day).day_number(), day);
370        }
371    }
372
373    #[test]
374    fn the_leap_day_exists_in_a_leap_year_only() {
375        assert_eq!(d(2028, 2, 29).day_number() + 1, d(2028, 3, 1).day_number());
376        assert_eq!(d(2027, 2, 28).day_number() + 1, d(2027, 3, 1).day_number());
377        assert_eq!(CivilDate::of_day(d(2028, 2, 29).day_number()), d(2028, 2, 29));
378        // 2100 is not a leap year, 2000 is.
379        assert_eq!(d(2100, 2, 28).day_number() + 1, d(2100, 3, 1).day_number());
380        assert_eq!(d(2000, 2, 29).day_number() + 1, d(2000, 3, 1).day_number());
381    }
382
383    #[test]
384    fn the_fixed_holidays() {
385        assert_eq!(holiday(2026, 1, 1), Some(Holiday::NewYearsDay));
386        assert_eq!(holiday(2026, 2, 14), Some(Holiday::ValentinesDay));
387        assert_eq!(holiday(2026, 3, 17), Some(Holiday::StPatricksDay));
388        assert_eq!(holiday(2026, 7, 4), Some(Holiday::IndependenceDay));
389        assert_eq!(holiday(2026, 10, 31), Some(Holiday::Halloween));
390        assert_eq!(holiday(2026, 12, 24), Some(Holiday::ChristmasEve));
391        assert_eq!(holiday(2026, 12, 25), Some(Holiday::Christmas));
392        assert_eq!(holiday(2026, 12, 31), Some(Holiday::NewYearsEve));
393        assert_eq!(holiday(2026, 7, 5), None);
394    }
395
396    #[test]
397    fn thanksgiving_is_the_fourth_thursday_of_november() {
398        // November 2026 starts on a Sunday: Thursdays are the 5th, 12th, 19th and 26th.
399        assert_eq!(holiday(2026, 11, 26), Some(Holiday::Thanksgiving));
400        assert_eq!(holiday(2026, 11, 19), None);
401        // November 2025 starts on a Saturday: the Thursdays are 6, 13, 20, 27.
402        assert_eq!(holiday(2025, 11, 27), Some(Holiday::Thanksgiving));
403        // November 2023 starts on a Wednesday: the fourth Thursday is the 23rd.
404        assert_eq!(holiday(2023, 11, 23), Some(Holiday::Thanksgiving));
405    }
406
407    #[test]
408    fn easter_is_computed_and_differs_by_year() {
409        assert_eq!(easter(2025), d(2025, 4, 20));
410        assert_eq!(easter(2026), d(2026, 4, 5));
411        assert_eq!(easter(2024), d(2024, 3, 31));
412        assert_eq!(easter(2038), d(2038, 4, 25));
413        assert_eq!(holiday(2025, 4, 20), Some(Holiday::Easter));
414        assert_eq!(holiday(2026, 4, 5), Some(Holiday::Easter));
415        assert_eq!(holiday(2026, 4, 20), None, "last year's Easter date is an ordinary day");
416        assert_eq!(holiday(2024, 3, 31), Some(Holiday::Easter), "Easter can fall in March");
417        for year in 1900..2200 {
418            assert_eq!(easter(year).weekday(), Weekday::Sunday);
419        }
420    }
421
422    #[test]
423    fn mothers_and_fathers_day_are_sundays() {
424        assert_eq!(holiday(2026, 5, 10), Some(Holiday::MothersDay));
425        assert_eq!(holiday(2026, 5, 3), None);
426        assert_eq!(holiday(2026, 6, 21), Some(Holiday::FathersDay));
427        assert_eq!(holiday(2025, 5, 11), Some(Holiday::MothersDay));
428        assert_eq!(holiday(2025, 6, 15), Some(Holiday::FathersDay));
429    }
430
431    #[test]
432    fn memorial_day_is_the_last_monday_of_may_and_labor_day_the_first_of_september() {
433        assert_eq!(holiday(2026, 5, 25), Some(Holiday::MemorialDay));
434        assert_eq!(holiday(2025, 5, 26), Some(Holiday::MemorialDay));
435        // May 2020 ended on a Sunday, so the last Monday was the 25th; in 2021 the 31st was a Monday.
436        assert_eq!(holiday(2021, 5, 31), Some(Holiday::MemorialDay));
437        assert_eq!(holiday(2026, 9, 7), Some(Holiday::LaborDay));
438        // September 2025 began on a Monday: the 1st itself is Labor Day.
439        assert_eq!(holiday(2025, 9, 1), Some(Holiday::LaborDay));
440        assert_eq!(holiday(2025, 9, 8), None);
441    }
442
443    #[test]
444    fn martin_luther_king_jr_day_and_presidents_day_are_the_third_monday() {
445        assert_eq!(holiday(2026, 1, 19), Some(Holiday::MartinLutherKingJrDay));
446        assert_eq!(holiday(2025, 1, 20), Some(Holiday::MartinLutherKingJrDay));
447        assert_eq!(holiday(2026, 2, 16), Some(Holiday::PresidentsDay));
448        assert_eq!(holiday(2025, 2, 17), Some(Holiday::PresidentsDay));
449        assert_eq!(holiday(2026, 2, 9), None);
450    }
451
452    #[test]
453    fn no_day_is_two_holidays_and_every_holiday_comes_up_every_year() {
454        for year in 1990..2400 {
455            let mut seen = Vec::new();
456            for day in d(year, 1, 1).day_number()..=d(year, 12, 31).day_number() {
457                if let Some(h) = holiday_on(CivilDate::of_day(day)) {
458                    seen.push(h);
459                }
460            }
461            assert_eq!(seen.len(), Holiday::ALL.len(), "year {year}: {seen:?}");
462            for h in Holiday::ALL {
463                assert_eq!(seen.iter().filter(|s| **s == h).count(), 1, "year {year}: {h:?}");
464            }
465        }
466    }
467
468    #[test]
469    fn every_holiday_has_an_admitted_icon_and_a_name() {
470        for h in Holiday::ALL {
471            assert!(!h.icon().as_str().is_empty());
472            assert!(!h.name().is_empty());
473        }
474    }
475
476    #[test]
477    fn local_midnight_moves_a_moment_to_the_next_day() {
478        // 2026-10-05 23:30 in a zone 5 hours behind UTC is 2026-10-06 04:30 UTC.
479        let utc = d(2026, 10, 6).day_number() as u64 * 86_400_000 + (4 * 60 + 30) * 60_000;
480        assert_eq!(local_day(utc, -300), d(2026, 10, 5).day_number());
481        // One minute later, at 00:00 local, it is the next day.
482        assert_eq!(local_day(utc + 30 * 60_000, -300), d(2026, 10, 6).day_number());
483        // The same moment in a zone east of UTC is already on the 6th, and 23:59 before local midnight is not.
484        assert_eq!(local_day(utc, 120), d(2026, 10, 6).day_number());
485        let before_midnight = d(2026, 10, 6).day_number() as u64 * 86_400_000 + 4 * 3_600_000 + 59 * 60_000;
486        assert_eq!(local_day(before_midnight, -300), d(2026, 10, 5).day_number());
487        assert_eq!(local_day(before_midnight + 60_000, -300), d(2026, 10, 6).day_number());
488    }
489
490    #[test]
491    fn a_clock_change_between_two_memories_is_read_from_each_offset() {
492        // The same UTC minute reads as the 5th at -5h and the 4th at -9h: the shell supplies the offset of the moment.
493        let at = d(2026, 11, 5).day_number() as u64 * 86_400_000 + 2 * 3_600_000;
494        assert_eq!(local_day(at, -300), d(2026, 11, 4).day_number());
495        assert_eq!(local_day(at, -180), d(2026, 11, 4).day_number());
496        assert_eq!(local_day(at, 0), d(2026, 11, 5).day_number());
497        assert_eq!(local_day(at, -540), d(2026, 11, 4).day_number());
498    }
499
500    #[test]
501    fn today_and_yesterday_stay_and_carry_the_holiday_beside_them() {
502        let halloween = d(2026, 10, 31).day_number();
503        let n = day_name(halloween, halloween);
504        assert_eq!(n.label, "Today");
505        assert_eq!(n.subtitle.as_deref(), Some("Halloween"));
506        assert_eq!(n.holiday, Some(Holiday::Halloween));
507        let n = day_name(halloween, halloween + 1);
508        assert_eq!(n.label, "Yesterday");
509        assert_eq!(n.subtitle.as_deref(), Some("Halloween"));
510        let ordinary = d(2026, 10, 5).day_number();
511        assert_eq!(day_name(ordinary, ordinary), DayName { label: "Today".into(), subtitle: None, holiday: None });
512        assert_eq!(day_name(ordinary, ordinary + 1).label, "Yesterday");
513    }
514
515    #[test]
516    fn the_week_is_named_by_weekday_and_older_days_by_date() {
517        let today = d(2026, 10, 5).day_number(); // a Monday
518        assert_eq!(day_name(today - 2, today).label, "Saturday");
519        assert_eq!(day_name(today - 3, today).label, "Friday");
520        assert_eq!(day_name(today - 6, today).label, "Tuesday");
521        assert_eq!(day_name(today - 7, today).label, "September 28");
522        assert_eq!(day_name(d(2026, 7, 15).day_number(), today).label, "July 15");
523        assert_eq!(day_name(d(2026, 7, 14).day_number(), today).label, "July 14");
524    }
525
526    #[test]
527    fn a_holiday_older_than_yesterday_wins_over_the_weekday_and_the_date() {
528        let today = d(2026, 11, 30).day_number(); // a Monday
529        let n = day_name(d(2026, 11, 26).day_number(), today);
530        assert_eq!(n.label, "Thanksgiving");
531        assert_eq!(n.subtitle, None);
532        assert_eq!(n.holiday, Some(Holiday::Thanksgiving));
533        assert_eq!(day_name(d(2026, 10, 31).day_number(), today).label, "Halloween");
534        assert_eq!(day_name(d(2026, 7, 4).day_number(), today).label, "The Fourth of July");
535    }
536
537    #[test]
538    fn a_year_change_names_the_year_only_when_it_is_not_this_one() {
539        let today = d(2027, 1, 2).day_number();
540        assert_eq!(day_name(d(2026, 12, 20).day_number(), today).label, "December 20, 2026");
541        assert_eq!(day_name(d(2026, 12, 31).day_number(), today).label, "New Year's Eve");
542        assert_eq!(day_name(d(2027, 1, 1).day_number(), today).label, "Yesterday");
543        assert_eq!(day_name(d(2027, 1, 1).day_number(), today).subtitle.as_deref(), Some("New Year's Day"));
544        let later = d(2027, 3, 1).day_number();
545        assert_eq!(day_name(d(2027, 1, 1).day_number(), later).label, "New Year's Day");
546        assert_eq!(day_name(d(2027, 1, 3).day_number(), later).label, "January 3");
547    }
548
549    #[test]
550    fn a_leap_day_is_named_and_the_days_around_it_are_in_order() {
551        let today = d(2028, 6, 1).day_number();
552        assert_eq!(day_name(d(2028, 2, 29).day_number(), today).label, "February 29");
553        assert_eq!(day_name(d(2028, 3, 1).day_number(), today).label, "March 1");
554    }
555
556    #[test]
557    fn a_day_in_the_future_is_a_date_not_today() {
558        let today = d(2026, 10, 5).day_number();
559        assert_eq!(day_name(today + 1, today).label, "October 6");
560    }
561
562    fn at(date: CivilDate, minute: u64) -> u64 {
563        date.day_number() as u64 * 86_400_000 + minute * 60_000
564    }
565
566    #[test]
567    fn memories_are_cut_into_days_newest_first() {
568        let today = d(2026, 10, 5).day_number();
569        let ms = [
570            Stamped { id: 1, said_at_ms: at(d(2026, 10, 3), 600), offset_minutes: 0 },
571            Stamped { id: 2, said_at_ms: at(d(2026, 10, 5), 60), offset_minutes: 0 },
572            Stamped { id: 3, said_at_ms: at(d(2026, 10, 3), 900), offset_minutes: 0 },
573            Stamped { id: 4, said_at_ms: at(d(2026, 10, 5), 700), offset_minutes: 0 },
574            Stamped { id: 5, said_at_ms: at(d(2026, 10, 4), 5), offset_minutes: 0 },
575        ];
576        let groups = group_by_day(&ms, today);
577        let shape: Vec<(&str, &[u64])> = groups.iter().map(|g| (g.name.label.as_str(), g.ids.as_slice())).collect();
578        assert_eq!(shape, [("Today", &[4, 2][..]), ("Yesterday", &[5][..]), ("Saturday", &[3, 1][..])]);
579        assert!(groups.windows(2).all(|w| w[0].day > w[1].day));
580    }
581
582    #[test]
583    fn two_memories_either_side_of_local_midnight_are_on_two_days() {
584        let today = d(2026, 10, 5).day_number();
585        // At -5h, 04:59 UTC on the 5th is 23:59 on the 4th; 05:00 UTC is midnight on the 5th.
586        let ms = [
587            Stamped { id: 1, said_at_ms: at(d(2026, 10, 5), 4 * 60 + 59), offset_minutes: -300 },
588            Stamped { id: 2, said_at_ms: at(d(2026, 10, 5), 5 * 60), offset_minutes: -300 },
589        ];
590        let groups = group_by_day(&ms, today);
591        assert_eq!(groups.len(), 2);
592        assert_eq!((groups[0].name.label.as_str(), groups[0].ids.as_slice()), ("Today", &[2][..]));
593        assert_eq!((groups[1].name.label.as_str(), groups[1].ids.as_slice()), ("Yesterday", &[1][..]));
594    }
595
596    #[test]
597    fn nothing_makes_no_days() {
598        assert!(group_by_day(&[], 20_000).is_empty());
599    }
600
601    #[test]
602    fn the_same_moment_orders_by_id_so_the_order_is_stable() {
603        let ms = [
604            Stamped { id: 1, said_at_ms: 86_400_000, offset_minutes: 0 },
605            Stamped { id: 2, said_at_ms: 86_400_000, offset_minutes: 0 },
606        ];
607        assert_eq!(group_by_day(&ms, 1)[0].ids, [2, 1]);
608    }
609
610    #[test]
611    fn a_memory_she_told_on_three_days_is_on_each_of_them_once() {
612        let today = d(2026, 10, 5).day_number();
613        let ms = [
614            Stamped { id: 7, said_at_ms: at(d(2026, 10, 3), 600), offset_minutes: 0 },
615            Stamped { id: 7, said_at_ms: at(d(2026, 10, 3), 900), offset_minutes: 0 },
616            Stamped { id: 7, said_at_ms: at(d(2026, 10, 4), 5), offset_minutes: 0 },
617            Stamped { id: 7, said_at_ms: at(d(2026, 10, 5), 60), offset_minutes: 0 },
618            Stamped { id: 8, said_at_ms: at(d(2026, 10, 5), 30), offset_minutes: 0 },
619        ];
620        let shape: Vec<(String, Vec<u64>)> = group_by_day(&ms, today).into_iter().map(|g| (g.name.label, g.ids)).collect();
621        assert_eq!(
622            shape,
623            [("Today".to_owned(), vec![7, 8]), ("Yesterday".to_owned(), vec![7]), ("Saturday".to_owned(), vec![7])],
624            "one card for each day she said it, and twice on one day is still one card"
625        );
626    }
627}