Skip to main content

sofie_values/time/
date.rs

1use std::convert::Infallible;
2use std::fmt;
3
4use jiff::civil;
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use sofie_core::value::Value;
7use sofie_core::{Error, Ordered, Result};
8
9#[cfg(feature = "web")]
10use crate::Conventions;
11use crate::refusal::refusal;
12use crate::{Input, Instant, Present, TimeZone, Weekday};
13use sofie_core::deserialize_parsed;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct Date {
17    day: civil::Date,
18}
19
20impl Date {
21    pub fn try_new(year: i16, month: i8, day: i8) -> Result<Self> {
22        civil::Date::new(year, month, day)
23            .map(Self::from)
24            .map_err(|_| invalid_date())
25    }
26
27    pub fn parse(text: &str) -> Result<Self> {
28        let day: civil::Date = text.parse().map_err(|_| invalid_date())?;
29        if day.to_string() != text {
30            return Err(invalid_date());
31        }
32        Ok(Self::from(day))
33    }
34
35    /// Today's date is `Date::utc_of(clock.now())`.
36    #[must_use]
37    pub fn utc_of(instant: Instant) -> Self {
38        Self::from(instant.timestamp().to_zoned(jiff::tz::TimeZone::UTC).date())
39    }
40
41    /// The day `instant` falls on where `zone`'s clocks are.
42    #[must_use]
43    pub fn of(instant: Instant, zone: &TimeZone) -> Self {
44        Self::from(zone.zoned(instant).date())
45    }
46
47    #[must_use]
48    pub fn civil(self) -> civil::Date {
49        self.day
50    }
51
52    #[must_use]
53    pub fn weekday(self) -> Weekday {
54        Weekday::from(self.day.weekday())
55    }
56}
57
58fn invalid_date() -> Error {
59    refusal(
60        "date",
61        "validation.date.invalid",
62        "must be a calendar day such as 2026-10-11",
63    )
64}
65
66impl From<civil::Date> for Date {
67    fn from(day: civil::Date) -> Self {
68        Self { day }
69    }
70}
71
72impl fmt::Display for Date {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        fmt::Display::fmt(&self.day, formatter)
75    }
76}
77
78impl Value for Date {
79    type Inner = civil::Date;
80
81    fn inner(&self) -> &civil::Date {
82        &self.day
83    }
84
85    fn try_from_inner(day: civil::Date) -> Result<Self, impl fmt::Display> {
86        Ok::<_, Infallible>(Self::from(day))
87    }
88}
89
90impl Ordered for Date {}
91
92impl Serialize for Date {
93    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94        serializer.collect_str(self)
95    }
96}
97
98impl<'de> Deserialize<'de> for Date {
99    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100        deserialize_parsed(deserializer, "a calendar day", Self::parse)
101    }
102}
103
104impl Present for Date {
105    const INPUT: Input = Input::typed(&[("type", "date")]);
106    const SEARCH: crate::Search = crate::Search::Time;
107
108    fn csv(&self) -> String {
109        self.to_string()
110    }
111
112    #[cfg(feature = "web")]
113    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
114        conventions.write_date(out, &self.csv());
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use sofie_core::SqlValue;
121
122    use super::*;
123    use crate::testing::round_trip;
124
125    #[test]
126    fn an_instant_falls_on_its_utc_day_and_on_its_zone_s_local_day() {
127        let late_evening = Instant::parse("2026-10-11T22:30:00Z").unwrap();
128        let istanbul = TimeZone::parse("Europe/Istanbul").unwrap();
129
130        assert_eq!(
131            Date::utc_of(late_evening),
132            Date::parse("2026-10-11").unwrap()
133        );
134        assert_eq!(
135            Date::of(late_evening, &istanbul),
136            Date::parse("2026-10-12").unwrap()
137        );
138    }
139
140    #[test]
141    fn accepts_real_days_only() {
142        assert_eq!(
143            Date::try_new(2024, 2, 29).unwrap(),
144            Date::parse("2024-02-29").unwrap()
145        );
146        assert!(Date::try_new(2026, 2, 29).is_err());
147        assert!(Date::try_new(2026, 13, 1).is_err());
148        assert!(Date::try_new(2026, 1, 0).is_err());
149    }
150
151    #[test]
152    fn reads_exactly_year_month_day() {
153        for text in [
154            "2026-10-11T09:30",
155            "2026-1-1",
156            "20261011",
157            " 2026-10-11",
158            "2026-10-32",
159            "",
160        ] {
161            assert!(Date::parse(text).is_err(), "{text:?}");
162        }
163    }
164
165    #[test]
166    fn round_trips_through_a_date_column_and_json() {
167        let day = Date::try_new(2026, 10, 11).unwrap();
168        assert_eq!(round_trip(&day), vec![SqlValue::Date(day.civil())]);
169        let json = serde_json::to_value(day).unwrap();
170        assert_eq!(json, serde_json::json!("2026-10-11"));
171        assert_eq!(serde_json::from_value::<Date>(json).unwrap(), day);
172        assert!(day < Date::try_new(2026, 10, 12).unwrap());
173    }
174}