Times for a page. UTC on the server, always; a person is shown a relative time ("3 days ago") whose
tooltip is the full date in their own timezone. The server cannot know the viewer's clock, so it writes
a <time datetime="…Z"> with a UTC fallback as its text and [SCRIPT] rewrites it in the browser and
keeps it up to date.
Instants are Unix milliseconds in an i64. The calendar is Howard Hinnant's days-to-civil algorithm,
here once instead of in every property.
9use maud::{html, Markup};
The script that rewrites every [moment] and every data-when element. Load it once per page.
12pub const SCRIPT: &str = include_str!("time.js");
A calendar date in the proleptic Gregorian calendar.
1 to 12.
19 pub month: u8,
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}
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}
48const DAY_MS: i64 = 86_400_000;
Splits an instant into its UTC date and the seconds since that day began.
RFC 3339 in UTC, 2026-09-30T20:05:30Z, for <time datetime>.
The same instant as text a reader sees before the script has run: 2026-09-30 20:05:30 UTC.
YYYY-MM-DDTHH:MM, with :SS and a trailing Z optional, read as UTC, to Unix milliseconds. A
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}
A moment for the page: [SCRIPT] turns it into "5 minutes ago" with the full local time as its
tooltip. The UTC text inside is only what shows before the script runs.
A moment a source already gives as RFC 3339 text, past or future ("in 3 months").
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}