Skip to main content

ppoppo_clock/
lib.rs

1//! Universal Clock + Timer port for the ppoppo workspace.
2//!
3//! Single deep-module port hiding the time-source substrate from every consumer.
4//! Three impl arms gated by feature: `native` (Tokio), `wasm` (js_sys::Date +
5//! Intl tz-name probe + Window.setTimeout), `mock` (FrozenClock + MockClock +
6//! AdvanceableTimer). Zone math is arm-independent: jiff's bundled tzdb makes
7//! `now_in`/`today_in` *provided* methods — each arm implements only `now()`.
8//!
9//! # SSOT relationship
10//!
11//! Standards: `STANDARDS_TIME_MECHANICS.md` §"Universal client + server time port"
12//! (jiff surface per RFC_202607130309_jiff-migration). Engine precedent:
13//! `ppoppo-token` (1st-party-consumed primitive, surface via SDK re-export).
14//! External Developer Apps consume via `pas_external::clock::*` — never
15//! path-dep this crate.
16//!
17//! # Dart mirror (CFC)
18//!
19//! `apps/cfc/lib/clock.dart` is the hand-maintained Dart shape mirror. Drift
20//! detection deferred to a follow-up RFC. When changing trait shape here,
21//! update the Dart mirror in the same commit.
22//!
23//! # Temporal alignment
24//!
25//! jiff is the Rust realization of the TC39 Temporal model (`Timestamp` ≙
26//! `Temporal.Instant`, `Zoned` ≙ `Temporal.ZonedDateTime`, `civil::Date` ≙
27//! `Temporal.PlainDate`, `tz::TimeZone` ≙ `Temporal.TimeZone`). The pre-jiff
28//! plan to swap the wasm arm onto `js_sys::Temporal` is obsolete — the model
29//! already lives in-process, identically on every arm.
30
31#![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::panic))]
32
33use futures::future::BoxFuture;
34use jiff::civil::Date;
35use jiff::tz::TimeZone;
36use jiff::{Timestamp, Zoned};
37use std::sync::Arc;
38use std::time::Duration;
39
40/// Port vocabulary re-export — consumers name jiff types through the port
41/// crate (and through `pas_external::clock::jiff` on the SDK surface).
42pub use jiff;
43
44#[cfg(feature = "native")]
45pub mod native;
46#[cfg(feature = "wasm")]
47pub mod wasm;
48#[cfg(feature = "mock")]
49pub mod mock;
50
51/// Wall-clock readouts. Single port for "what time is it?" across all surfaces.
52///
53/// `now()` is the only required method; the zone-aware readouts derive from it
54/// via jiff (tzdb available on every arm, including wasm via the bundled db).
55pub trait Clock: Send + Sync + 'static {
56    fn now(&self) -> Timestamp;
57
58    fn now_in(&self, tz: &TimeZone) -> Zoned {
59        self.now().to_zoned(tz.clone())
60    }
61
62    fn today_in(&self, tz: &TimeZone) -> Date {
63        self.now_in(tz).date()
64    }
65
66    fn now_unix_millis(&self) -> i64 {
67        self.now().as_millisecond()
68    }
69}
70
71/// Async scheduling primitive. Single port for "wait N ms" / "yield event-loop tick".
72pub trait Timer: Send + Sync + 'static {
73    fn sleep(&self, dur: Duration) -> BoxFuture<'static, ()>;
74    fn next_tick(&self) -> BoxFuture<'static, ()>;
75}
76
77/// Convenience aliases for the common injection shape.
78pub type ArcClock = Arc<dyn Clock>;
79pub type ArcTimer = Arc<dyn Timer>;
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    /// Fixed-instant clock for exercising the provided methods without any
86    /// feature arm — zone math must be identical regardless of arm.
87    struct FixedClock(Timestamp);
88
89    impl Clock for FixedClock {
90        fn now(&self) -> Timestamp {
91            self.0
92        }
93    }
94
95    fn utc_midnight_2026_05_10() -> Timestamp {
96        "2026-05-10T00:00:00Z".parse().expect("valid RFC 3339")
97    }
98
99    #[test]
100    fn now_in_seoul_utc_midnight_is_hour_9() {
101        // UTC 2026-05-10 00:00 → KST 09:00 (UTC+9, no DST in Korea)
102        let clock = FixedClock(utc_midnight_2026_05_10());
103        let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
104        assert_eq!(clock.now_in(&tz).hour(), 9);
105    }
106
107    #[test]
108    fn today_in_crosses_date_line() {
109        // UTC 2026-05-10 23:30 is already 2026-05-11 in Seoul.
110        let clock = FixedClock("2026-05-10T23:30:00Z".parse().expect("valid"));
111        let tz = TimeZone::get("Asia/Seoul").expect("tzdb has Seoul");
112        assert_eq!(clock.today_in(&tz), jiff::civil::date(2026, 5, 11));
113        assert_eq!(clock.today_in(&TimeZone::UTC), jiff::civil::date(2026, 5, 10));
114    }
115
116    #[test]
117    fn dst_transition_new_york_2024() {
118        // America/New_York spring-forward 2024-03-10 02:00 → 03:00
119        // 06:59 UTC = 01:59 EST; 07:00 UTC = 03:00 EDT
120        let tz = TimeZone::get("America/New_York").expect("tzdb has NY");
121        let before = FixedClock("2024-03-10T06:59:00Z".parse().expect("valid"));
122        let after = FixedClock("2024-03-10T07:00:00Z".parse().expect("valid"));
123        assert_eq!(before.now_in(&tz).hour(), 1);
124        assert_eq!(after.now_in(&tz).hour(), 3);
125    }
126
127    #[test]
128    fn now_unix_millis_matches_timestamp() {
129        let clock = FixedClock("1970-01-01T00:00:01Z".parse().expect("valid"));
130        assert_eq!(clock.now_unix_millis(), 1_000);
131    }
132
133    #[test]
134    fn dow_monday0_convention_via_zoned() {
135        // 2026-05-10 is a Sunday → to_monday_zero_offset() == 6
136        let clock = FixedClock("2026-05-10T12:00:00Z".parse().expect("valid"));
137        let zdt = clock.now_in(&TimeZone::UTC);
138        assert_eq!(zdt.weekday().to_monday_zero_offset(), 6);
139    }
140}