Skip to main content

polydat_nodes/
datetime.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Datetime and epoch function nodes.
5
6/// Scale a u64 to epoch milliseconds by multiplying by a factor.
7///
8/// Signature: `(input: u64) -> (u64)`
9/// Param: `factor: u64` — milliseconds per input unit.
10///
11/// Example: `EpochScale(1000)` treats input as seconds → millis.
12#[polydat::polydat_node(category = Datetime)]
13fn epoch_scale(
14    input: u64,
15    #[poly_default(1u64)] factor: polydat::derive_support::Const<u64>,
16) -> u64 {
17    input.wrapping_mul(*factor)
18}
19
20impl EpochScale {
21    /// Scale by 1: milliseconds stay milliseconds.
22    pub fn millis() -> Self {
23        Self::new(1)
24    }
25    /// Scale by 1,000: seconds to milliseconds.
26    pub fn seconds() -> Self {
27        Self::new(1_000)
28    }
29    /// Scale by 60,000: minutes to milliseconds.
30    pub fn minutes() -> Self {
31        Self::new(60_000)
32    }
33    /// Scale by 3,600,000: hours to milliseconds.
34    pub fn hours() -> Self {
35        Self::new(3_600_000)
36    }
37}
38
39/// Add a base epoch offset to a u64 value.
40#[polydat::polydat_node(category = Datetime)]
41fn epoch_offset(
42    input: u64,
43    #[poly_default(0u64)] base_epoch_ms: polydat::derive_support::Const<u64>,
44) -> u64 {
45    input.wrapping_add(*base_epoch_ms)
46}
47
48impl EpochOffset {
49    /// 2024-01-01T00:00:00Z in epoch millis.
50    pub fn from_2024() -> Self {
51        Self::new(1_704_067_200_000)
52    }
53    /// 2025-01-01T00:00:00Z in epoch millis.
54    pub fn from_2025() -> Self {
55        Self::new(1_735_689_600_000)
56    }
57}
58
59/// Format an epoch-millis u64 as an ISO-8601-like timestamp string.
60///
61/// Signature: `(input: u64) -> (String)`
62///
63/// Produces: `"YYYY-MM-DDThh:mm:ss.mmmZ"`
64/// Uses a simple arithmetic calendar (no timezone, no leap second handling).
65#[polydat::polydat_node(category = Datetime)]
66fn to_timestamp(input: u64) -> String {
67    epoch_ms_to_iso(input)
68}
69
70/// Decompose epoch millis into date/time components.
71#[polydat::polydat_node(
72    category = Datetime,
73    output_names(year, month, day, hour, minute, second, millis),
74)]
75fn date_components(input: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
76    decompose_epoch_ms(input)
77}
78
79// --- Calendar arithmetic (simplified, no leap seconds) ---
80
81const MILLIS_PER_SEC: u64 = 1_000;
82#[allow(dead_code)]
83const MILLIS_PER_MIN: u64 = 60_000;
84#[allow(dead_code)]
85const MILLIS_PER_HOUR: u64 = 3_600_000;
86#[allow(dead_code)]
87const MILLIS_PER_DAY: u64 = 86_400_000;
88
89fn decompose_epoch_ms(epoch_ms: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
90    let mut remaining = epoch_ms;
91    let ms = remaining % MILLIS_PER_SEC;
92    remaining /= MILLIS_PER_SEC;
93    let sec = remaining % 60;
94    remaining /= 60;
95    let min = remaining % 60;
96    remaining /= 60;
97    let hour = remaining % 24;
98    let (year, month, day) = civil_from_days(remaining / 24);
99    (year, month, day, hour, min, sec, ms)
100}
101
102/// Days since 1970-01-01 to a proleptic Gregorian `(year, month, day)`,
103/// in constant time.
104///
105/// The calendar used to be walked a year at a time, which is exact but
106/// linear in the year: at `u64::MAX` milliseconds that is some 584
107/// million iterations, one evaluation taking tens of seconds. This is
108/// the closed form (H. Hinnant, "chrono-Compatible Low-Level Date
109/// Algorithms"): count 400-year eras of 146 097 days from 0000-03-01,
110/// so the leap day falls at the end of each year of the era, then read
111/// the year, day of year, and month out of the era arithmetically. The
112/// input never precedes 1970, so every step stays in `u64`, and the
113/// largest input (`u64::MAX / 86_400_000` days) is far from overflow.
114fn civil_from_days(days: u64) -> (u64, u64, u64) {
115    let z = days + 719_468; // days since 0000-03-01
116    let era = z / 146_097;
117    let doe = z - era * 146_097; // [0, 146096]
118    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
119    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365], from March 1
120    let mp = (5 * doy + 2) / 153; // [0, 11], March = 0
121    let day = doy - (153 * mp + 2) / 5 + 1;
122    let month = if mp < 10 { mp + 3 } else { mp - 9 };
123    let year = yoe + era * 400 + u64::from(month <= 2);
124    (year, month, day)
125}
126
127fn epoch_ms_to_iso(epoch_ms: u64) -> String {
128    let (y, mo, d, h, mi, s, ms) = decompose_epoch_ms(epoch_ms);
129    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{ms:03}Z")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use polydat::ast::{PolydatNode, Value};
136
137    /// Every day from 1970 through the year 10 000 against the calendar
138    /// counted forward a day at a time: slow as a node, plainly right,
139    /// and cheap here because the count is carried along rather than
140    /// restarted. The range crosses the 100- and 400-year rules (2000
141    /// leaps, 2100 does not) and some twenty whole 400-year eras, the
142    /// period the closed form repeats with.
143    #[test]
144    fn the_closed_form_calendar_agrees_with_the_walk() {
145        let mut days = 0u64;
146        let (mut y, mut m, mut d) = (1970u64, 1u64, 1u64);
147        let leap =
148            |y: u64| (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400);
149        while y <= 10_000 {
150            assert_eq!(civil_from_days(days), (y, m, d), "day {days}");
151            let len = [
152                31,
153                if leap(y) { 29 } else { 28 },
154                31,
155                30,
156                31,
157                30,
158                31,
159                31,
160                30,
161                31,
162                30,
163                31,
164            ][m as usize - 1];
165            (y, m, d) = if d < len {
166                (y, m, d + 1)
167            } else if m < 12 {
168                (y, m + 1, 1)
169            } else {
170                (y + 1, 1, 1)
171            };
172            days += 1;
173        }
174    }
175
176    /// The top of the range answers at once and in range: the input
177    /// that used to walk 584 million years. The bound is loose; the walk
178    /// took tens of seconds and the closed form takes nanoseconds.
179    #[test]
180    fn the_largest_epoch_decomposes_in_constant_time() {
181        let t = std::time::Instant::now();
182        let (y, mo, d, h, mi, s, ms) = decompose_epoch_ms(u64::MAX);
183        assert!(t.elapsed() < std::time::Duration::from_secs(1));
184        assert!(y > 584_000_000 && (1..=12).contains(&mo) && (1..=31).contains(&d));
185        assert!(h < 24 && mi < 60 && s < 60 && ms < 1000);
186    }
187
188    #[test]
189    fn epoch_scale_seconds() {
190        let node = EpochScale::seconds();
191        let mut out = [Value::None];
192        node.eval(&[Value::U64(5)], &mut out);
193        assert_eq!(out[0].as_u64(), 5000);
194    }
195
196    #[test]
197    fn epoch_offset_basic() {
198        let node = EpochOffset::new(1_000_000);
199        let mut out = [Value::None];
200        node.eval(&[Value::U64(500)], &mut out);
201        assert_eq!(out[0].as_u64(), 1_000_500);
202    }
203
204    #[test]
205    fn to_timestamp_epoch_zero() {
206        let node = ToTimestamp::new();
207        let mut out = [Value::None];
208        node.eval(&[Value::U64(0)], &mut out);
209        assert_eq!(out[0].as_str(), "1970-01-01T00:00:00.000Z");
210    }
211
212    #[test]
213    fn to_timestamp_known_date() {
214        let node = ToTimestamp::new();
215        let mut out = [Value::None];
216        // 2024-01-01T00:00:00.000Z = 1704067200000
217        node.eval(&[Value::U64(1_704_067_200_000)], &mut out);
218        assert_eq!(out[0].as_str(), "2024-01-01T00:00:00.000Z");
219    }
220
221    #[test]
222    fn date_components_epoch_zero() {
223        let node = DateComponents::new();
224        let mut out = vec![Value::None; 7];
225        node.eval(&[Value::U64(0)], &mut out);
226        assert_eq!(out[0].as_u64(), 1970);
227        assert_eq!(out[1].as_u64(), 1);
228        assert_eq!(out[2].as_u64(), 1);
229        assert_eq!(out[3].as_u64(), 0);
230        assert_eq!(out[4].as_u64(), 0);
231        assert_eq!(out[5].as_u64(), 0);
232        assert_eq!(out[6].as_u64(), 0);
233    }
234
235    #[test]
236    fn date_components_known() {
237        let node = DateComponents::new();
238        let mut out = vec![Value::None; 7];
239        // 2024-03-15T14:30:45.123Z
240        // Manually: days from epoch to 2024-03-15 = 19797
241        // 19797 * 86400000 + 14*3600000 + 30*60000 + 45*1000 + 123
242        let epoch = 19797u64 * MILLIS_PER_DAY
243            + 14 * MILLIS_PER_HOUR
244            + 30 * MILLIS_PER_MIN
245            + 45 * MILLIS_PER_SEC
246            + 123;
247        node.eval(&[Value::U64(epoch)], &mut out);
248        assert_eq!(out[0].as_u64(), 2024);
249        assert_eq!(out[1].as_u64(), 3);
250        assert_eq!(out[2].as_u64(), 15);
251        assert_eq!(out[3].as_u64(), 14);
252        assert_eq!(out[4].as_u64(), 30);
253        assert_eq!(out[5].as_u64(), 45);
254        assert_eq!(out[6].as_u64(), 123);
255    }
256
257    #[test]
258    fn epoch_scale_compiled() {
259        let node = EpochScale::seconds();
260        let op = node.compiled_u64().unwrap();
261        let mut out = [0u64];
262        op(&[5], &mut out);
263        assert_eq!(out[0], 5000);
264    }
265}