Skip to main content

fc_dioxus/behavior/
date.rs

1//! Calendar arithmetic for the date picker, ported from upstream's `datepicker.ts`.
2//!
3//! Dates are civil dates with no time zone, so no daylight-saving shift can turn
4//! midnight into the previous day; they cross the API as ISO `YYYY-MM-DD`. Day counts
5//! use Howard Hinnant's `days_from_civil`, exact over the whole proleptic Gregorian range
6//! this type allows.
7
8use std::fmt;
9
10/// A calendar date. Construction validates it, so every `Date` exists.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct Date {
13    year: i32,
14    month: u8,
15    day: u8,
16}
17
18/// The text or numbers do not name a real date.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct InvalidDate;
21impl fmt::Display for InvalidDate {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str("expected a real date as YYYY-MM-DD")
24    }
25}
26impl std::error::Error for InvalidDate {}
27
28const fn is_leap(year: i32) -> bool {
29    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
30}
31
32/// Days in a month of a year.
33#[must_use]
34pub const fn days_in_month(year: i32, month: u8) -> u8 {
35    match month {
36        2 if is_leap(year) => 29,
37        2 => 28,
38        4 | 6 | 9 | 11 => 30,
39        _ => 31,
40    }
41}
42
43impl Date {
44    /// A date from its parts.
45    ///
46    /// # Errors
47    /// Returns `InvalidDate` for a month outside 1–12 or a day the month lacks, or a year
48    /// outside 1–9999, which ISO strings cannot write in four digits.
49    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, InvalidDate> {
50        if (1..=9999).contains(&year)
51            && (1..=12).contains(&month)
52            && day >= 1
53            && day <= days_in_month(year, month)
54        {
55            Ok(Self { year, month, day })
56        } else {
57            Err(InvalidDate)
58        }
59    }
60    /// Parse `YYYY-MM-DD`.
61    ///
62    /// # Errors
63    /// Returns `InvalidDate` for any other shape or a date that does not exist.
64    pub fn parse(iso: &str) -> Result<Self, InvalidDate> {
65        let mut parts = iso.split('-');
66        let (Some(y), Some(m), Some(d), None) =
67            (parts.next(), parts.next(), parts.next(), parts.next())
68        else {
69            return Err(InvalidDate);
70        };
71        if y.len() != 4 || m.len() != 2 || d.len() != 2 {
72            return Err(InvalidDate);
73        }
74        let number = |s: &str| s.parse::<u16>().map_err(|_| InvalidDate);
75        let year = i32::from(number(y)?);
76        let month = u8::try_from(number(m)?).map_err(|_| InvalidDate)?;
77        let day = u8::try_from(number(d)?).map_err(|_| InvalidDate)?;
78        Self::new(year, month, day)
79    }
80    /// The year.
81    #[must_use]
82    pub const fn year(self) -> i32 {
83        self.year
84    }
85    /// The month, 1–12.
86    #[must_use]
87    pub const fn month(self) -> u8 {
88        self.month
89    }
90    /// The day of the month.
91    #[must_use]
92    pub const fn day(self) -> u8 {
93        self.day
94    }
95    /// Days since 1970-01-01.
96    #[must_use]
97    pub fn to_days(self) -> i64 {
98        let y = i64::from(self.year) - i64::from(self.month <= 2);
99        let era = y.div_euclid(400);
100        let yoe = y - era * 400;
101        let m = i64::from(self.month);
102        let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(self.day) - 1;
103        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
104        era * 146_097 + doe - 719_468
105    }
106    /// The date `days` since 1970-01-01, or `None` outside years 1–9999.
107    #[must_use]
108    pub fn from_days(days: i64) -> Option<Self> {
109        let z = days.checked_add(719_468)?;
110        let era = z.div_euclid(146_097);
111        let doe = z - era * 146_097;
112        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
113        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
114        let mp = (5 * doy + 2) / 153;
115        let day = doy - (153 * mp + 2) / 5 + 1;
116        let month = if mp < 10 { mp + 3 } else { mp - 9 };
117        let year = yoe + era * 400 + i64::from(month <= 2);
118        Self::new(
119            i32::try_from(year).ok()?,
120            u8::try_from(month).ok()?,
121            u8::try_from(day).ok()?,
122        )
123        .ok()
124    }
125    /// The date `delta` days away, or `None` past the representable range.
126    #[must_use]
127    pub fn add_days(self, delta: i64) -> Option<Self> {
128        Self::from_days(self.to_days().checked_add(delta)?)
129    }
130    /// Day of the week, 0 = Sunday … 6 = Saturday.
131    #[must_use]
132    pub fn weekday(self) -> u8 {
133        // 1970-01-01 was a Thursday.
134        u8::try_from((self.to_days() + 4).rem_euclid(7)).unwrap_or(0)
135    }
136    /// The same day `delta` months away, clamped to that month's length, as upstream's
137    /// `addMonths`.
138    ///
139    /// ```
140    /// use fc_dioxus::behavior::date::Date;
141    /// let Ok(jan31) = Date::parse("2026-01-31") else { return };
142    /// assert_eq!(jan31.add_months(1).map(|d| d.to_string()), Some("2026-02-28".into()));
143    /// assert_eq!(jan31.add_months(-13).map(|d| d.to_string()), Some("2024-12-31".into()));
144    /// ```
145    #[must_use]
146    pub fn add_months(self, delta: i32) -> Option<Self> {
147        let index = self
148            .year
149            .checked_mul(12)?
150            .checked_add(i32::from(self.month) - 1)?
151            .checked_add(delta)?;
152        let year = index.div_euclid(12);
153        let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
154        let day = self.day.min(days_in_month(year, month));
155        Self::new(year, month, day).ok()
156    }
157    /// The first of this date's month.
158    #[must_use]
159    pub fn first_of_month(self) -> Self {
160        Self { day: 1, ..self }
161    }
162}
163impl fmt::Display for Date {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
166    }
167}
168
169/// How many blank cells come before the first of the month, given the first day of the
170/// week (0 = Sunday).
171#[must_use]
172pub fn leading_blanks(first_of_month: Date, first_day_of_week: u8) -> u8 {
173    (first_of_month.weekday() + 7 - first_day_of_week % 7) % 7
174}
175
176/// Where a key moves focus from `current`, as upstream's date grid does: arrows by a day
177/// (swapped in RTL) or a week, Home and End to the ends of the week, Page Up and Down by
178/// a month, or a year with Shift.
179#[must_use]
180pub fn key_target(
181    key: &dioxus::prelude::Key,
182    shift: bool,
183    rtl: bool,
184    current: Date,
185    first_day_of_week: u8,
186) -> Option<Date> {
187    use dioxus::prelude::Key;
188    let weekday = i64::from((current.weekday() + 7 - first_day_of_week % 7) % 7);
189    match key {
190        Key::ArrowRight => current.add_days(if rtl { -1 } else { 1 }),
191        Key::ArrowLeft => current.add_days(if rtl { 1 } else { -1 }),
192        Key::ArrowDown => current.add_days(7),
193        Key::ArrowUp => current.add_days(-7),
194        Key::Home => current.add_days(-weekday),
195        Key::End => current.add_days(6 - weekday),
196        Key::PageDown => current.add_months(if shift { 12 } else { 1 }),
197        Key::PageUp => current.add_months(if shift { -12 } else { -1 }),
198        _ => None,
199    }
200}