Skip to main content

ravenrs_widgets/
date.rs

1//! A small civil-date type for calendars and date pickers (proleptic Gregorian,
2//! no time zones), so date components need no external dependency.
3
4use std::fmt;
5
6/// A calendar date (year, month, day), always valid.
7///
8/// ```
9/// use ravenrs_widgets::Date;
10///
11/// let d = Date::new(2026, 1, 31).unwrap();
12/// assert_eq!(d.add_months(1).to_string(), "2026-02-28");
13/// assert_eq!(d.add_days(1).short(), "Feb 1, 2026");
14/// assert!(Date::new(2026, 2, 30).is_none());
15/// ```
16#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
17#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
18pub struct Date {
19    year: i32,
20    month: u8,
21    day: u8,
22}
23
24pub const MONTH_NAMES: [&str; 12] = [
25    "January",
26    "February",
27    "March",
28    "April",
29    "May",
30    "June",
31    "July",
32    "August",
33    "September",
34    "October",
35    "November",
36    "December",
37];
38
39#[cfg_attr(feature = "live", ravenrs_live::expose)]
40impl Date {
41    /// Returns `None` for impossible dates (e.g. February 30).
42    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
43        (1..=12)
44            .contains(&month)
45            .then(|| days_in_month(year, month))
46            .filter(|&n| day >= 1 && day <= n)
47            .map(|_| Self { year, month, day })
48    }
49
50    /// The year, e.g. 2026.
51    pub fn year(self) -> i32 {
52        self.year
53    }
54
55    /// The month, 1 = January … 12 = December.
56    pub fn month(self) -> u8 {
57        self.month
58    }
59
60    /// The day of the month, starting at 1.
61    pub fn day(self) -> u8 {
62        self.day
63    }
64
65    /// Day of the week, 0 = Sunday … 6 = Saturday.
66    pub fn weekday(self) -> u8 {
67        // Sakamoto's method.
68        const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
69        let y = if self.month < 3 { self.year - 1 } else { self.year };
70        let w = (y + y.div_euclid(4) - y.div_euclid(100)
71            + y.div_euclid(400)
72            + T[usize::from(self.month - 1)]
73            + i32::from(self.day))
74        .rem_euclid(7);
75        w as u8
76    }
77
78    /// The first day of this date's month.
79    pub fn first_of_month(self) -> Self {
80        Self { day: 1, ..self }
81    }
82
83    /// Moves by whole months, clamping the day (Jan 31 + 1 month = Feb 28/29).
84    pub fn add_months(self, months: i32) -> Self {
85        let total = self.year * 12 + i32::from(self.month) - 1 + months;
86        let (year, month) = (total.div_euclid(12), (total.rem_euclid(12) + 1) as u8);
87        Self { year, month, day: self.day.min(days_in_month(year, month)) }
88    }
89
90    /// Moves by `days` (negative goes back).
91    pub fn add_days(self, days: i32) -> Self {
92        Self::from_days(self.to_days() + i64::from(days))
93    }
94
95    /// Days since 1970-01-01.
96    pub fn to_days(self) -> i64 {
97        // Howard Hinnant's days_from_civil.
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 * (if m > 2 { m - 3 } else { m + 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
107    /// The date `days` days after 1970-01-01 (inverse of [`Date::to_days`]).
108    pub fn from_days(days: i64) -> Self {
109        let z = days + 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) as u8;
116        let month = if mp < 10 { mp + 3 } else { mp - 9 } as u8;
117        let year = (yoe + era * 400 + i64::from(month <= 2)) as i32;
118        Self { year, month, day }
119    }
120
121    /// "October 2026".
122    pub fn month_title(self) -> String {
123        format!("{} {}", MONTH_NAMES[usize::from(self.month - 1)], self.year)
124    }
125
126    /// "Oct 31, 2026".
127    pub fn short(self) -> String {
128        format!("{} {}, {}", &MONTH_NAMES[usize::from(self.month - 1)][..3], self.day, self.year)
129    }
130}
131
132impl fmt::Display for Date {
133    /// ISO 8601: `2026-10-31`.
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
136    }
137}
138
139/// Whether `year` has a February 29 (Gregorian rules).
140pub fn is_leap_year(year: i32) -> bool {
141    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
142}
143
144/// Number of days in `month` (1–12) of `year`.
145pub fn days_in_month(year: i32, month: u8) -> u8 {
146    match month {
147        2 if is_leap_year(year) => 29,
148        2 => 28,
149        4 | 6 | 9 | 11 => 30,
150        _ => 31,
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn validates_and_formats() {
160        assert!(Date::new(2026, 2, 29).is_none());
161        assert!(Date::new(2024, 2, 29).is_some());
162        let d = Date::new(2026, 10, 31).unwrap();
163        assert_eq!(d.to_string(), "2026-10-31");
164        assert_eq!(d.short(), "Oct 31, 2026");
165        assert_eq!(d.month_title(), "October 2026");
166    }
167
168    #[test]
169    fn weekdays_and_arithmetic() {
170        assert_eq!(Date::new(2026, 10, 31).unwrap().weekday(), 6, "a Saturday");
171        assert_eq!(Date::new(2000, 1, 1).unwrap().weekday(), 6);
172        assert_eq!(Date::new(2026, 1, 31).unwrap().add_months(1), Date::new(2026, 2, 28).unwrap());
173        assert_eq!(Date::new(2026, 12, 15).unwrap().add_months(1), Date::new(2027, 1, 15).unwrap());
174        assert_eq!(Date::new(2026, 3, 1).unwrap().add_days(-1), Date::new(2026, 2, 28).unwrap());
175        assert_eq!(Date::new(1970, 1, 1).unwrap().to_days(), 0);
176        for days in [-1_000_000_i64, -1, 0, 59, 60, 19_000, 2_000_000] {
177            assert_eq!(Date::from_days(days).to_days(), days);
178        }
179    }
180}