a73x

src/timestamp.rs

Ref:   Size: 4.8 KiB   History

//! How a stored timestamp goes onto a screen.
//!
//! Events store `chrono::Utc::now().to_rfc3339()`, which is 35 characters of
//! which nine are nanoseconds:
//!
//! ```text
//! 2026-08-11T16:08:31.458124482+00:00
//! ```
//!
//! That is the right thing to *store* — it is exact, sortable and unambiguous
//! about its offset — and the wrong thing to put in a table cell. At 35
//! characters it was the widest column after the title, it wrapped every row
//! onto two lines, and the nine digits of precision are not a number any
//! reader has ever needed. See issue `b2a57996`.
//!
//! So a timestamp reaching a template is split in two: a short form for the
//! cell and the stored form for `title=`, which browsers surface on hover.
//! Nothing is discarded — the exact value is still on the page, one hover
//! away, and `--json` is untouched and still carries it in full.
//!
//! # Why the dashboard uses it too
//!
//! Issue `952390ad`: the TUI kept printing the stored string after the web had
//! stopped, which is the divergence `10bb2d84` says must not happen for a
//! displayed value — two surfaces rendering one field two ways teach two
//! different habits and make screenshots and pasted output disagree. A
//! terminal has no hover, so the dashboard shows [`Timestamp::short`] and
//! nothing else; the full value is still one `show` or `--json` away.
//!
//! # Why minutes, and why absolute
//!
//! Minute precision is the coarsest rendering that still orders two events on
//! the same day, which is the common case for a review and the comment
//! answering it. Seconds add three characters and settle nothing a reader
//! cares about.
//!
//! Absolute rather than relative ("3 days ago") because a relative rendering
//! is a function of when the page was built, so the same URL says different
//! things at different times: it cannot be cached, cannot be compared between
//! two rows rendered from different requests, and cannot be asserted by a test
//! without freezing a clock. The exact instant is what was recorded; the page
//! should say it.
//!
//! # Why UTC
//!
//! The server cannot know the reader's timezone, and a page rendered in the
//! *server's* zone would silently disagree with the same page rendered
//! elsewhere. Every timestamp this project writes is already `+00:00`, so
//! normalizing to UTC changes nothing in practice and fixes the display for a
//! value that arrived with some other offset from a repository synced in.

/// A stored timestamp, split into what a cell shows and what it stands for.
#[derive(Debug, Clone)]
pub struct Timestamp {
    /// Exactly as stored — the `title` attribute, so nothing is lost.
    pub full: String,
    /// `YYYY-MM-DD HH:MM` in UTC.
    pub short: String,
}

impl Timestamp {
    /// Split a stored RFC3339 timestamp for display.
    ///
    /// An unparseable value renders as itself. A timestamp this project did
    /// not write is still a fact about the object, and a page that dropped it
    /// or printed a placeholder would be hiding the one thing that could
    /// explain the row. Ugly beats absent.
    pub fn new(stored: impl Into<String>) -> Self {
        let full = stored.into();
        let short = chrono::DateTime::parse_from_rfc3339(&full)
            .map(|dt| {
                dt.with_timezone(&chrono::Utc)
                    .format("%Y-%m-%d %H:%M")
                    .to_string()
            })
            .unwrap_or_else(|_| full.clone());
        Timestamp { full, short }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nanoseconds_and_the_offset_are_dropped_from_the_short_form() {
        let t = Timestamp::new("2026-08-11T16:08:31.458124482+00:00");
        assert_eq!(t.short, "2026-08-11 16:08");
        assert_eq!(t.short.len(), 16);
    }

    #[test]
    fn the_stored_value_survives_for_the_title_attribute() {
        let stored = "2026-08-11T16:08:31.458124482+00:00";
        assert_eq!(Timestamp::new(stored).full, stored);
    }

    /// A value that arrived with a non-UTC offset must not read as though its
    /// wall-clock digits were UTC — two rows in one table have to be
    /// comparable by eye.
    #[test]
    fn a_foreign_offset_is_normalized_to_utc() {
        let t = Timestamp::new("2026-08-11T18:08:31+02:00");
        assert_eq!(t.short, "2026-08-11 16:08");
    }

    #[test]
    fn a_second_precision_timestamp_shortens_too() {
        assert_eq!(
            Timestamp::new("2026-08-11T16:08:31Z").short,
            "2026-08-11 16:08"
        );
    }

    #[test]
    fn an_unparseable_timestamp_renders_as_itself_rather_than_vanishing() {
        let t = Timestamp::new("not a date");
        assert_eq!(t.short, "not a date");
        assert_eq!(t.full, "not a date");
    }

    #[test]
    fn an_empty_timestamp_stays_empty() {
        assert_eq!(Timestamp::new("").short, "");
    }
}