Skip to main content

pitboard_core/
time.rs

1//! Time through jiff: epoch seconds in everything Pitboard stores, the local time zone only
2//! in what it shows a person.
3//!
4//! There is no free function that reads the clock. Everything that needs to know the time
5//! asks its [`Context`](crate::context::Context), which holds a [`Clock`]. The expiry of a
6//! parked login, whether a renewal is due, and how long doctor says is left are all
7//! judgements about time, and none of them could be tested while the clock was a call into
8//! the operating system made wherever it was needed.
9
10use jiff::Timestamp;
11use jiff::tz::TimeZone;
12
13/// What Pitboard reads the time from.
14#[doc(hidden)]
15pub trait Clock: Send + Sync + std::fmt::Debug {
16    /// Epoch seconds: what everything Pitboard stores is measured in.
17    fn now(&self) -> i64;
18
19    /// Epoch milliseconds. Park names carry this, so two parks of one account in the same
20    /// second do not collide.
21    fn now_millis(&self) -> i64;
22}
23
24/// This machine's clock, which is what every real context uses.
25#[derive(Debug, Clone, Copy)]
26pub(crate) struct SystemClock;
27
28impl Clock for SystemClock {
29    fn now(&self) -> i64 {
30        Timestamp::now().as_second()
31    }
32
33    fn now_millis(&self) -> i64 {
34        Timestamp::now().as_millisecond()
35    }
36}
37
38/// A clock that says what it is told, and can be moved. What the interesting judgements in
39/// this crate are about is when something happens, so a test needs to say when.
40#[cfg(any(test, feature = "test-support"))]
41#[doc(hidden)]
42#[derive(Debug)]
43pub struct FixedClock(std::sync::atomic::AtomicI64);
44
45#[cfg(any(test, feature = "test-support"))]
46impl FixedClock {
47    pub fn at(epoch_seconds: i64) -> FixedClock {
48        FixedClock(std::sync::atomic::AtomicI64::new(epoch_seconds))
49    }
50
51    /// Move the clock forward, or back.
52    pub fn advance(&self, seconds: i64) {
53        self.0
54            .fetch_add(seconds, std::sync::atomic::Ordering::Relaxed);
55    }
56}
57
58#[cfg(any(test, feature = "test-support"))]
59impl Clock for FixedClock {
60    fn now(&self) -> i64 {
61        self.0.load(std::sync::atomic::Ordering::Relaxed)
62    }
63
64    fn now_millis(&self) -> i64 {
65        self.now() * 1000
66    }
67}
68
69/// An RFC 3339 instant such as `2026-09-20T22:20:00.095287+00:00`, as epoch seconds. `None`
70/// for anything else, including a time without an offset, which names no instant.
71pub fn parse(text: &str) -> Option<i64> {
72    text.parse::<Timestamp>().ok().map(Timestamp::as_second)
73}
74
75/// `epoch` in this machine's time zone, formatted with `strftime` directives.
76pub fn local(epoch: i64, pattern: &str) -> String {
77    Timestamp::from_second(epoch)
78        .map(|t| t.to_zoned(TimeZone::system()).strftime(pattern).to_string())
79        .unwrap_or_default()
80}
81
82/// What a person reads for a moment: "14:02", or with the date once it is not today.
83pub fn moment(epoch: i64, now: i64) -> String {
84    let pattern = if local(epoch, "%F") == local(now, "%F") {
85        "%H:%M"
86    } else {
87        "%b %-d %H:%M"
88    };
89    local(epoch, pattern)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn parses_the_shapes_the_usage_api_actually_emits() {
98        // Captured verbatim from a live /api/oauth/usage response.
99        assert_eq!(parse("2026-09-20T22:20:00.095287+00:00"), Some(1789942800));
100        assert_eq!(parse("2026-09-27T02:00:00.095306+00:00"), Some(1790474400));
101        assert_eq!(parse("2026-09-20T22:20:00Z"), Some(1789942800));
102        assert_eq!(parse("2026-09-21T05:20:00+07:00"), Some(1789942800));
103    }
104
105    #[test]
106    fn refuses_rather_than_guesses() {
107        for bad in [
108            "",
109            "not a date",
110            "2026-09-20",
111            "2026-09-20T22:20:00",
112            "2026/09/20T22:20:00Z",
113            "2026-13-01T00:00:00Z",
114            "2026-01-32T00:00:00Z",
115            "2026-01-01T24:00:00Z",
116        ] {
117            assert_eq!(parse(bad), None, "should refuse {bad:?}");
118        }
119    }
120
121    #[test]
122    fn a_moment_today_is_just_its_time() {
123        let now = SystemClock.now();
124        assert_eq!(moment(now, now).len(), 5);
125        assert!(moment(now - 3 * 86_400, now).len() > 5);
126    }
127
128    #[test]
129    fn formats_in_the_local_zone() {
130        assert_eq!(local(0, "%Y").len(), 4);
131        assert_eq!(
132            local(i64::MAX, "%Y"),
133            "",
134            "out of range is empty, not a panic"
135        );
136    }
137}