Skip to main content

todoapp_core/
temporal.rs

1//! Typed date/time/duration domain values (spec §3): `jiff` for instants and
2//! calendar dates, `std::time::Duration` for durations — never raw `String`/
3//! `u32`. Each wrapper's `Serialize`/`Deserialize` matches the on-wire shape
4//! the fields had before typing (bare integer / ISO date string), so adapters
5//! that bridge components through `serde_json` (`todoapp-store-turso`) need no
6//! schema changes.
7
8use std::fmt;
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// An instant (task `created_at`/`updated_at`). Serializes as epoch
13/// milliseconds (a bare integer), matching the pre-jiff `Timestamp(i64)` shape.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub struct Timestamp(pub jiff::Timestamp);
16
17impl Timestamp {
18    pub fn as_millisecond(&self) -> i64 {
19        self.0.as_millisecond()
20    }
21    pub fn from_millisecond(ms: i64) -> Self {
22        Self(jiff::Timestamp::from_millisecond(ms).unwrap_or(jiff::Timestamp::UNIX_EPOCH))
23    }
24}
25
26impl Serialize for Timestamp {
27    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
28        s.serialize_i64(self.as_millisecond())
29    }
30}
31
32impl<'de> Deserialize<'de> for Timestamp {
33    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
34        Ok(Self::from_millisecond(i64::deserialize(d)?))
35    }
36}
37
38/// A calendar date (`Schedule`/due dates). Serializes as an ISO-8601
39/// `YYYY-MM-DD` string (jiff's own serde impl) — the same shape the field had
40/// as a raw `String`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(transparent)]
43pub struct Date(pub jiff::civil::Date);
44
45impl Date {
46    pub fn parse(s: &str) -> Result<Self, jiff::Error> {
47        s.parse::<jiff::civil::Date>().map(Date)
48    }
49}
50
51impl fmt::Display for Date {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57/// A time-of-day (minute precision), for a due date's optional rendez-vous
58/// time. Naive/local, like the rest of the codebase — no zone is stored.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
60pub struct Time(pub jiff::civil::Time);
61
62impl Time {
63    /// Accepts `"HH:MM"` (24h).
64    pub fn parse(s: &str) -> Result<Self, String> {
65        let s = s.trim();
66        let (h, m) = s
67            .split_once(':')
68            .ok_or_else(|| format!("expected \"HH:MM\", got {s:?}"))?;
69        let h: i8 = h.parse().map_err(|_| format!("bad hour in {s:?}"))?;
70        let m: i8 = m.parse().map_err(|_| format!("bad minute in {s:?}"))?;
71        jiff::civil::Time::new(h, m, 0, 0)
72            .map(Time)
73            .map_err(|e| format!("bad time {s:?}: {e}"))
74    }
75}
76
77impl fmt::Display for Time {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{:02}:{:02}", self.0.hour(), self.0.minute())
80    }
81}
82
83// jscpd:ignore-start
84// ponytail: same 7-line Serialize/Deserialize-via-Display/parse shape as `Due`
85// below; only 2 occurrences and each is tied to its own type's `Display`/
86// `parse`, so a macro would cost more readability than the duplication does.
87// Promote to a macro if a third string-roundtrip type shows up.
88impl Serialize for Time {
89    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
90        s.serialize_str(&self.to_string())
91    }
92}
93
94impl<'de> Deserialize<'de> for Time {
95    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
96        let s = String::deserialize(d)?;
97        Time::parse(&s).map_err(serde::de::Error::custom)
98    }
99}
100// jscpd:ignore-end
101
102/// A due date with an optional time-of-day (spec: a "rendez-vous" due can
103/// carry a time; overdue/eta rollups (`Aggregate::earliest_due`,
104/// `project_finish_date`) stay day-granularity and only ever read `.date` —
105/// `.time` is display-only, never compared.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
107pub struct Due {
108    pub date: Date,
109    pub time: Option<Time>,
110}
111
112impl Due {
113    /// Accepts `"YYYY-MM-DD"` or `"YYYY-MM-DD HH:MM"` (space or `T` separated).
114    pub fn parse(s: &str) -> Result<Self, String> {
115        let s = s.trim();
116        let (date_part, time_part) = match s.split_once([' ', 'T']) {
117            Some((d, t)) => (d, Some(t)),
118            None => (s, None),
119        };
120        let date = Date::parse(date_part).map_err(|e| format!("bad date {date_part:?}: {e}"))?;
121        let time = time_part.map(Time::parse).transpose()?;
122        Ok(Due { date, time })
123    }
124}
125
126impl From<Date> for Due {
127    fn from(date: Date) -> Self {
128        Due { date, time: None }
129    }
130}
131
132impl fmt::Display for Due {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self.time {
135            Some(t) => write!(f, "{} {t}", self.date),
136            None => write!(f, "{}", self.date),
137        }
138    }
139}
140
141// jscpd:ignore-start
142// ponytail: same shape as `Time` above — see the note there.
143impl Serialize for Due {
144    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
145        s.serialize_str(&self.to_string())
146    }
147}
148
149impl<'de> Deserialize<'de> for Due {
150    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
151        let s = String::deserialize(d)?;
152        Due::parse(&s).map_err(serde::de::Error::custom)
153    }
154}
155// jscpd:ignore-end
156
157/// An effort/elapsed duration (`Estimate`, `TimeSpent`), minute precision.
158/// Serializes as an integer number of minutes — the same shape the field had
159/// as a raw `u32`.
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
161pub struct Duration(pub std::time::Duration);
162
163impl Duration {
164    pub const ZERO: Self = Self(std::time::Duration::ZERO);
165
166    pub fn from_minutes(m: u32) -> Self {
167        Self(std::time::Duration::from_secs(u64::from(m) * 60))
168    }
169
170    pub fn as_minutes(&self) -> u32 {
171        u32::try_from(self.0.as_secs() / 60).unwrap_or(u32::MAX)
172    }
173}
174
175impl std::ops::Add for Duration {
176    type Output = Duration;
177    fn add(self, rhs: Self) -> Duration {
178        Duration(self.0 + rhs.0)
179    }
180}
181
182impl std::ops::AddAssign for Duration {
183    fn add_assign(&mut self, rhs: Self) {
184        self.0 += rhs.0;
185    }
186}
187
188impl std::ops::Sub for Duration {
189    type Output = Duration;
190    fn sub(self, rhs: Self) -> Duration {
191        Duration(self.0.saturating_sub(rhs.0))
192    }
193}
194
195impl std::iter::Sum for Duration {
196    fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
197        iter.fold(Duration::ZERO, std::ops::Add::add)
198    }
199}
200
201impl fmt::Display for Duration {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        let total = self.as_minutes();
204        match (total / 60, total % 60) {
205            (0, 0) => write!(f, "0m"),
206            (0, m) => write!(f, "{m}m"),
207            (h, 0) => write!(f, "{h}h"),
208            (h, m) => write!(f, "{h}h{m}m"),
209        }
210    }
211}
212
213impl Serialize for Duration {
214    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
215        s.serialize_u32(self.as_minutes())
216    }
217}
218
219impl<'de> Deserialize<'de> for Duration {
220    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
221        Ok(Self::from_minutes(u32::deserialize(d)?))
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn duration_display_is_human_readable() {
231        assert_eq!(Duration::ZERO.to_string(), "0m");
232        assert_eq!(Duration::from_minutes(45).to_string(), "45m");
233        assert_eq!(Duration::from_minutes(60).to_string(), "1h");
234        assert_eq!(Duration::from_minutes(90).to_string(), "1h30m");
235    }
236
237    #[test]
238    fn duration_json_roundtrips_as_integer_minutes() {
239        let d = Duration::from_minutes(30);
240        let json = serde_json::to_string(&d).unwrap();
241        assert_eq!(json, "30");
242        assert_eq!(serde_json::from_str::<Duration>(&json).unwrap(), d);
243    }
244
245    #[test]
246    fn date_json_roundtrips_as_iso_string() {
247        let d = Date::parse("2026-07-01").unwrap();
248        let json = serde_json::to_string(&d).unwrap();
249        assert_eq!(json, "\"2026-07-01\"");
250        assert_eq!(serde_json::from_str::<Date>(&json).unwrap(), d);
251    }
252
253    #[test]
254    fn due_parses_date_only_and_date_time() {
255        let date_only = Due::parse("2026-07-01").unwrap();
256        assert_eq!(date_only.time, None);
257        assert_eq!(date_only.to_string(), "2026-07-01");
258
259        let with_time = Due::parse("2026-07-01 14:30").unwrap();
260        assert_eq!(with_time.date, date_only.date);
261        assert_eq!(with_time.to_string(), "2026-07-01 14:30");
262
263        // legacy plain-date rows (no time) still parse under the richer type.
264        assert_eq!(Due::parse("2026-07-01T14:30").unwrap(), with_time);
265    }
266
267    #[test]
268    fn due_json_roundtrips_and_orders_date_before_time() {
269        let with_time = Due::parse("2026-07-01 09:00").unwrap();
270        let json = serde_json::to_string(&with_time).unwrap();
271        assert_eq!(json, "\"2026-07-01 09:00\"");
272        assert_eq!(serde_json::from_str::<Due>(&json).unwrap(), with_time);
273
274        let date_only: Due = Date::parse("2026-07-01").unwrap().into();
275        assert!(
276            date_only < with_time,
277            "a bare due date sorts before a same-day rendez-vous time"
278        );
279    }
280}