1//! The calendar days of her memories: which local day a memory was learned on, what the day is called, and the 2//! kind holiday it may be. 3//! 4//! The core keeps UTC. The shell supplies, for each moment, the offset of her time zone from UTC at that moment 5//! (so a clock change is right), and the day number of today (as for [`crate::LocalMoment`]; day 0 is 1970-01-01, 6//! a Thursday). There is no second time source here: everything is a pure function of those numbers. 7//! 8//! A day is called, newest wording first: 9//! 10//! | The day | Its label | Its subtitle | 11//! |---|---|---| 12//! | today | "Today" | the holiday, if it is one | 13//! | yesterday | "Yesterday" | the holiday, if it is one | 14//! | an older day that is a holiday | the holiday's name | none | 15//! | one of the five days before yesterday | the weekday: "Saturday" | none | 16//! | anything older, or a day in the future | the date: "July 15" (with the year when it is not this year) | none | 17//! 18//! [`group_by_day`] sorts memories into those days, newest day first and newest memory first inside a day. The 19//! date scroller in the app takes its positions from the same groups as the dividers, so the two cannot disagree. 20//! 21//! The holidays are the ones a six-year-old knows, each from a fixed rule ([`holiday_on`]). None of them is about 22//! 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 23//! one holiday or none. 24 25use crate::greeting::Weekday; 26use whiskers_icons::IconId; 27 28const MINUTE_MS: u64 = 60_000; 29const DAY_MINUTES: i64 = 24 * 60; 30 31/// A calendar date in the proleptic Gregorian calendar. 32#[derive(Clone, Copy, Debug, PartialEq, Eq)] 33pub struct CivilDate { 34 pub year: i32, 35 /// 1 to 12. 36 pub month: u8, 37 /// 1 to 31. 38 pub day: u8, 39} 40 41impl CivilDate { 42 pub fn new(year: i32, month: u8, day: u8) -> CivilDate { 43 CivilDate { year, month, day } 44 } 45 46 /// 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 } 59 60 /// 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 } 70 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} 81 82/// 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} 94 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} 106 107/// The local day number of a moment, given the offset of her time zone from UTC at that moment, in minutes 108/// (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} 112 113/// 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} 133 134impl Holiday { 135 /// 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 ]; 154 155 /// 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 } 176 177 /// The admitted pixel icon that stands for it. The set has no flag, firework, turkey or clover, so the 178 /// 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} 201 202/// The day number of the `n`th (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} 208 209/// 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} 215 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} 227 228/// 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} 246 247/// 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} 280 281/// What a day is called on a divider. 282#[derive(Clone, Debug, PartialEq, Eq)] 283pub struct DayName { 284 /// The big words: "Today", "Yesterday", "Saturday", "July 15", or a holiday's name. 285 pub label: String, 286 /// The holiday's name when the label is "Today" or "Yesterday" and the day is a holiday; otherwise none. 287 pub subtitle: Option<String>, 288 /// The holiday, whichever way it is worded, for its picture on the scroller. 289 pub holiday: Option<Holiday>, 290} 291 292/// How many days back a bare weekday name still means this week: the five before yesterday. 293const WEEKDAY_DAYS_BACK: i64 = 6; 294 295/// 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} 313 314/// One entry of the timeline: a memory's id, a moment she said it (UTC) and the offset of her time zone from UTC 315/// at that moment, in minutes. A memory she told on three days is three of these, with one id; a memory is 316/// 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} 323 324/// One local day of memories. 325#[derive(Clone, Debug, PartialEq, Eq)] 326pub struct DayGroup { 327 pub day: i64, 328 pub name: DayName, 329 /// 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} 332 333/// 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} 350 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}