ailloli_ui_core/
date_picker.rs

1//! Pure date picker values and calendar helpers.
2
3/// A date in the proleptic Gregorian calendar.
4///
5/// # Examples
6///
7/// ```
8/// use ailloli_ui_core::DateValue;
9///
10/// let date = DateValue::new(2024, 2, 31);
11/// assert_eq!(date.format_yyyy_mm_dd(), "2024-02-29");
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct DateValue {
15    /// Signed calendar year.
16    pub year: i32,
17    /// One-based month in `1..=12` for values created by [`DateValue::new`].
18    pub month: u8,
19    /// One-based day, bounded by the selected month for validated values.
20    pub day: u8,
21}
22
23/// A year and month in the proleptic Gregorian calendar.
24///
25/// # Examples
26///
27/// ```
28/// use ailloli_ui_core::MonthValue;
29///
30/// assert_eq!(MonthValue::new(2026, 13).month, 12);
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct MonthValue {
34    /// Signed calendar year.
35    pub year: i32,
36    /// One-based month in `1..=12` for values created by [`MonthValue::new`].
37    pub month: u8,
38}
39
40/// First column of a generated calendar week.
41///
42/// # Examples
43///
44/// ```
45/// use ailloli_ui_core::WeekStart;
46///
47/// assert_eq!(WeekStart::default(), WeekStart::Monday);
48/// ```
49#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub enum WeekStart {
51    /// Weeks begin on Sunday.
52    Sunday,
53    /// Weeks begin on Monday; this is the default.
54    #[default]
55    Monday,
56}
57
58/// One cell in the fixed six-week grid returned by [`month_grid`].
59///
60/// # Examples
61///
62/// ```
63/// use ailloli_ui_core::date_picker::CalendarDay;
64/// use ailloli_ui_core::DateValue;
65///
66/// let day = CalendarDay { date: DateValue::new(2026, 5, 1), in_month: true };
67/// assert!(day.in_month);
68/// ```
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CalendarDay {
71    /// Date represented by this cell.
72    pub date: DateValue,
73    /// `true` when `date` belongs to the requested month; `false` for padding.
74    pub in_month: bool,
75}
76
77impl DateValue {
78    /// Creates a date, clamping the month and day to a valid calendar value.
79    ///
80    /// Months clamp to `1..=12`; days then clamp to the valid range for that
81    /// month and year.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use ailloli_ui_core::DateValue;
87    /// assert_eq!(DateValue::new(2024, 2, 31), DateValue { year: 2024, month: 2, day: 29 });
88    /// ```
89    pub fn new(year: i32, month: u8, day: u8) -> Self {
90        let month = month.clamp(1, 12);
91        let day = day.clamp(1, days_in_month(year, month));
92        Self { year, month, day }
93    }
94
95    /// Returns this date's year and month.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use ailloli_ui_core::{DateValue, MonthValue};
101    /// assert_eq!(DateValue::new(2024, 7, 3).month_value(), MonthValue::new(2024, 7));
102    /// ```
103    pub fn month_value(self) -> MonthValue {
104        MonthValue::new(self.year, self.month)
105    }
106
107    /// Formats the date as a zero-padded `YYYY-MM-DD` string.
108    ///
109    /// Years wider than four digits are not truncated.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use ailloli_ui_core::DateValue;
115    /// assert_eq!(DateValue::new(2024, 7, 3).format_yyyy_mm_dd(), "2024-07-03");
116    /// ```
117    pub fn format_yyyy_mm_dd(self) -> String {
118        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
119    }
120}
121
122impl MonthValue {
123    /// Creates a month, clamping `month` to `1..=12`.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use ailloli_ui_core::MonthValue;
129    /// assert_eq!(MonthValue::new(2024, 20), MonthValue { year: 2024, month: 12 });
130    /// ```
131    pub fn new(year: i32, month: u8) -> Self {
132        Self {
133            year,
134            month: month.clamp(1, 12),
135        }
136    }
137}
138
139/// Parses an exact decimal `year-month-day` date with a valid month and day.
140///
141/// Returns `None` for missing or extra components, numeric parse failures, or
142/// an out-of-range month/day. Surrounding whitespace and `+` signs follow the
143/// underlying integer parsers; callers needing a stricter wire format should
144/// validate the original string separately.
145///
146/// # Examples
147///
148/// ```
149/// use ailloli_ui_core::date_picker::parse_yyyy_mm_dd;
150/// use ailloli_ui_core::DateValue;
151///
152/// assert_eq!(parse_yyyy_mm_dd("2024-02-29"), Some(DateValue::new(2024, 2, 29)));
153/// assert_eq!(parse_yyyy_mm_dd("2023-02-29"), None);
154/// ```
155pub fn parse_yyyy_mm_dd(input: &str) -> Option<DateValue> {
156    let mut parts = input.split('-');
157    let year = parts.next()?.parse::<i32>().ok()?;
158    let month = parts.next()?.parse::<u8>().ok()?;
159    let day = parts.next()?.parse::<u8>().ok()?;
160    if parts.next().is_some() || !(1..=12).contains(&month) {
161        return None;
162    }
163    if !(1..=days_in_month(year, month)).contains(&day) {
164        return None;
165    }
166    Some(DateValue { year, month, day })
167}
168
169/// Returns whether `year` is a leap year in the proleptic Gregorian calendar.
170///
171/// # Examples
172///
173/// ```
174/// use ailloli_ui_core::date_picker::is_leap_year;
175///
176/// assert!(is_leap_year(2000));
177/// assert!(!is_leap_year(2100));
178/// ```
179pub fn is_leap_year(year: i32) -> bool {
180    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
181}
182
183/// Returns the number of days in `month` for `year`.
184///
185/// Invalid month values are clamped to `1..=12` before evaluation.
186///
187/// # Examples
188///
189/// ```
190/// use ailloli_ui_core::date_picker::days_in_month;
191///
192/// assert_eq!(days_in_month(2024, 2), 29);
193/// assert_eq!(days_in_month(2026, 13), 31);
194/// ```
195pub fn days_in_month(year: i32, month: u8) -> u8 {
196    match month.clamp(1, 12) {
197        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
198        4 | 6 | 9 | 11 => 30,
199        2 if is_leap_year(year) => 29,
200        2 => 28,
201        _ => 30,
202    }
203}
204
205/// Clamps a date to optional inclusive bounds.
206///
207/// The lower bound is applied before the upper bound. If `min > max`, the
208/// result is therefore `max`; callers should normally provide ordered bounds.
209///
210/// # Examples
211///
212/// ```
213/// use ailloli_ui_core::date_picker::clamp_date;
214/// use ailloli_ui_core::DateValue;
215///
216/// let min = DateValue::new(2026, 1, 10);
217/// assert_eq!(clamp_date(DateValue::new(2026, 1, 1), Some(min), None), min);
218/// ```
219pub fn clamp_date(value: DateValue, min: Option<DateValue>, max: Option<DateValue>) -> DateValue {
220    let mut value = value;
221    if let Some(min) = min {
222        value = value.max(min);
223    }
224    if let Some(max) = max {
225        value = value.min(max);
226    }
227    value
228}
229
230/// Returns whether a date falls within both optional inclusive bounds.
231///
232/// With no bounds every value is enabled. Inverted bounds disable every date.
233///
234/// # Examples
235///
236/// ```
237/// use ailloli_ui_core::date_picker::is_date_enabled;
238/// use ailloli_ui_core::DateValue;
239///
240/// let date = DateValue::new(2026, 5, 4);
241/// assert!(is_date_enabled(date, None, None));
242/// assert!(!is_date_enabled(date, Some(DateValue::new(2026, 5, 5)), None));
243/// ```
244pub fn is_date_enabled(value: DateValue, min: Option<DateValue>, max: Option<DateValue>) -> bool {
245    min.is_none_or(|min| value >= min) && max.is_none_or(|max| value <= max)
246}
247
248/// Adds a signed number of calendar months using Euclidean year rollover.
249///
250/// # Panics
251///
252/// Debug builds may panic if the intermediate `year * 12 + delta` overflows
253/// `i32`; use ordinary application-scale years and deltas.
254///
255/// # Examples
256///
257/// ```
258/// use ailloli_ui_core::date_picker::add_months;
259/// use ailloli_ui_core::MonthValue;
260///
261/// assert_eq!(add_months(MonthValue::new(2026, 1), -2), MonthValue::new(2025, 11));
262/// ```
263pub fn add_months(month: MonthValue, delta: i32) -> MonthValue {
264    let zero_based = month.year * 12 + (month.month as i32 - 1) + delta;
265    let year = zero_based.div_euclid(12);
266    let month = zero_based.rem_euclid(12) as u8 + 1;
267    MonthValue { year, month }
268}
269
270/// Moves a date by a signed number of calendar days.
271///
272/// Runtime is proportional to the number of crossed months.
273///
274/// # Panics
275///
276/// Debug builds may panic if adding `delta` to the day or crossing an extreme
277/// `i32` year overflows.
278///
279/// # Examples
280///
281/// ```
282/// use ailloli_ui_core::date_picker::next_day;
283/// use ailloli_ui_core::DateValue;
284///
285/// assert_eq!(next_day(DateValue::new(2024, 2, 28), 1), DateValue::new(2024, 2, 29));
286/// ```
287pub fn next_day(date: DateValue, delta: i32) -> DateValue {
288    let mut year = date.year;
289    let mut month = date.month;
290    let mut day = date.day as i32 + delta;
291    loop {
292        let dim = days_in_month(year, month) as i32;
293        if day < 1 {
294            let prev = add_months(MonthValue::new(year, month), -1);
295            year = prev.year;
296            month = prev.month;
297            day += days_in_month(year, month) as i32;
298        } else if day > dim {
299            day -= dim;
300            let next = add_months(MonthValue::new(year, month), 1);
301            year = next.year;
302            month = next.month;
303        } else {
304            return DateValue::new(year, month, day as u8);
305        }
306    }
307}
308
309/// Returns the zero-based weekday column for `date` and `week_start`.
310///
311/// The result is always in `0..=6`; `0` denotes the requested first weekday.
312///
313/// # Panics
314///
315/// Debug builds may panic for extreme `i32` years whose calendar arithmetic
316/// overflows.
317///
318/// # Examples
319///
320/// ```
321/// use ailloli_ui_core::date_picker::weekday_index;
322/// use ailloli_ui_core::{DateValue, WeekStart};
323///
324/// assert_eq!(weekday_index(DateValue::new(2026, 5, 4), WeekStart::Monday), 0);
325/// ```
326pub fn weekday_index(date: DateValue, week_start: WeekStart) -> u8 {
327    let mut y = date.year;
328    let mut m = date.month as i32;
329    let d = date.day as i32;
330    if m < 3 {
331        m += 12;
332        y -= 1;
333    }
334    let k = y.rem_euclid(100);
335    let j = y.div_euclid(100);
336    let h = (d + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j).rem_euclid(7);
337    let sunday_based = ((h + 6) % 7) as u8;
338    match week_start {
339        WeekStart::Sunday => sunday_based,
340        WeekStart::Monday => (sunday_based + 6) % 7,
341    }
342}
343
344/// Generates a fixed 42-cell, six-week calendar grid around `month`.
345///
346/// Leading and trailing cells belong to adjacent months and have
347/// [`CalendarDay::in_month`] set to `false`.
348///
349/// # Panics
350///
351/// Debug builds may panic for extreme `i32` years when the delegated weekday
352/// or adjacent-day arithmetic overflows.
353///
354/// # Examples
355///
356/// ```
357/// use ailloli_ui_core::date_picker::month_grid;
358/// use ailloli_ui_core::{MonthValue, WeekStart};
359///
360/// let grid = month_grid(MonthValue::new(2026, 5), WeekStart::Monday);
361/// assert_eq!(grid.len(), 42);
362/// assert!(grid.iter().any(|day| !day.in_month));
363/// ```
364pub fn month_grid(month: MonthValue, week_start: WeekStart) -> [CalendarDay; 42] {
365    let first = DateValue::new(month.year, month.month, 1);
366    let start = next_day(first, -(weekday_index(first, week_start) as i32));
367    std::array::from_fn(|idx| {
368        let date = next_day(start, idx as i32);
369        CalendarDay {
370            date,
371            in_month: date.year == month.year && date.month == month.month,
372        }
373    })
374}
375
376#[cfg(test)]
377mod tests {
378    //! Covers leap years, six-week grids, and month rollover in both directions.
379
380    use super::*;
381
382    #[test]
383    fn leap_year_and_days_are_stable() {
384        assert!(is_leap_year(2024));
385        assert!(!is_leap_year(2026));
386        assert_eq!(days_in_month(2024, 2), 29);
387        assert_eq!(days_in_month(2026, 2), 28);
388    }
389
390    #[test]
391    fn month_grid_is_six_weeks_with_monday_start() {
392        let grid = month_grid(MonthValue::new(2026, 5), WeekStart::Monday);
393        assert_eq!(grid.len(), 42);
394        assert_eq!(grid[0].date, DateValue::new(2026, 4, 27));
395        assert_eq!(grid[4].date, DateValue::new(2026, 5, 1));
396    }
397
398    #[test]
399    fn add_months_crosses_years() {
400        assert_eq!(
401            add_months(MonthValue::new(2026, 1), -2),
402            MonthValue::new(2025, 11)
403        );
404        assert_eq!(
405            add_months(MonthValue::new(2026, 12), 2),
406            MonthValue::new(2027, 2)
407        );
408    }
409}