Skip to main content

qframe/
date.rs

1//! Calendar dates in the proleptic Gregorian calendar, times of day, and the two together.
2//!
3//! The arithmetic counts days since 1970-01-01 with Howard Hinnant's civil-calendar
4//! algorithms, which are exact for every date an `i32` year can hold.
5//!
6//! [`Date`] and [`TimeOfDay`] carry no time zone. [`DateTime`] carries the offset from UTC of
7//! the moment it holds, and [`local_offset_minutes`] reads that offset from the system; where the
8//! system does not say, [`local_offset`] returns `None` instead of a made-up zero.
9
10use std::fmt;
11use std::str::FromStr;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14mod zone;
15
16/// Seconds in a day.
17const DAY: i64 = 86_400;
18
19/// A day of the week.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub enum Weekday {
22    /// Monday.
23    Monday,
24    /// Tuesday.
25    Tuesday,
26    /// Wednesday.
27    Wednesday,
28    /// Thursday.
29    Thursday,
30    /// Friday.
31    Friday,
32    /// Saturday.
33    Saturday,
34    /// Sunday.
35    Sunday,
36}
37
38impl Weekday {
39    /// Monday to Sunday.
40    pub const ALL: [Self; 7] =
41        [Self::Monday, Self::Tuesday, Self::Wednesday, Self::Thursday, Self::Friday, Self::Saturday, Self::Sunday];
42
43    /// 1 for Monday up to 7 for Sunday (ISO 8601).
44    #[must_use]
45    pub fn number(self) -> u8 {
46        match self {
47            Self::Monday => 1,
48            Self::Tuesday => 2,
49            Self::Wednesday => 3,
50            Self::Thursday => 4,
51            Self::Friday => 5,
52            Self::Saturday => 6,
53            Self::Sunday => 7,
54        }
55    }
56
57    /// The weekday numbered `number` (1 Monday to 7 Sunday).
58    #[must_use]
59    pub fn from_number(number: u8) -> Option<Self> {
60        Self::ALL.get(usize::from(number).checked_sub(1)?).copied()
61    }
62
63    /// Days from `start` to this weekday going forward, `0..7`.
64    #[must_use]
65    pub fn days_since(self, start: Self) -> u8 {
66        (self.number() + 7 - start.number()) % 7
67    }
68}
69
70/// A calendar date.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct Date {
73    year: i32,
74    month: u8,
75    day: u8,
76}
77
78impl Date {
79    /// The date `year`-`month`-`day`, or `None` when no such day exists.
80    #[must_use]
81    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
82        (1..=12).contains(&month).then_some(())?;
83        (day >= 1 && day <= days_in_month(year, month)).then_some(Self { year, month, day })
84    }
85
86    /// Today in UTC, from the system clock.
87    #[must_use]
88    pub fn today_utc() -> Self {
89        Self::from_days(unix_now().div_euclid(DAY))
90    }
91
92    /// Today where the machine stands, from the system clock and the system time zone. Falls back
93    /// to [`Date::today_utc`] where the zone is unknown; [`local_offset`] tells you which it is.
94    #[must_use]
95    pub fn today_local() -> Self {
96        DateTime::now_local().date
97    }
98
99    /// Reads `YYYY-MM-DD`: four or more digits for the year, then two for the month and the day,
100    /// with a leading `-` for a year before the era. The calendar is checked, so 2026-02-30 is an
101    /// error, and surrounding spaces are ignored.
102    ///
103    /// ```
104    /// use qframe::date::Date;
105    ///
106    /// assert_eq!(Date::parse("2026-09-17"), Ok(Date::new(2026, 9, 17).expect("a real day")));
107    /// assert!(Date::parse("2026-02-30").is_err());
108    /// ```
109    pub fn parse(text: &str) -> Result<Self, String> {
110        let trimmed = text.trim();
111        let (negative, digits) = match trimmed.strip_prefix('-') {
112            Some(rest) => (true, rest),
113            None => (false, trimmed),
114        };
115        let mut parts = digits.split('-');
116        let (Some(year), Some(month), Some(day), None) = (parts.next(), parts.next(), parts.next(), parts.next())
117        else {
118            return Err(format!("`{trimmed}` is no date; a date is written YYYY-MM-DD"));
119        };
120        let number = |part: &str, width: usize, exact: bool, name: &str| -> Result<i32, String> {
121            let length = if exact { part.len() == width } else { part.len() >= width };
122            let digits = length && part.chars().all(|c| c.is_ascii_digit());
123            part.parse::<i32>()
124                .ok()
125                .filter(|_| digits)
126                .ok_or_else(|| format!("`{part}` is no {name}; it takes {width} digits"))
127        };
128        let year = number(year, 4, false, "year")?;
129        let month = number(month, 2, true, "month")?;
130        let day = number(day, 2, true, "day")?;
131        let year = if negative { -year } else { year };
132        let parts = u8::try_from(month).ok().zip(u8::try_from(day).ok());
133        parts
134            .and_then(|(month, day)| Self::new(year, month, day))
135            .ok_or_else(|| format!("{trimmed} is not a day this calendar has"))
136    }
137
138    /// The year.
139    #[must_use]
140    pub fn year(self) -> i32 {
141        self.year
142    }
143
144    /// The month, 1 to 12.
145    #[must_use]
146    pub fn month(self) -> u8 {
147        self.month
148    }
149
150    /// The day of the month, from 1.
151    #[must_use]
152    pub fn day(self) -> u8 {
153        self.day
154    }
155
156    /// Days since 1970-01-01 (negative before).
157    #[must_use]
158    pub fn to_days(self) -> i64 {
159        let year = i64::from(self.year) - i64::from(self.month <= 2);
160        let era = year.div_euclid(400);
161        let year_of_era = year.rem_euclid(400);
162        let month = i64::from(self.month);
163        let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(self.day) - 1;
164        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
165        era * 146_097 + day_of_era - 719_468
166    }
167
168    /// The date `days` after 1970-01-01. Years beyond the range of `i32` are clamped to it.
169    #[must_use]
170    pub fn from_days(days: i64) -> Self {
171        let z = days.saturating_add(719_468);
172        let era = z.div_euclid(146_097);
173        let day_of_era = z.rem_euclid(146_097);
174        let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
175        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
176        let mp = (5 * day_of_year + 2) / 153;
177        let day = day_of_year - (153 * mp + 2) / 5 + 1;
178        let month = if mp < 10 { mp + 3 } else { mp - 9 };
179        let year = year_of_era + era * 400 + i64::from(month <= 2);
180        Self {
181            year: i32::try_from(year).unwrap_or(if year < 0 { i32::MIN } else { i32::MAX }),
182            month: u8::try_from(month).unwrap_or(1),
183            day: u8::try_from(day).unwrap_or(1),
184        }
185    }
186
187    /// The day of the week.
188    #[must_use]
189    pub fn weekday(self) -> Weekday {
190        // 1970-01-01 was a Thursday.
191        let index = (self.to_days() + 3).rem_euclid(7);
192        Weekday::ALL[usize::try_from(index).unwrap_or(0)]
193    }
194
195    /// The date `days` later (earlier when negative).
196    #[must_use]
197    pub fn add_days(self, days: i64) -> Self {
198        Self::from_days(self.to_days().saturating_add(days))
199    }
200
201    /// The same day `months` later (earlier when negative), moved back to the last day of a
202    /// shorter month: January 31 plus one month is February 28 or 29.
203    #[must_use]
204    pub fn add_months(self, months: i32) -> Self {
205        let index = i64::from(self.year) * 12 + i64::from(self.month) - 1 + i64::from(months);
206        let year = i32::try_from(index.div_euclid(12)).unwrap_or(self.year);
207        let month = u8::try_from(index.rem_euclid(12) + 1).unwrap_or(1);
208        let day = self.day.min(days_in_month(year, month));
209        Self { year, month, day }
210    }
211
212    /// The first day of this date's month.
213    #[must_use]
214    pub fn first_of_month(self) -> Self {
215        Self { day: 1, ..self }
216    }
217
218    /// The date the week containing this date starts on, for weeks starting on `start`.
219    #[must_use]
220    pub fn start_of_week(self, start: Weekday) -> Self {
221        self.add_days(-i64::from(self.weekday().days_since(start)))
222    }
223
224    /// The date the way the active language writes it, with the whole month name:
225    /// `September 18, 2026`, `18. September 2026`, `2026年9月18日`.
226    ///
227    /// This is the form a [`DatePicker`](crate::widgets::DatePicker) shows, so an application
228    /// that writes a date of its own with it puts both in the same order. Composing a date by
229    /// hand is what makes one screen say `September 18` beside a field saying `18 September`.
230    ///
231    /// A language whose month names change inside a date gives that form as
232    /// `quvyta.date.month-in-date-*`: Russian `января` where the heading says `Январь`.
233    #[must_use]
234    pub fn written(self) -> String {
235        crate::t!("quvyta.date.format", day = u32::from(self.day), month = self.month_in_date(), year = self.year)
236    }
237
238    /// The date with the short month name, for somewhere too narrow for [`written`](Self::written):
239    /// `Sep 18, 2026`, `18. Sep 2026`.
240    #[must_use]
241    pub fn written_short(self) -> String {
242        crate::t!("quvyta.date.format-short", day = u32::from(self.day), month = self.month_short(), year = self.year)
243    }
244
245    /// The day and the month with the whole month name, without the year: `September 18`,
246    /// `18 Eylül`. The form a heading over a day's own screen wants.
247    #[must_use]
248    pub fn day_and_month(self) -> String {
249        crate::t!("quvyta.date.format-day-month-long", day = u32::from(self.day), month = self.month_in_date())
250    }
251
252    /// The day and the short month, without the year: `Sep 18`, `18. Sep`. The narrowest form a
253    /// date field falls back to.
254    #[must_use]
255    pub fn day_and_month_short(self) -> String {
256        crate::t!("quvyta.date.format-day-month", day = u32::from(self.day), month = self.month_short())
257    }
258
259    /// The whole month name in the form this language uses inside a date.
260    fn month_in_date(self) -> String {
261        crate::i18n::translate_active_if_known(&format!("quvyta.date.month-in-date-{}", self.month))
262            .unwrap_or_else(|| crate::t!(&format!("quvyta.date.month-{}", self.month)))
263    }
264
265    /// The short month name.
266    fn month_short(self) -> String {
267        crate::t!(&format!("quvyta.date.month-short-{}", self.month))
268    }
269}
270
271impl fmt::Display for Date {
272    /// Writes `YYYY-MM-DD`, the form [`Date::parse`] reads. A year before the era keeps its sign
273    /// in front of four digits, and a year of five digits or more is written in full.
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        let (sign, year) = if self.year < 0 { ("-", self.year.unsigned_abs()) } else { ("", self.year.unsigned_abs()) };
276        write!(f, "{sign}{year:04}-{:02}-{:02}", self.month, self.day)
277    }
278}
279
280impl FromStr for Date {
281    type Err = String;
282
283    /// The same as [`Date::parse`].
284    fn from_str(text: &str) -> Result<Self, Self::Err> {
285        Self::parse(text)
286    }
287}
288
289/// A time of day on a 24-hour clock, with no date and no time zone.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
291pub struct TimeOfDay {
292    /// Hour, 0 to 23.
293    pub hour: u8,
294    /// Minute, 0 to 59.
295    pub minute: u8,
296    /// Second, 0 to 59.
297    pub second: u8,
298}
299
300impl TimeOfDay {
301    /// The last moment of a day, and so the largest value of each part.
302    pub const LARGEST: Self = Self { hour: 23, minute: 59, second: 59 };
303
304    /// The time `hour:minute:second`; each part is capped at its largest value.
305    #[must_use]
306    pub fn new(hour: u8, minute: u8, second: u8) -> Self {
307        Self {
308            hour: hour.min(Self::LARGEST.hour),
309            minute: minute.min(Self::LARGEST.minute),
310            second: second.min(Self::LARGEST.second),
311        }
312    }
313
314    /// Reads `h:mm` or `h:mm:ss` with every part in range; surrounding spaces are ignored and a
315    /// missing second is zero. Out-of-range parts such as `24:00` are `None`, not capped.
316    #[must_use]
317    pub fn parse(text: &str) -> Option<Self> {
318        let parts: Vec<&str> = text.trim().split(':').collect();
319        if !(2..=3).contains(&parts.len()) {
320            return None;
321        }
322        let largest = [Self::LARGEST.hour, Self::LARGEST.minute, Self::LARGEST.second];
323        let mut values = [0u8; 3];
324        for (index, part) in parts.iter().enumerate() {
325            let valid = (1..=2).contains(&part.len()) && part.chars().all(|c| c.is_ascii_digit());
326            values[index] = part.parse().ok().filter(|value| valid && *value <= largest[index])?;
327        }
328        Some(Self { hour: values[0], minute: values[1], second: values[2] })
329    }
330
331    /// Seconds from midnight, 0 to 86 399.
332    #[must_use]
333    pub fn seconds_since_midnight(self) -> u32 {
334        u32::from(self.hour) * 3_600 + u32::from(self.minute) * 60 + u32::from(self.second)
335    }
336
337    /// The time `seconds` after midnight; a value past the end of a day wraps into the next one.
338    #[must_use]
339    pub fn from_seconds_since_midnight(seconds: u32) -> Self {
340        let seconds = seconds % 86_400;
341        Self {
342            hour: u8::try_from(seconds / 3_600).unwrap_or(0),
343            minute: u8::try_from(seconds / 60 % 60).unwrap_or(0),
344            second: u8::try_from(seconds % 60).unwrap_or(0),
345        }
346    }
347}
348
349impl fmt::Display for TimeOfDay {
350    /// Writes `hh:mm:ss`, the same in every language.
351    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
353    }
354}
355
356/// A date and a time of day together, with the offset from UTC the moment was read at.
357///
358/// The date and the time are local: they are what a clock on the wall shows. `offset_minutes`
359/// is what turns them back into an instant, which is what [`DateTime::to_unix`] does.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
361pub struct DateTime {
362    /// The local date.
363    pub date: Date,
364    /// The local time of day.
365    pub time: TimeOfDay,
366    /// Minutes local time is ahead of UTC; negative west of it.
367    pub offset_minutes: i16,
368}
369
370impl DateTime {
371    /// Now where the machine stands, from the system clock and [`local_offset_minutes`].
372    #[must_use]
373    pub fn now_local() -> Self {
374        Self::from_unix(unix_now(), local_offset_minutes())
375    }
376
377    /// The moment `seconds` after 1970-01-01 00:00 UTC, written in an offset of
378    /// `offset_minutes`.
379    #[must_use]
380    pub fn from_unix(seconds: i64, offset_minutes: i16) -> Self {
381        let local = seconds.saturating_add(i64::from(offset_minutes) * 60);
382        let day_seconds = u32::try_from(local.rem_euclid(DAY)).unwrap_or(0);
383        Self {
384            date: Date::from_days(local.div_euclid(DAY)),
385            time: TimeOfDay::from_seconds_since_midnight(day_seconds),
386            offset_minutes,
387        }
388    }
389
390    /// Seconds since 1970-01-01 00:00 UTC.
391    ///
392    /// ```
393    /// use qframe::date::DateTime;
394    ///
395    /// // Noon in Istanbul is 09:00 UTC.
396    /// let noon = DateTime::from_unix(1_773_997_200, 180);
397    /// assert_eq!(noon.time.to_string(), "12:00:00");
398    /// assert_eq!(noon.to_unix(), 1_773_997_200);
399    /// ```
400    #[must_use]
401    pub fn to_unix(&self) -> i64 {
402        self.date
403            .to_days()
404            .saturating_mul(DAY)
405            .saturating_add(i64::from(self.time.seconds_since_midnight()))
406            .saturating_sub(i64::from(self.offset_minutes) * 60)
407    }
408}
409
410/// Minutes local time is ahead of UTC right now, or `None` when the system does not say.
411///
412/// The offset is read from the system time zone file — what `TZ` names, or `/etc/localtime` —
413/// once per process. `None` means there was no such file or it could not be understood: Windows,
414/// a system with no zone database, or a `TZ` holding a POSIX rule rather than a zone name. An
415/// application that wants to be exact can tell its user the local time is unknown instead of
416/// showing UTC as if it were local.
417#[must_use]
418pub fn local_offset() -> Option<i16> {
419    zone::offset_minutes(unix_now())
420}
421
422/// Minutes local time is ahead of UTC right now, and 0 when the system does not say.
423///
424/// Use [`local_offset`] where the difference between "UTC" and "unknown" matters.
425#[must_use]
426pub fn local_offset_minutes() -> i16 {
427    local_offset().unwrap_or(0)
428}
429
430/// Seconds since 1970-01-01 00:00 UTC, from the system clock; negative before it.
431fn unix_now() -> i64 {
432    match SystemTime::now().duration_since(UNIX_EPOCH) {
433        Ok(since) => i64::try_from(since.as_secs()).unwrap_or(i64::MAX),
434        Err(before) => i64::try_from(before.duration().as_secs()).unwrap_or(i64::MAX).saturating_neg(),
435    }
436}
437
438/// Whether `year` has a February 29.
439#[must_use]
440pub fn is_leap_year(year: i32) -> bool {
441    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
442}
443
444/// How many days `month` of `year` has; 0 for a month outside 1 to 12.
445#[must_use]
446pub fn days_in_month(year: i32, month: u8) -> u8 {
447    match month {
448        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
449        4 | 6 | 9 | 11 => 30,
450        2 if is_leap_year(year) => 29,
451        2 => 28,
452        _ => 0,
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn date(year: i32, month: u8, day: u8) -> Date {
461        Date::new(year, month, day).expect("valid date")
462    }
463
464    #[test]
465    fn validates_days_and_leap_years() {
466        assert!(Date::new(2024, 2, 29).is_some());
467        assert!(Date::new(2026, 2, 29).is_none());
468        assert!(Date::new(1900, 2, 29).is_none());
469        assert!(Date::new(2000, 2, 29).is_some());
470        assert!(Date::new(2026, 13, 1).is_none());
471        assert!(Date::new(2026, 4, 31).is_none());
472        assert!(Date::new(2026, 4, 0).is_none());
473    }
474
475    #[test]
476    fn counts_days_from_the_epoch_both_ways() {
477        assert_eq!(date(1970, 1, 1).to_days(), 0);
478        assert_eq!(date(2000, 3, 1).to_days(), 11_017);
479        assert_eq!(date(1969, 12, 31).to_days(), -1);
480        assert_eq!(date(2026, 9, 16).to_days(), 20_712);
481        for days in (-800_000..800_000).step_by(997) {
482            assert_eq!(Date::from_days(days).to_days(), days);
483        }
484        assert_eq!(Date::from_days(-719_468), date(0, 3, 1));
485    }
486
487    #[test]
488    fn knows_weekdays() {
489        assert_eq!(date(1970, 1, 1).weekday(), Weekday::Thursday);
490        assert_eq!(date(2026, 9, 16).weekday(), Weekday::Wednesday);
491        assert_eq!(date(2000, 1, 1).weekday(), Weekday::Saturday);
492        assert_eq!(date(1900, 1, 1).weekday(), Weekday::Monday);
493        assert_eq!(Weekday::Sunday.days_since(Weekday::Monday), 6);
494        assert_eq!(Weekday::Monday.days_since(Weekday::Sunday), 1);
495        assert_eq!(Weekday::from_number(7), Some(Weekday::Sunday));
496        assert_eq!(Weekday::from_number(0), None);
497    }
498
499    #[test]
500    fn adds_days_and_months() {
501        assert_eq!(date(2026, 12, 31).add_days(1), date(2027, 1, 1));
502        assert_eq!(date(2024, 3, 1).add_days(-1), date(2024, 2, 29));
503        assert_eq!(date(2026, 1, 31).add_months(1), date(2026, 2, 28));
504        assert_eq!(date(2024, 1, 31).add_months(1), date(2024, 2, 29));
505        assert_eq!(date(2026, 1, 15).add_months(-1), date(2025, 12, 15));
506        assert_eq!(date(2026, 3, 31).add_months(-13), date(2025, 2, 28));
507        assert_eq!(date(2026, 9, 16).add_months(24), date(2028, 9, 16));
508    }
509
510    #[test]
511    fn extreme_day_counts_do_not_overflow() {
512        assert_eq!(Date::from_days(i64::MAX).year(), i32::MAX);
513        assert_eq!(Date::from_days(i64::MIN).year(), i32::MIN);
514        assert_eq!(date(2026, 9, 16).add_days(i64::MAX).year(), i32::MAX);
515    }
516
517    #[test]
518    fn finds_week_starts() {
519        let wednesday = date(2026, 9, 16);
520        assert_eq!(wednesday.start_of_week(Weekday::Monday), date(2026, 9, 14));
521        assert_eq!(wednesday.start_of_week(Weekday::Sunday), date(2026, 9, 13));
522        assert_eq!(date(2026, 9, 13).start_of_week(Weekday::Sunday), date(2026, 9, 13));
523        assert_eq!(wednesday.first_of_month(), date(2026, 9, 1));
524    }
525
526    #[test]
527    fn today_is_a_real_date() {
528        let today = Date::today_utc();
529        assert!(today.year() >= 2024 && Date::new(today.year(), today.month(), today.day()).is_some());
530    }
531
532    #[test]
533    fn reads_and_writes_iso_dates() {
534        assert_eq!(Date::parse("2026-09-17"), Ok(date(2026, 9, 17)));
535        assert_eq!("2026-09-17".parse::<Date>(), Ok(date(2026, 9, 17)));
536        assert_eq!(Date::parse("  2026-09-17\n"), Ok(date(2026, 9, 17)), "spaces around it are ignored");
537        assert_eq!(date(2026, 9, 17).to_string(), "2026-09-17");
538        assert_eq!(date(999, 1, 2).to_string(), "0999-01-02");
539        assert_eq!(Date::parse("-0044-03-15"), Ok(date(-44, 3, 15)));
540        assert_eq!(date(-44, 3, 15).to_string(), "-0044-03-15");
541        for days in (-400_000..400_000).step_by(499) {
542            let value = Date::from_days(days);
543            assert_eq!(Date::parse(&value.to_string()), Ok(value), "{value}");
544        }
545    }
546
547    #[test]
548    fn iso_dates_that_are_no_date_are_refused() {
549        for text in [
550            "",
551            "2026",
552            "2026-09",
553            "2026-09-17-01",
554            "2026/09/17",
555            "20260917",
556            "2026-9-17",
557            "2026-09-7",
558            "2026-09-017",
559            "26-09-17",
560            "2026-aa-17",
561            "2026-09-1x",
562            "2026-13-40",
563            "2026-02-30",
564            "2026-04-31",
565            "2026-00-10",
566            "2026-09-00",
567            "1900-02-29",
568            "+2026-09-17",
569        ] {
570            assert!(Date::parse(text).is_err(), "`{text}` was read as a date");
571        }
572        // Leap years are the calendar's, not a guess.
573        assert_eq!(Date::parse("2024-02-29"), Ok(date(2024, 2, 29)));
574        assert_eq!(Date::parse("2000-02-29"), Ok(date(2000, 2, 29)));
575        assert!(Date::parse("2100-02-29").is_err());
576        // The message names what was wrong.
577        assert!(Date::parse("2026-02-30").is_err_and(|error| error.contains("not a day this calendar has")));
578        assert!(Date::parse("2026-9-17").is_err_and(|error| error.contains("2 digits")));
579    }
580
581    #[test]
582    fn times_of_day_are_capped_read_and_written() {
583        assert_eq!(TimeOfDay::new(25, 70, 90), TimeOfDay::LARGEST);
584        assert_eq!(TimeOfDay::new(9, 30, 0).to_string(), "09:30:00");
585        assert_eq!(TimeOfDay::parse("9:30"), Some(TimeOfDay::new(9, 30, 0)));
586        assert_eq!(TimeOfDay::parse(" 09:30:15 "), Some(TimeOfDay::new(9, 30, 15)));
587        assert_eq!(TimeOfDay::parse("24:00"), None, "out of range is refused, not capped");
588        assert_eq!(TimeOfDay::parse("9"), None);
589        assert_eq!(TimeOfDay::parse("9:30:15:20"), None);
590        assert_eq!(TimeOfDay::parse("nine:thirty"), None);
591        assert_eq!(TimeOfDay::default(), TimeOfDay::new(0, 0, 0));
592        assert!(TimeOfDay::new(9, 30, 0) < TimeOfDay::new(9, 30, 1), "times compare in clock order");
593    }
594
595    #[test]
596    fn seconds_since_midnight_go_both_ways() {
597        assert_eq!(TimeOfDay::new(0, 0, 0).seconds_since_midnight(), 0);
598        assert_eq!(TimeOfDay::LARGEST.seconds_since_midnight(), 86_399);
599        assert_eq!(TimeOfDay::new(9, 30, 15).seconds_since_midnight(), 34_215);
600        for seconds in (0..86_400).step_by(37) {
601            assert_eq!(TimeOfDay::from_seconds_since_midnight(seconds).seconds_since_midnight(), seconds);
602        }
603        assert_eq!(TimeOfDay::from_seconds_since_midnight(86_400), TimeOfDay::new(0, 0, 0), "a day wraps");
604    }
605
606    #[test]
607    fn a_moment_and_its_unix_second_agree() {
608        let epoch = DateTime::from_unix(0, 0);
609        assert_eq!(epoch.date, date(1970, 1, 1));
610        assert_eq!(epoch.time, TimeOfDay::new(0, 0, 0));
611        assert_eq!(epoch.to_unix(), 0);
612
613        // The same instant in Istanbul and in Los Angeles.
614        let istanbul = DateTime::from_unix(1_773_997_200, 180);
615        assert_eq!((istanbul.date.to_string(), istanbul.time.to_string()), ("2026-03-20".into(), "12:00:00".into()));
616        let angeles = DateTime::from_unix(1_773_997_200, -420);
617        assert_eq!((angeles.date.to_string(), angeles.time.to_string()), ("2026-03-20".into(), "02:00:00".into()));
618        assert_eq!(istanbul.to_unix(), angeles.to_unix());
619
620        // An offset can push the local day over either end of the UTC day.
621        let before = DateTime::from_unix(0, -180);
622        assert_eq!((before.date.to_string(), before.time.to_string()), ("1969-12-31".into(), "21:00:00".into()));
623        assert_eq!(before.to_unix(), 0);
624        let after = DateTime::from_unix(-1, 120);
625        assert_eq!((after.date.to_string(), after.time.to_string()), ("1970-01-01".into(), "01:59:59".into()));
626        assert_eq!(after.to_unix(), -1);
627
628        for seconds in (-2_000_000_000..2_000_000_000).step_by(1_000_003) {
629            for offset in [-720, -270, 0, 180, 345, 840] {
630                assert_eq!(DateTime::from_unix(seconds, offset).to_unix(), seconds, "{seconds} at {offset}");
631            }
632        }
633    }
634
635    #[test]
636    fn extreme_unix_seconds_do_not_overflow() {
637        for seconds in [i64::MIN, i64::MIN + 1, i64::MAX - 1, i64::MAX] {
638            for offset in [i16::MIN, -1, 0, 1, i16::MAX] {
639                let moment = DateTime::from_unix(seconds, offset);
640                assert!(Date::new(moment.date.year(), moment.date.month(), moment.date.day()).is_some());
641                let _ = moment.to_unix();
642            }
643        }
644    }
645
646    #[test]
647    fn the_local_offset_is_either_known_or_said_to_be_unknown() {
648        match local_offset() {
649            Some(minutes) => {
650                assert_eq!(local_offset_minutes(), minutes);
651                assert!((-720..=840).contains(&minutes), "{minutes} minutes is no time zone offset");
652            }
653            None => assert_eq!(local_offset_minutes(), 0, "an unknown offset counts as UTC"),
654        }
655    }
656
657    #[test]
658    fn local_today_and_now_agree_with_utc_within_the_offset() {
659        // One reading answers everything that must agree exactly; the clock is read again only
660        // where a midnight passing in between is allowed for, since two readings either side of
661        // one are a day apart and this test would otherwise fail every night for one second.
662        let before = Date::today_local();
663        let now = DateTime::now_local();
664        let after = Date::today_local();
665        assert_eq!(now.offset_minutes, local_offset_minutes());
666        assert!(now.date == before || now.date == after, "{} is not the local day {before}", now.date);
667        let difference = now.date.to_days() - DateTime::from_unix(now.to_unix(), 0).date.to_days();
668        assert!((-1..=1).contains(&difference), "the local day is at most a day from the UTC one");
669        assert!(now.date.year() >= 2024, "{}", now.date);
670        // The offset is exactly what turns the local reading back into the UTC one.
671        let utc = DateTime::from_unix(now.to_unix(), 0);
672        let minutes = (now.date.to_days() - utc.date.to_days()) * 1_440
673            + i64::from(now.time.seconds_since_midnight()) / 60
674            - i64::from(utc.time.seconds_since_midnight()) / 60;
675        assert_eq!(minutes, i64::from(now.offset_minutes));
676    }
677}