1//! Times for a page. UTC on the server, always; a person is shown a relative time ("3 days ago") whose 2//! tooltip is the full date in their own timezone. The server cannot know the viewer's clock, so it writes 3//! a `<time datetime="…Z">` with a UTC fallback as its text and [`SCRIPT`] rewrites it in the browser and 4//! keeps it up to date. 5//! 6//! Instants are Unix milliseconds in an `i64`. The calendar is Howard Hinnant's days-to-civil algorithm, 7//! here once instead of in every property. 8 9use maud::{html, Markup}; 10 11/// The script that rewrites every [`moment`] and every `data-when` element. Load it once per page. 12pub const SCRIPT: &str = include_str!("time.js"); 13 14/// A calendar date in the proleptic Gregorian calendar. 15#[derive(Clone, Copy, Debug, PartialEq, Eq)] 16pub struct Civil { 17 pub year: i64, 18 /// 1 to 12. 19 pub month: u8, 20 /// 1 to 31. 21 pub day: u8, 22} 23 24/// The date of a day number (day 0 is 1970-01-01). 25pub fn civil_from_days(days: i64) -> Civil { 26 let z = days + 719_468; 27 let era = z.div_euclid(146_097); 28 let doe = z.rem_euclid(146_097); 29 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; 30 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 31 let mp = (5 * doy + 2) / 153; 32 let day = (doy - (153 * mp + 2) / 5 + 1) as u8; 33 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8; 34 Civil { year: yoe + era * 400 + i64::from(month <= 2), month, day } 35} 36 37/// The day number of a date, the inverse of [`civil_from_days`]. 38pub fn days_from_civil(date: Civil) -> i64 { 39 let year = if date.month <= 2 { date.year - 1 } else { date.year }; 40 let era = year.div_euclid(400); 41 let yoe = year.rem_euclid(400); 42 let mp = i64::from(if date.month > 2 { date.month - 3 } else { date.month + 9 }); 43 let doy = (153 * mp + 2) / 5 + i64::from(date.day) - 1; 44 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; 45 era * 146_097 + doe - 719_468 46} 47 48const DAY_MS: i64 = 86_400_000; 49 50/// Splits an instant into its UTC date and the seconds since that day began. 51fn split(unix_ms: i64) -> (Civil, i64) { 52 let days = unix_ms.div_euclid(DAY_MS); 53 (civil_from_days(days), unix_ms.rem_euclid(DAY_MS) / 1000) 54} 55 56/// RFC 3339 in UTC, `2026-09-30T20:05:30Z`, for `<time datetime>`. 57pub fn iso_utc(unix_ms: i64) -> String { 58 let (date, time) = split(unix_ms); 59 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", date.year, date.month, date.day, time / 3600, (time % 3600) / 60, time % 60) 60} 61 62/// The same instant as text a reader sees before the script has run: `2026-09-30 20:05:30 UTC`. 63pub fn utc_text(unix_ms: i64) -> String { 64 let (date, time) = split(unix_ms); 65 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", date.year, date.month, date.day, time / 3600, (time % 3600) / 60, time % 60) 66} 67 68/// `YYYY-MM-DDTHH:MM`, with `:SS` and a trailing `Z` optional, read as UTC, to Unix milliseconds. A 69/// date that does not exist (`2026-02-30`) is refused. 70pub fn parse_utc(text: &str) -> Option<i64> { 71 let text = text.strip_suffix('Z').unwrap_or(text); 72 let (date, time) = text.split_once('T')?; 73 let mut d = date.split('-'); 74 let (year, month, day) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u8>().ok()?, d.next()?.parse::<u8>().ok()?); 75 if d.next().is_some() || date.split('-').next()?.len() != 4 { 76 return None; 77 } 78 let mut t = time.split(':'); 79 let (hour, minute) = (t.next()?.parse::<i64>().ok()?, t.next()?.parse::<i64>().ok()?); 80 let second = match t.next() { 81 Some(s) => s.parse::<i64>().ok()?, 82 None => 0, 83 }; 84 if t.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 59 { 85 return None; 86 } 87 let civil = Civil { year, month, day }; 88 let days = days_from_civil(civil); 89 // The round trip refuses a day the month does not have. 90 if civil_from_days(days) != civil { 91 return None; 92 } 93 Some(days * DAY_MS + (hour * 3600 + minute * 60 + second) * 1000) 94} 95 96/// A moment for the page: [`SCRIPT`] turns it into "5 minutes ago" with the full local time as its 97/// tooltip. The UTC text inside is only what shows before the script runs. 98pub fn moment(unix_ms: i64) -> Markup { 99 html! { time data-ago datetime=(iso_utc(unix_ms)) { (utc_text(unix_ms)) } } 100} 101 102/// A moment a source already gives as RFC 3339 text, past or future ("in 3 months"). 103pub fn moment_rfc3339(instant: &str) -> Markup { 104 html! { time data-ago datetime=(instant) { (instant) } } 105} 106 107#[cfg(test)] 108mod tests { 109 use super::*; 110 111 #[test] 112 fn the_epoch_and_a_leap_day() { 113 assert_eq!(civil_from_days(0), Civil { year: 1970, month: 1, day: 1 }); 114 assert_eq!(civil_from_days(11_016), Civil { year: 2000, month: 2, day: 29 }); 115 assert_eq!(days_from_civil(Civil { year: 2000, month: 2, day: 29 }), 11_016); 116 } 117 118 #[test] 119 fn every_day_of_four_hundred_years_comes_back() { 120 for day in -146_097..146_097 { 121 assert_eq!(days_from_civil(civil_from_days(day)), day); 122 } 123 } 124 125 #[test] 126 fn iso_and_text_in_utc() { 127 // 2026-09-30T20:05:30Z 128 let at = 1_790_798_730_000; 129 assert_eq!(iso_utc(at), "2026-09-30T20:05:30Z"); 130 assert_eq!(utc_text(at), "2026-09-30 20:05:30 UTC"); 131 assert_eq!(parse_utc(&iso_utc(at)), Some(at)); 132 } 133 134 #[test] 135 fn before_the_epoch_rounds_down() { 136 assert_eq!(iso_utc(-1000), "1969-12-31T23:59:59Z"); 137 } 138 139 #[test] 140 fn parsing_refuses_what_is_not_a_time() { 141 assert_eq!(parse_utc("2026-02-30T10:00"), None); 142 assert_eq!(parse_utc("2026-13-01T10:00"), None); 143 assert_eq!(parse_utc("2026-01-01T24:00"), None); 144 assert_eq!(parse_utc("2026-01-01"), None); 145 assert_eq!(parse_utc("26-01-01T10:00"), None); 146 assert_eq!(parse_utc("2026-01-01T10:00:00:00"), None); 147 assert_eq!(parse_utc("2026-01-01T10:00"), Some(1_767_261_600_000)); 148 } 149 150 #[test] 151 fn a_moment_carries_the_instant_and_a_utc_fallback() { 152 assert_eq!( 153 moment(0).into_string(), 154 r#"<time data-ago datetime="1970-01-01T00:00:00Z">1970-01-01 00:00:00 UTC</time>"# 155 ); 156 } 157}