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}