lib.rsannotatedlib.rssource157 lines · 6.0 KB · raw

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.

15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct Civil {
17    pub year: i64,

1 to 12.

19    pub month: u8,

1 to 31.

21    pub day: u8,
22}

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.

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}

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}

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}

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.

98pub fn moment(unix_ms: i64) -> Markup {
99    html! { time data-ago datetime=(iso_utc(unix_ms)) { (utc_text(unix_ms)) } }
100}

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}
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}