Skip to main content

day_spec/controls/
datetime.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4pub const DATE_KIND: &str = "day.piece.datepicker";
5pub const TIME_KIND: &str = "day.piece.timepicker";
6
7// ---------------------------------------------------------------------------
8// Values: proleptic-Gregorian civil date + wall-clock time. No chrono dependency: ISO-8601
9// strings and epoch days / seconds-of-day are the interchange forms across native boundaries.
10// ---------------------------------------------------------------------------
11
12/// A civil (zoneless) calendar date, proleptic Gregorian. Ordered chronologically; `Display` and
13/// [`DayDate::parse_iso`] speak ISO-8601 (`YYYY-MM-DD`).
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct DayDate {
16    pub year: i32,
17    pub month: u8,
18    pub day: u8,
19}
20
21impl Default for DayDate {
22    fn default() -> Self {
23        DayDate {
24            year: 1970,
25            month: 1,
26            day: 1,
27        }
28    }
29}
30
31/// Seconds since the Unix epoch, from whatever clock this target has.
32///
33/// `SystemTime::now()` traps on `wasm32-unknown-unknown`: the target has no clock, and the trap
34/// is not a panic that can be caught, it aborts the module. So a `DayDate::today()` anywhere on
35/// the startup path took the whole web app down with `RuntimeError: Unreachable code`. The web
36/// build reads the host's clock through the same JS shim day-toolkit-dom imports everything else from.
37fn now_epoch_secs() -> i64 {
38    #[cfg(target_arch = "wasm32")]
39    {
40        // SAFETY: the shim exports this for every web-dom app; a build that links this piece and
41        // an older shim fails at instantiation with a LinkError naming the missing import, which
42        // is the loud failure rather than the silent one.
43        unsafe { day_datetime_now_secs() as i64 }
44    }
45    #[cfg(not(target_arch = "wasm32"))]
46    {
47        std::time::SystemTime::now()
48            .duration_since(std::time::UNIX_EPOCH)
49            .map(|d| d.as_secs() as i64)
50            .unwrap_or(0)
51    }
52}
53
54// The attribute makes this a wasm import from the instantiation's `env` object (shim.js supplies
55// it) rather than a link-time-resolved symbol; without it rust-lld reports it undefined.
56#[cfg(target_arch = "wasm32")]
57#[link(wasm_import_module = "env")]
58unsafe extern "C" {
59    /// Seconds since the Unix epoch (fractional), from the browser's clock.
60    fn day_datetime_now_secs() -> f64;
61}
62
63impl DayDate {
64    /// A validated date (`month` 1–12, `day` within the month, leap-aware); `None` otherwise.
65    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
66        if (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month) {
67            Some(DayDate { year, month, day })
68        } else {
69            None
70        }
71    }
72
73    /// Today's date derived from the system clock in UTC (Day carries no time-zone database; an
74    /// app that must roll the date at local midnight should compute it with its own zone source).
75    pub fn today() -> Self {
76        Self::from_epoch_days(now_epoch_secs().div_euclid(86_400))
77    }
78
79    /// Days since 1970-01-01 (negative before). The numeric interchange form across native
80    /// boundaries (`Event::Custom.num`).
81    pub fn to_epoch_days(self) -> i64 {
82        // Howard Hinnant's days_from_civil.
83        let y = self.year as i64 - if self.month <= 2 { 1 } else { 0 };
84        let era = if y >= 0 { y } else { y - 399 } / 400;
85        let yoe = y - era * 400;
86        let m = self.month as i64;
87        let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
88        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
89        era * 146097 + doe - 719468
90    }
91
92    /// The date `days` after 1970-01-01 (inverse of [`DayDate::to_epoch_days`]).
93    pub fn from_epoch_days(days: i64) -> Self {
94        // Howard Hinnant's civil_from_days.
95        let z = days + 719468;
96        let era = if z >= 0 { z } else { z - 146096 } / 146097;
97        let doe = z - era * 146097;
98        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
99        let y = yoe + era * 400;
100        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
101        let mp = (5 * doy + 2) / 153;
102        let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
103        let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8;
104        DayDate {
105            year: (y + if month <= 2 { 1 } else { 0 }) as i32,
106            month,
107            day,
108        }
109    }
110
111    /// Parse strict ISO-8601 `YYYY-MM-DD` (the form `Display` writes). `None` on anything else.
112    pub fn parse_iso(s: &str) -> Option<Self> {
113        let mut it = s.split('-');
114        let (y, m, d) = (it.next()?, it.next()?, it.next()?);
115        if it.next().is_some() || y.len() != 4 || m.len() != 2 || d.len() != 2 {
116            return None;
117        }
118        if [y, m, d]
119            .iter()
120            .any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
121        {
122            return None;
123        }
124        Self::new(y.parse().ok()?, m.parse().ok()?, d.parse().ok()?)
125    }
126
127    /// This date limited to the inclusive `[min, max]` range (either bound optional).
128    pub fn clamped(self, min: Option<DayDate>, max: Option<DayDate>) -> Self {
129        let mut d = self;
130        if let Some(min) = min
131            && d < min
132        {
133            d = min;
134        }
135        if let Some(max) = max
136            && d > max
137        {
138            d = max;
139        }
140        d
141    }
142}
143
144impl std::fmt::Display for DayDate {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
147    }
148}
149
150pub fn is_leap(year: i32) -> bool {
151    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
152}
153
154pub fn days_in_month(year: i32, month: u8) -> u8 {
155    match month {
156        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
157        4 | 6 | 9 | 11 => 30,
158        2 if is_leap(year) => 29,
159        2 => 28,
160        _ => 0,
161    }
162}
163
164/// A civil (zoneless) wall-clock time. `Display` and [`DayTime::parse_iso`] speak ISO-8601
165/// (`HH:MM`, or `HH:MM:SS` when seconds are nonzero).
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
167pub struct DayTime {
168    pub hour: u8,
169    pub minute: u8,
170    pub second: u8,
171}
172
173impl DayTime {
174    /// A validated time (`hour` 0–23, `minute`/`second` 0–59); `None` otherwise.
175    pub fn new(hour: u8, minute: u8, second: u8) -> Option<Self> {
176        if hour < 24 && minute < 60 && second < 60 {
177            Some(DayTime {
178                hour,
179                minute,
180                second,
181            })
182        } else {
183            None
184        }
185    }
186
187    /// The current time of day from the system clock in UTC, the twin of [`DayDate::today`],
188    /// and carrying the same caveat: Day ships no time-zone database, so an app that needs local
189    /// wall-clock time should apply its own offset. Pair the two for a timestamp
190    /// (`Day-Showcase-2026-08-12-14-23-05.png`).
191    pub fn now() -> Self {
192        Self::from_seconds_of_day(now_epoch_secs().rem_euclid(86_400))
193    }
194
195    /// Seconds since midnight, the numeric interchange form across native boundaries.
196    pub fn seconds_of_day(self) -> i64 {
197        self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64
198    }
199
200    /// The time `secs` after midnight (inverse of [`DayTime::seconds_of_day`]; out-of-range wraps
201    /// into the day).
202    pub fn from_seconds_of_day(secs: i64) -> Self {
203        let s = secs.rem_euclid(86_400);
204        DayTime {
205            hour: (s / 3600) as u8,
206            minute: (s / 60 % 60) as u8,
207            second: (s % 60) as u8,
208        }
209    }
210
211    /// Parse ISO-8601 `HH:MM` or `HH:MM:SS`. `None` on anything else.
212    pub fn parse_iso(s: &str) -> Option<Self> {
213        let mut it = s.split(':');
214        let (h, m) = (it.next()?, it.next()?);
215        let sec = it.next();
216        if it.next().is_some() || h.len() != 2 || m.len() != 2 {
217            return None;
218        }
219        if let Some(sec) = sec
220            && sec.len() != 2
221        {
222            return None;
223        }
224        if [Some(h), Some(m), sec]
225            .iter()
226            .flatten()
227            .any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
228        {
229            return None;
230        }
231        Self::new(
232            h.parse().ok()?,
233            m.parse().ok()?,
234            sec.map_or(Some(0), |s| s.parse().ok())?,
235        )
236    }
237}
238
239impl std::fmt::Display for DayTime {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        if self.second == 0 {
242            write!(f, "{:02}:{:02}", self.hour, self.minute)
243        } else {
244            write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// The pieces
251// ---------------------------------------------------------------------------
252
253/// How the picker presents (docs/datepicker.md has the per-toolkit table). Like `PickerStyle`,
254/// each intent maps to the platform's closest idiomatic control, so chrome differs per platform.
255#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
256pub enum Style {
257    /// The platform default: [`Style::Compact`] everywhere today.
258    #[default]
259    Automatic,
260    /// A field/button showing the value that summons a transient chooser (NSDatePicker
261    /// textFieldAndStepper / UIDatePicker .compact / a Material dialog launcher / QDateEdit with
262    /// calendar popup / CalendarDatePicker flyout / GtkMenuButton+calendar popover /
263    /// ArkUI CalendarPicker).
264    Compact,
265    /// An embedded calendar / clock / wheels (NSDatePicker graphical / UIDatePicker .inline /
266    /// framework DatePicker+TimePicker widgets / QCalendarWidget / CalendarView / GtkCalendar /
267    /// ArkUI DatePicker wheels).
268    Inline,
269}
270
271/// Full props (realize) for the date picker. `style`/`min`/`max` are set once at build; only the
272/// value patches.
273#[derive(Clone, Copy, Debug, Default, PartialEq)]
274pub struct DateProps {
275    pub date: DayDate,
276    pub style: Style,
277    pub min: Option<DayDate>,
278    pub max: Option<DayDate>,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq)]
282pub enum DatePatch {
283    SetDate(DayDate),
284}
285
286/// Full props (realize) for the time picker. `style`/`seconds` are set once at build; only the
287/// value patches.
288#[derive(Clone, Copy, Debug, Default, PartialEq)]
289pub struct TimeProps {
290    pub time: DayTime,
291    pub style: Style,
292    /// Show/edit seconds; honored on AppKit and Qt (the toolkits whose controls have a seconds
293    /// field), a documented no-op elsewhere.
294    pub seconds: bool,
295}
296
297#[derive(Clone, Copy, Debug, PartialEq)]
298pub enum TimePatch {
299    SetTime(DayTime),
300}