Skip to main content

max / goingson

13.2 KB · 448 lines History Blame Raw
1 //! Natural-language date parser for free-text date fields.
2 //!
3 //! Single source of truth for the formats the UI accepts in date inputs
4 //! ("tomorrow", "friday 3pm", "next week", "in 3 days", "dec 25",
5 //! "2026-12-25", "2026-12-25 3pm", ISO datetime). The frontend reaches this
6 //! through the `parse_natural_date` command instead of maintaining a parallel
7 //! JavaScript parser ("Rust does the heavy lifting").
8 //!
9 //! All arithmetic is wall-clock (local) time: the caller passes the current
10 //! local time and receives a local `NaiveDateTime`, matching the previous JS
11 //! behaviour, which built `Date` objects in the browser's local zone and
12 //! emitted a local `YYYY-MM-DDTHH:MM` string.
13
14 use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
15
16 use crate::constants::{DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE};
17
18 /// Weekday names indexed Sunday=0..Saturday=6 (matches JS `Date.getDay`).
19 const WEEKDAYS: [&str; 7] = [
20 "sunday",
21 "monday",
22 "tuesday",
23 "wednesday",
24 "thursday",
25 "friday",
26 "saturday",
27 ];
28 const MONTHS: [&str; 12] = [
29 "january",
30 "february",
31 "march",
32 "april",
33 "may",
34 "june",
35 "july",
36 "august",
37 "september",
38 "october",
39 "november",
40 "december",
41 ];
42 const MONTH_ABBRS: [&str; 12] = [
43 "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
44 ];
45
46 /// Parse a natural-language date string relative to `now` (local wall clock).
47 ///
48 /// Returns the resolved local `NaiveDateTime`, or `None` if unrecognized.
49 pub fn parse_natural_date(input: &str, now: NaiveDateTime) -> Option<NaiveDateTime> {
50 let trimmed = input.trim();
51 if trimmed.is_empty() {
52 return None;
53 }
54
55 // Already an ISO-ish datetime (YYYY-MM-DDTHH:MM or with a space / seconds).
56 if let Some(dt) = parse_iso_datetime(trimmed) {
57 return Some(dt);
58 }
59
60 let lower = trimmed.to_lowercase();
61 let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
62 let today = now.date();
63 let tokens: Vec<&str> = lower.split_whitespace().collect();
64
65 // Plain YYYY-MM-DD with an optional trailing time ("2026-12-25", "2026-12-25 3pm").
66 if let Ok(date) = NaiveDate::parse_from_str(tokens[0], "%Y-%m-%d") {
67 let time = parse_time_tokens(&tokens[1..], default_time)?;
68 return Some(date.and_time(time));
69 }
70
71 // Relative keywords.
72 match lower.as_str() {
73 "today" => return Some(today.and_time(default_time)),
74 "tomorrow" => return Some((today + Duration::days(1)).and_time(default_time)),
75 "yesterday" => return Some((today - Duration::days(1)).and_time(default_time)),
76 "next week" => {
77 // Next Monday at the default time.
78 let day = today.weekday().num_days_from_sunday() as i64;
79 let mut days_until_mon = (8 - day) % 7;
80 if days_until_mon == 0 {
81 days_until_mon = 7;
82 }
83 return Some((today + Duration::days(days_until_mon)).and_time(default_time));
84 }
85 _ => {}
86 }
87
88 // "in N days".
89 if tokens.len() == 3 && tokens[0] == "in" && (tokens[2] == "day" || tokens[2] == "days") {
90 if let Ok(n) = tokens[1].parse::<i64>()
91 && n >= 0
92 {
93 return Some((today + Duration::days(n)).and_time(default_time));
94 }
95 return None;
96 }
97
98 // Weekday names ("friday", "next friday", "friday 3pm").
99 if let Some(dt) = parse_weekday(&tokens, now, default_time) {
100 return Some(dt);
101 }
102
103 // Month + day ("dec 25", "december 25", "jan 5 3pm").
104 parse_month_day(&tokens, now, default_time)
105 }
106
107 /// Parse an explicit ISO-ish datetime in local wall-clock terms.
108 fn parse_iso_datetime(s: &str) -> Option<NaiveDateTime> {
109 const FORMATS: [&str; 4] = [
110 "%Y-%m-%dT%H:%M:%S",
111 "%Y-%m-%dT%H:%M",
112 "%Y-%m-%d %H:%M:%S",
113 "%Y-%m-%d %H:%M",
114 ];
115 FORMATS
116 .iter()
117 .find_map(|fmt| NaiveDateTime::parse_from_str(s, fmt).ok())
118 }
119
120 /// Resolve the next occurrence of a weekday, with optional `next` prefix and time.
121 fn parse_weekday(
122 tokens: &[&str],
123 now: NaiveDateTime,
124 default_time: NaiveTime,
125 ) -> Option<NaiveDateTime> {
126 let is_next = tokens.first() == Some(&"next");
127 let name_idx = usize::from(is_next);
128 let target = WEEKDAYS
129 .iter()
130 .position(|d| Some(d) == tokens.get(name_idx))? as i64;
131
132 let today = now.date();
133 let current = today.weekday().num_days_from_sunday() as i64;
134 // Mirrors the previous JS offset logic exactly, including how "next" skips
135 // an extra week.
136 let mut diff = target - current;
137 if diff <= 0 || is_next {
138 diff += 7;
139 }
140 if is_next && diff <= 7 {
141 diff += 7;
142 }
143
144 let time = parse_time_tokens(&tokens[name_idx + 1..], default_time)?;
145 Some((today + Duration::days(diff)).and_time(time))
146 }
147
148 /// Resolve a "month day" reference, rolling to next year if already past.
149 fn parse_month_day(
150 tokens: &[&str],
151 now: NaiveDateTime,
152 default_time: NaiveTime,
153 ) -> Option<NaiveDateTime> {
154 if tokens.len() < 2 {
155 return None;
156 }
157 let month_idx = MONTH_ABBRS
158 .iter()
159 .position(|m| m == &tokens[0])
160 .or_else(|| MONTHS.iter().position(|m| m == &tokens[0]))?;
161 let day: u32 = tokens[1].parse().ok()?;
162
163 let year = now.year();
164 let mut date = NaiveDate::from_ymd_opt(year, month_idx as u32 + 1, day)?;
165 // Compare at midnight (as the JS did) before applying the time-of-day.
166 if date.and_hms_opt(0, 0, 0)? < now {
167 date = NaiveDate::from_ymd_opt(year + 1, month_idx as u32 + 1, day)?;
168 }
169
170 let time = parse_time_tokens(&tokens[2..], default_time)?;
171 Some(date.and_time(time))
172 }
173
174 /// Parse an optional trailing time ("3pm", "3:30pm", "15:30", "3 pm").
175 ///
176 /// Returns `default` when no tokens remain, or `None` when the trailing tokens
177 /// are present but not a valid time (so the whole input is rejected, as the old
178 /// anchored regexes did).
179 fn parse_time_tokens(tokens: &[&str], default: NaiveTime) -> Option<NaiveTime> {
180 if tokens.is_empty() {
181 return Some(default);
182 }
183 parse_clock(&tokens.concat())
184 }
185
186 /// Parse a clock string with an optional am/pm suffix and optional minutes.
187 fn parse_clock(raw: &str) -> Option<NaiveTime> {
188 let mut s = raw;
189 let mut ampm: Option<bool> = None; // Some(true) = pm, Some(false) = am
190 if let Some(stripped) = s.strip_suffix("pm") {
191 ampm = Some(true);
192 s = stripped;
193 } else if let Some(stripped) = s.strip_suffix("am") {
194 ampm = Some(false);
195 s = stripped;
196 }
197 let s = s.trim();
198
199 let (h_str, m_str) = match s.split_once(':') {
200 Some((h, m)) => (h, Some(m)),
201 None => (s, None),
202 };
203 let mut h: u32 = h_str.trim().parse().ok()?;
204 let m: u32 = match m_str {
205 Some(m) => m.trim().parse().ok()?,
206 None => 0,
207 };
208
209 match ampm {
210 Some(true) if h < 12 => h += 12,
211 Some(false) if h == 12 => h = 0,
212 _ => {}
213 }
214 if h > 23 || m > 59 {
215 return None;
216 }
217 NaiveTime::from_hms_opt(h, m, 0)
218 }
219
220 #[cfg(test)]
221 mod tests {
222 use super::*;
223
224 /// Reference "now": Wednesday 2026-06-17 at 14:30 local.
225 fn now() -> NaiveDateTime {
226 NaiveDate::from_ymd_opt(2026, 6, 17)
227 .unwrap()
228 .and_hms_opt(14, 30, 0)
229 .unwrap()
230 }
231
232 fn parse(s: &str) -> Option<NaiveDateTime> {
233 parse_natural_date(s, now())
234 }
235
236 #[test]
237 fn relative_keywords() {
238 assert_eq!(
239 parse("today"),
240 Some(
241 NaiveDate::from_ymd_opt(2026, 6, 17)
242 .unwrap()
243 .and_hms_opt(9, 0, 0)
244 .unwrap()
245 )
246 );
247 assert_eq!(
248 parse("tomorrow"),
249 Some(
250 NaiveDate::from_ymd_opt(2026, 6, 18)
251 .unwrap()
252 .and_hms_opt(9, 0, 0)
253 .unwrap()
254 )
255 );
256 assert_eq!(
257 parse("yesterday"),
258 Some(
259 NaiveDate::from_ymd_opt(2026, 6, 16)
260 .unwrap()
261 .and_hms_opt(9, 0, 0)
262 .unwrap()
263 )
264 );
265 }
266
267 #[test]
268 fn blank_and_garbage_are_none() {
269 assert_eq!(parse(""), None);
270 assert_eq!(parse(" "), None);
271 assert_eq!(parse("someday maybe"), None);
272 assert_eq!(parse("friday at noon"), None); // "at noon" is not a valid time suffix
273 }
274
275 #[test]
276 fn iso_datetime_preserves_wall_clock() {
277 assert_eq!(
278 parse("2026-12-25T15:30"),
279 Some(
280 NaiveDate::from_ymd_opt(2026, 12, 25)
281 .unwrap()
282 .and_hms_opt(15, 30, 0)
283 .unwrap()
284 )
285 );
286 assert_eq!(
287 parse("2026-12-25 15:30"),
288 Some(
289 NaiveDate::from_ymd_opt(2026, 12, 25)
290 .unwrap()
291 .and_hms_opt(15, 30, 0)
292 .unwrap()
293 )
294 );
295 }
296
297 #[test]
298 fn iso_date_defaults_to_nine_am() {
299 assert_eq!(
300 parse("2026-12-25"),
301 Some(
302 NaiveDate::from_ymd_opt(2026, 12, 25)
303 .unwrap()
304 .and_hms_opt(9, 0, 0)
305 .unwrap()
306 )
307 );
308 assert_eq!(
309 parse("2026-12-25 3pm"),
310 Some(
311 NaiveDate::from_ymd_opt(2026, 12, 25)
312 .unwrap()
313 .and_hms_opt(15, 0, 0)
314 .unwrap()
315 )
316 );
317 }
318
319 #[test]
320 fn in_n_days() {
321 assert_eq!(
322 parse("in 3 days"),
323 Some(
324 NaiveDate::from_ymd_opt(2026, 6, 20)
325 .unwrap()
326 .and_hms_opt(9, 0, 0)
327 .unwrap()
328 )
329 );
330 assert_eq!(
331 parse("in 1 day"),
332 Some(
333 NaiveDate::from_ymd_opt(2026, 6, 18)
334 .unwrap()
335 .and_hms_opt(9, 0, 0)
336 .unwrap()
337 )
338 );
339 }
340
341 #[test]
342 fn next_week_is_next_monday() {
343 // 2026-06-17 is a Wednesday; next Monday is 2026-06-22.
344 assert_eq!(
345 parse("next week"),
346 Some(
347 NaiveDate::from_ymd_opt(2026, 6, 22)
348 .unwrap()
349 .and_hms_opt(9, 0, 0)
350 .unwrap()
351 )
352 );
353 }
354
355 #[test]
356 fn weekday_finds_next_occurrence() {
357 // From Wednesday, "friday" is 2026-06-19.
358 assert_eq!(
359 parse("friday"),
360 Some(
361 NaiveDate::from_ymd_opt(2026, 6, 19)
362 .unwrap()
363 .and_hms_opt(9, 0, 0)
364 .unwrap()
365 )
366 );
367 // Same weekday goes to next week (Wednesday -> 2026-06-24).
368 assert_eq!(
369 parse("wednesday"),
370 Some(
371 NaiveDate::from_ymd_opt(2026, 6, 24)
372 .unwrap()
373 .and_hms_opt(9, 0, 0)
374 .unwrap()
375 )
376 );
377 // "friday 3pm" applies the time.
378 assert_eq!(
379 parse("friday 3pm"),
380 Some(
381 NaiveDate::from_ymd_opt(2026, 6, 19)
382 .unwrap()
383 .and_hms_opt(15, 0, 0)
384 .unwrap()
385 )
386 );
387 }
388
389 #[test]
390 fn next_weekday_skips_an_extra_week() {
391 // "next friday" skips the coming Friday (matches the prior JS quirk):
392 // coming Friday is 06-19, so "next friday" -> 06-26.
393 assert_eq!(
394 parse("next friday"),
395 Some(
396 NaiveDate::from_ymd_opt(2026, 6, 26)
397 .unwrap()
398 .and_hms_opt(9, 0, 0)
399 .unwrap()
400 )
401 );
402 }
403
404 #[test]
405 fn month_day_rolls_to_next_year_when_past() {
406 // June 17 is past at 14:30; "jan 5" rolls to 2027.
407 assert_eq!(
408 parse("jan 5"),
409 Some(
410 NaiveDate::from_ymd_opt(2027, 1, 5)
411 .unwrap()
412 .and_hms_opt(9, 0, 0)
413 .unwrap()
414 )
415 );
416 // A future month stays this year.
417 assert_eq!(
418 parse("december 25"),
419 Some(
420 NaiveDate::from_ymd_opt(2026, 12, 25)
421 .unwrap()
422 .and_hms_opt(9, 0, 0)
423 .unwrap()
424 )
425 );
426 assert_eq!(
427 parse("dec 25 3pm"),
428 Some(
429 NaiveDate::from_ymd_opt(2026, 12, 25)
430 .unwrap()
431 .and_hms_opt(15, 0, 0)
432 .unwrap()
433 )
434 );
435 }
436
437 #[test]
438 fn clock_parsing_handles_am_pm_and_24h() {
439 assert_eq!(parse_clock("3pm"), NaiveTime::from_hms_opt(15, 0, 0));
440 assert_eq!(parse_clock("3:30pm"), NaiveTime::from_hms_opt(15, 30, 0));
441 assert_eq!(parse_clock("12am"), NaiveTime::from_hms_opt(0, 0, 0));
442 assert_eq!(parse_clock("12pm"), NaiveTime::from_hms_opt(12, 0, 0));
443 assert_eq!(parse_clock("15:30"), NaiveTime::from_hms_opt(15, 30, 0));
444 assert_eq!(parse_clock("25:00"), None);
445 assert_eq!(parse_clock("noon"), None);
446 }
447 }
448