1use jiff::civil;
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use sofie_core::show::Fields;
4use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlType, SqlValue};
5
6use crate::{Control, Input, Instant, Part, Present, TimeZone};
7
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub struct LocalDateTime {
11 local: civil::DateTime,
12 zone: TimeZone,
13}
14
15impl LocalDateTime {
16 #[must_use]
17 pub fn new(local: civil::DateTime, zone: TimeZone) -> Self {
18 Self { local, zone }
19 }
20
21 pub fn parse(local: &str, zone: TimeZone) -> Result<Self> {
22 Ok(Self::new(parse_local(local)?, zone))
23 }
24
25 #[must_use]
26 pub fn local(&self) -> civil::DateTime {
27 self.local
28 }
29
30 #[must_use]
31 pub fn zone(&self) -> &TimeZone {
32 &self.zone
33 }
34
35 pub fn instant(&self) -> Result<Instant> {
36 self.zone.instant_of(self.local)
37 }
38}
39
40fn parse_local(local: &str) -> Result<civil::DateTime> {
41 let refusal = || {
42 Error::invalid(
43 "local",
44 "must be a date and time without an offset, such as 2026-11-01T09:30",
45 )
46 };
47 if names_an_offset(local) {
48 return Err(refusal());
49 }
50 local.parse().map_err(|_| refusal())
51}
52
53fn names_an_offset(local: &str) -> bool {
55 local
56 .split_once(['T', 't', ' '])
57 .is_some_and(|(_, time)| time.contains(['+', '-', 'Z', 'z', '[']))
58}
59
60impl Show for LocalDateTime {
62 fn show(&self) -> Shown<'_> {
63 Shown::Record(self)
64 }
65}
66
67impl Fields for LocalDateTime {
68 fn name(&self, index: usize) -> Option<&str> {
69 ["local", "zone"].get(index).copied()
70 }
71
72 fn get(&self, name: &str) -> Option<Shown<'_>> {
73 match name {
74 "local" => Some(Shown::Formatted(&self.local)),
75 "zone" => Some(self.zone.show()),
76 _ => None,
77 }
78 }
79}
80
81impl Cast for LocalDateTime {
83 const COLUMNS: &'static [ColumnSpec] = &[
84 ColumnSpec::new("", SqlType::DateTime).unordered(),
85 ColumnSpec::new("_zone", SqlType::Text).unordered(),
86 ];
87
88 fn encode(&self, out: &mut Vec<SqlValue>) {
89 out.push(SqlValue::DateTime(self.local));
90 self.zone.encode(out);
91 }
92
93 fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
94 let local = row.date_time()?;
95 Ok(Self::new(local, TimeZone::decode(row)?))
96 }
97}
98
99#[derive(Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101struct LocalDateTimeFields {
102 local: String,
103 zone: TimeZone,
104}
105
106impl Serialize for LocalDateTime {
107 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
108 let fields = LocalDateTimeFields {
109 local: self.local.to_string(),
110 zone: self.zone.clone(),
111 };
112 fields.serialize(serializer)
113 }
114}
115
116impl<'de> Deserialize<'de> for LocalDateTime {
117 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118 let fields = LocalDateTimeFields::deserialize(deserializer)?;
119 Self::parse(&fields.local, fields.zone).map_err(Error::into_deserialize_error)
120 }
121}
122
123impl Present for LocalDateTime {
125 const INPUT: Input = Input::new(Control::Parts(&[
126 Part {
127 name: "local",
128 attributes: &[("type", "datetime-local")],
129 options: &[],
130 },
131 Part {
132 name: "zone",
133 attributes: &[("type", "text")],
134 options: &[],
135 },
136 ]));
137 const SEARCH: crate::Search = crate::Search::Time;
138
139 fn csv(&self) -> String {
140 format!("{} {}", self.local, self.zone)
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::testing::{round_trip, suffixes};
148
149 fn zone(name: &str) -> TimeZone {
150 TimeZone::parse(name).unwrap()
151 }
152
153 #[test]
154 fn no_column_sorts_a_list_since_zones_differ_row_to_row() {
155 assert!(LocalDateTime::COLUMNS.iter().all(|column| !column.orders));
156 }
157
158 fn meeting(local: &str, name: &str) -> LocalDateTime {
159 LocalDateTime::parse(local, zone(name)).unwrap()
160 }
161
162 #[test]
163 fn works_out_its_instant_in_its_zone() {
164 let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
165
166 assert_eq!(at.instant().unwrap().to_string(), "2026-11-01T06:30:00Z");
167 assert_eq!(at.local().to_string(), "2026-11-01T09:30:00");
168 assert_eq!(at.zone().name(), "Europe/Istanbul");
169 }
170
171 #[test]
172 fn places_a_skipped_time_where_the_shift_ends_and_a_repeated_one_first() {
173 let skipped = meeting("2026-03-08T02:30", "America/New_York");
174 let repeated = meeting("2026-11-01T01:30", "America/New_York");
175
176 assert_eq!(
177 skipped.instant().unwrap().to_string(),
178 "2026-03-08T07:00:00Z"
179 );
180 assert_eq!(
181 repeated.instant().unwrap().to_string(),
182 "2026-11-01T05:30:00Z"
183 );
184 }
185
186 #[test]
187 fn refuses_a_time_with_an_offset_or_none_at_all() {
188 for local in [
189 "2026-11-01T09:30:00Z",
190 "2026-11-01T09:30+03:00",
191 "2026-11-01 09:30-05:00",
192 "tomorrow",
193 "",
194 ] {
195 assert!(
196 LocalDateTime::parse(local, zone("UTC")).is_err(),
197 "{local:?}"
198 );
199 }
200 }
201
202 #[test]
203 fn round_trips_through_a_time_and_a_zone_column() {
204 let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
205 assert_eq!(
206 round_trip(&at),
207 vec![
208 SqlValue::DateTime(civil::date(2026, 11, 1).at(9, 30, 0, 0)),
209 SqlValue::Text("Europe/Istanbul".into()),
210 ]
211 );
212 assert_eq!(suffixes::<LocalDateTime>(), ["", "_zone"]);
213 }
214
215 #[test]
216 fn round_trips_through_json() {
217 let at = meeting("2026-11-01T09:30", "Europe/Istanbul");
218 let json = serde_json::to_value(&at).unwrap();
219
220 assert_eq!(
221 json,
222 serde_json::json!({"local": "2026-11-01T09:30:00", "zone": "Europe/Istanbul"})
223 );
224 assert_eq!(serde_json::from_value::<LocalDateTime>(json).unwrap(), at);
225 let unknown_zone = serde_json::json!({"local": "2026-11-01T09:30", "zone": "Mars/Base"});
226 assert!(serde_json::from_value::<LocalDateTime>(unknown_zone).is_err());
227 }
228
229 #[test]
230 fn builds_from_a_civil_time() {
231 let local = civil::date(2026, 11, 1).at(9, 30, 0, 0);
232
233 assert_eq!(
234 LocalDateTime::new(local, zone("Europe/Istanbul")),
235 meeting("2026-11-01T09:30", "Europe/Istanbul")
236 );
237 }
238}