Skip to main content

eui_core/
dates.rs

1//! VAPP-190: calendar-day maths, the Rust twin of
2//! `packages/exponential-ui/src/dates.ts`, shared by the records query
3//! (time buckets, `host::query`) and the bind-time expressions (Q2:
4//! dateAdd, startOf, isoWeek…). Pure and proleptic Gregorian over
5//! `YYYY-MM-DD` texts, no clock and no locale; `fixtures/records-query.json`
6//! `bucketCases` lock it. A date is a calendar day (spec §4.3); a
7//! timestamp's day is the first ten characters of its ISO text.
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct CalendarDay {
13    pub y: i64,
14    pub m: i64,
15    pub d: i64,
16}
17
18/// The time buckets a query groups dates by.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum DateBucket {
22    Day,
23    Week,
24    Month,
25    Quarter,
26    Year,
27}
28
29pub const DATE_BUCKETS: [&str; 5] = ["day", "week", "month", "quarter", "year"];
30
31impl DateBucket {
32    pub fn parse(text: &str) -> Option<Self> {
33        match text {
34            "day" => Some(Self::Day),
35            "week" => Some(Self::Week),
36            "month" => Some(Self::Month),
37            "quarter" => Some(Self::Quarter),
38            "year" => Some(Self::Year),
39            _ => None,
40        }
41    }
42
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Day => "day",
46            Self::Week => "week",
47            Self::Month => "month",
48            Self::Quarter => "quarter",
49            Self::Year => "year",
50        }
51    }
52}
53
54pub fn is_leap_year(y: i64) -> bool {
55    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
56}
57
58pub fn days_in_month(y: i64, m: i64) -> i64 {
59    match m {
60        2 if is_leap_year(y) => 29,
61        2 => 28,
62        4 | 6 | 9 | 11 => 30,
63        _ => 31,
64    }
65}
66
67/// 0 = Sunday (Sakamoto's method).
68pub fn weekday_of(y: i64, m: i64, d: i64) -> usize {
69    const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
70    let y = if m < 3 { y - 1 } else { y };
71    (y + y.div_euclid(4) - y.div_euclid(100) + y.div_euclid(400) + T[(m.clamp(1, 12) - 1) as usize] + d).rem_euclid(7) as usize
72}
73
74/// 1-based day of the year.
75pub fn day_of_year(y: i64, m: i64, d: i64) -> i64 {
76    (1..m).map(|i| days_in_month(y, i)).sum::<i64>() + d
77}
78
79/// ISO 8601 weeks in a year: 53 when it starts on a Thursday (or a Wednesday in a leap year).
80pub fn iso_weeks_in_year(y: i64) -> i64 {
81    let jan1 = weekday_of(y, 1, 1);
82    if jan1 == 4 || (jan1 == 3 && is_leap_year(y)) {
83        53
84    } else {
85        52
86    }
87}
88
89/// The ISO week (Monday first, week 1 holds the first Thursday): `(year, week)`.
90pub fn iso_week(y: i64, m: i64, d: i64) -> (i64, i64) {
91    let wd = match weekday_of(y, m, d) {
92        0 => 7,
93        n => n as i64,
94    };
95    let week = (day_of_year(y, m, d) - wd + 10).div_euclid(7);
96    if week < 1 {
97        return (y - 1, iso_weeks_in_year(y - 1));
98    }
99    if week > iso_weeks_in_year(y) {
100        return (y + 1, 1);
101    }
102    (y, week)
103}
104
105fn digits(b: &[u8], from: usize, n: usize) -> bool {
106    b.len() >= from + n && b[from..from + n].iter().all(u8::is_ascii_digit)
107}
108
109fn int(b: &[u8], from: usize, n: usize) -> i64 {
110    b[from..from + n].iter().fold(0, |acc, c| acc * 10 + i64::from(c - b'0'))
111}
112
113/// The calendar day at the start of `text` (`YYYY-MM-DD`, then the end, `T`
114/// or a space); `None` when it is not a real day.
115pub fn parse_day(text: &str) -> Option<CalendarDay> {
116    let b = text.as_bytes();
117    if !(digits(b, 0, 4) && b.get(4) == Some(&b'-') && digits(b, 5, 2) && b.get(7) == Some(&b'-') && digits(b, 8, 2)) {
118        return None;
119    }
120    if b.len() > 10 && b[10] != b'T' && b[10] != b' ' {
121        return None;
122    }
123    let (y, m, d) = (int(b, 0, 4), int(b, 5, 2), int(b, 8, 2));
124    if !(1..=12).contains(&m) || d < 1 || d > days_in_month(y, m) {
125        return None;
126    }
127    Some(CalendarDay { y, m, d })
128}
129
130/// The year and month at the start of `text` (`YYYY-MM`, then the end or `-`).
131pub fn parse_month(text: &str) -> Option<(i64, i64)> {
132    let b = text.as_bytes();
133    if !(digits(b, 0, 4) && b.get(4) == Some(&b'-') && digits(b, 5, 2)) || (b.len() > 7 && b[7] != b'-') {
134        return None;
135    }
136    let m = int(b, 5, 2);
137    (1..=12).contains(&m).then(|| (int(b, 0, 4), m))
138}
139
140/// The year at the start of `text` (`YYYY`, then the end or `-`).
141pub fn parse_year(text: &str) -> Option<i64> {
142    let b = text.as_bytes();
143    if !digits(b, 0, 4) || (b.len() > 4 && b[4] != b'-') {
144        return None;
145    }
146    Some(int(b, 0, 4))
147}
148
149/// `YYYY-MM-DD`.
150pub fn format_day(day: CalendarDay) -> String {
151    format!("{:04}-{:02}-{:02}", day.y, day.m, day.d)
152}
153
154/// A date's bucket key: day `2026-10-05`, week `2026-W41` (ISO), month
155/// `2026-10`, quarter `2026-Q4`, year `2026`; `None` when the text holds no
156/// date that precise.
157pub fn date_bucket(text: &str, bucket: DateBucket) -> Option<String> {
158    match bucket {
159        DateBucket::Day => parse_day(text).map(format_day),
160        DateBucket::Week => parse_day(text).map(|day| {
161            let (year, week) = iso_week(day.y, day.m, day.d);
162            format!("{year:04}-W{week:02}")
163        }),
164        DateBucket::Year => parse_year(text).map(|y| format!("{y:04}")),
165        DateBucket::Month => parse_month(text).map(|(y, m)| format!("{y:04}-{m:02}")),
166        DateBucket::Quarter => parse_month(text).map(|(y, m)| format!("{y:04}-Q{}", (m - 1) / 3 + 1)),
167    }
168}
169
170/// VAPP-191: the days since 1970-01-01 of a calendar day (negative before;
171/// Howard Hinnant's days_from_civil), and back (`dayNumber` /
172/// `dayFromNumber` in dates.ts).
173pub fn day_number(y: i64, m: i64, d: i64) -> i64 {
174    let yy = if m <= 2 { y - 1 } else { y };
175    let era = yy.div_euclid(400);
176    let yoe = yy - era * 400;
177    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2).div_euclid(5) + d - 1;
178    let doe = yoe * 365 + yoe.div_euclid(4) - yoe.div_euclid(100) + doy;
179    era * 146_097 + doe - 719_468
180}
181
182pub fn day_from_number(n: i64) -> CalendarDay {
183    let z = n + 719_468;
184    let era = z.div_euclid(146_097);
185    let doe = z - era * 146_097;
186    let yoe = (doe - doe.div_euclid(1460) + doe.div_euclid(36_524) - doe.div_euclid(146_096)).div_euclid(365);
187    let doy = doe - (365 * yoe + yoe.div_euclid(4) - yoe.div_euclid(100));
188    let mp = (5 * doy + 2).div_euclid(153);
189    let d = doy - (153 * mp + 2).div_euclid(5) + 1;
190    let m = if mp < 10 { mp + 3 } else { mp - 9 };
191    CalendarDay { y: yoe + era * 400 + i64::from(m <= 2), m, d }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn iso_weeks_cross_years() {
200        assert_eq!(date_bucket("2027-01-01", DateBucket::Week).as_deref(), Some("2026-W53"));
201        assert_eq!(date_bucket("2024-12-30", DateBucket::Week).as_deref(), Some("2025-W01"));
202        assert_eq!(date_bucket("2026-02-30", DateBucket::Day), None);
203    }
204}