Skip to main content

things_mcp/core/reader/
dates.rs

1//! Date helpers for the read path.
2//!
3//! Things stores user-facing dates (`startDate`, `deadline`) as bit-packed
4//! integers:
5//!
6//! ```text
7//!   bit  27                              0
8//!        YYYYYYYYYYYY MMMM DDDDD 0000000
9//!        ↑ 12 bits    ↑ 4   ↑ 5   ↑ 7 bits padding
10//! ```
11//!
12//! `0` means "no date" (Things never writes `1970-00-00`). Things stores
13//! row-modification timestamps (`creationDate`, `userModificationDate`,
14//! `stopDate`) separately as REAL Unix seconds — those go through
15//! `unix_to_iso` over in `queries.rs`, not here.
16
17/// Decode a Things packed date into an ISO `YYYY-MM-DD` string.
18/// Returns `None` for `0` and for out-of-range / malformed values so a
19/// future schema change can't surface garbage to callers.
20pub fn decode_things_date(packed: i64) -> Option<String> {
21    if packed == 0 {
22        return None;
23    }
24    let year = ((packed >> 16) & 0xFFF) as i32;
25    let month = ((packed >> 12) & 0x0F) as u32;
26    let day = ((packed >> 7) & 0x1F) as u32;
27    if year < 1900 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
28        return None;
29    }
30    Some(format!("{year:04}-{month:02}-{day:02}"))
31}
32
33/// Pack a `(year, month, day)` triple back into Things' format. Used by
34/// query helpers that need to compare against `today` or user-supplied
35/// date bounds without round-tripping through ISO strings.
36pub fn pack_things_date(year: i32, month: u32, day: u32) -> i64 {
37    ((year as i64) << 16) | ((month as i64) << 12) | ((day as i64) << 7)
38}
39
40/// Parse `YYYY-MM-DD` into `(y, m, d)`. Returns `None` on any deviation
41/// from the strict 10-character ISO date form so caller validation is
42/// straightforward.
43pub fn parse_iso_date(iso: &str) -> Option<(i32, u32, u32)> {
44    if iso.len() != 10 {
45        return None;
46    }
47    let bytes = iso.as_bytes();
48    if bytes[4] != b'-' || bytes[7] != b'-' {
49        return None;
50    }
51    let y: i32 = iso.get(0..4)?.parse().ok()?;
52    let m: u32 = iso.get(5..7)?.parse().ok()?;
53    let d: u32 = iso.get(8..10)?.parse().ok()?;
54    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
55        return None;
56    }
57    Some((y, m, d))
58}
59
60/// `(year, month, day)` → Unix epoch seconds (00:00 UTC of that date).
61/// Inverse of `core::backup::unix_to_ymdhms` rounded to whole
62/// days. Used by `things_list_logbook`'s `from`/`to` filters which compare
63/// against `stopDate` (REAL Unix seconds).
64pub fn ymd_to_unix_utc(year: i32, month: u32, day: u32) -> i64 {
65    let mut days: i64 = 0;
66    let from_year = 1970;
67    if year >= from_year {
68        for yi in from_year..year {
69            let leap = (yi % 4 == 0 && yi % 100 != 0) || (yi % 400 == 0);
70            days += if leap { 366 } else { 365 };
71        }
72    } else {
73        for yi in year..from_year {
74            let leap = (yi % 4 == 0 && yi % 100 != 0) || (yi % 400 == 0);
75            days -= if leap { 366 } else { 365 };
76        }
77    }
78    let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
79    let months_len: [u32; 12] = [
80        31,
81        if leap { 29 } else { 28 },
82        31,
83        30,
84        31,
85        30,
86        31,
87        31,
88        30,
89        31,
90        30,
91        31,
92    ];
93    for mo in 1..month {
94        days += months_len[(mo - 1) as usize] as i64;
95    }
96    days += (day - 1) as i64;
97    days * 86_400
98}
99
100/// Today's date (UTC), packed for direct comparison against Things'
101/// `startDate` / `deadline` columns.
102pub fn today_packed_utc() -> i64 {
103    let secs = std::time::SystemTime::now()
104        .duration_since(std::time::UNIX_EPOCH)
105        .map(|d| d.as_secs() as i64)
106        .unwrap_or(0);
107    let (y, m, d, _, _, _) = crate::core::backup::unix_to_ymdhms(secs);
108    pack_things_date(y, m, d)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn decode_zero_is_none() {
117        assert_eq!(decode_things_date(0), None);
118    }
119
120    #[test]
121    fn decode_known_packed_value() {
122        // pack(2026, 5, 20)
123        let p = pack_things_date(2026, 5, 20);
124        assert_eq!(decode_things_date(p), Some("2026-05-20".to_string()));
125    }
126
127    #[test]
128    fn pack_then_decode_round_trip() {
129        for (y, m, d) in [(2000, 1, 1), (2026, 12, 31), (2099, 12, 31)] {
130            let p = pack_things_date(y, m, d);
131            assert_eq!(
132                decode_things_date(p),
133                Some(format!("{y:04}-{m:02}-{d:02}"))
134            );
135        }
136    }
137
138    #[test]
139    fn decode_rejects_malformed_year() {
140        // Year 1800 — below the 1900 sanity cutoff, mark as malformed.
141        let p = pack_things_date(1800, 1, 1);
142        assert_eq!(decode_things_date(p), None);
143    }
144
145    #[test]
146    fn parse_iso_date_happy_path() {
147        assert_eq!(parse_iso_date("2026-05-20"), Some((2026, 5, 20)));
148    }
149
150    #[test]
151    fn parse_iso_date_rejects_wrong_separators() {
152        assert_eq!(parse_iso_date("2026/05/20"), None);
153    }
154
155    #[test]
156    fn parse_iso_date_rejects_wrong_length() {
157        assert_eq!(parse_iso_date("2026-5-20"), None);
158        assert_eq!(parse_iso_date("2026-05-2"), None);
159    }
160
161    #[test]
162    fn ymd_to_unix_utc_at_epoch() {
163        assert_eq!(ymd_to_unix_utc(1970, 1, 1), 0);
164        assert_eq!(ymd_to_unix_utc(1970, 1, 2), 86_400);
165    }
166
167    #[test]
168    fn ymd_to_unix_utc_leap_day() {
169        // 2024-02-29 → days = 365*54 + 13 leap days + 31 (jan) + 28 (feb) days
170        // We don't hard-code the answer; just check that 2024-03-01 is one day
171        // after 2024-02-29.
172        let a = ymd_to_unix_utc(2024, 2, 29);
173        let b = ymd_to_unix_utc(2024, 3, 1);
174        assert_eq!(b - a, 86_400);
175    }
176
177    #[test]
178    fn today_packed_utc_decodes_to_real_date() {
179        let p = today_packed_utc();
180        let s = decode_things_date(p).expect("today must decode");
181        assert_eq!(s.len(), 10);
182        assert_eq!(&s[4..5], "-");
183        assert_eq!(&s[7..8], "-");
184    }
185}