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