Skip to main content

max / goingson

fix(ux): centralize natural-date parsing in Rust, fix cache-invalidate order, clamp pagination Axis 3 of the Run #26 ultra-fuzz remediation. Date parser: remove the divergent JavaScript natural-language date parser and make Rust the single source of truth. New goingson_core::date_parser ports the full grammar (relative keywords, "next week", "in N days", weekdays with the "next" skip quirk, month/day, ISO, time-of-day) operating on local wall-clock time, exposed via the parse_natural_date command. The JS parseNaturalDate is now a thin async wrapper; form transforms and the live preview are awaited (preview debounced), and the task/event/project call sites are async. Cache ordering: move cache.invalidate('tasks') into onSuccess for create/update/ start so a failed mutation no longer drops the cache (the optimistic complete and delete paths keep their early invalidate by design). Pagination: clamp list_filtered LIMIT to [0, 1000] and OFFSET to >= 0, so a negative LIMIT can no longer mean "unbounded" in SQLite (defense-in-depth). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 02:23 UTC
Commit: 7025358f2acf5b608fc4d8e1d7b8b20d5672df64
Parent: 622bc51
13 files changed, +377 insertions, -104 deletions
@@ -0,0 +1,297 @@
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", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
21 + ];
22 + const MONTHS: [&str; 12] = [
23 + "january", "february", "march", "april", "may", "june", "july", "august",
24 + "september", "october", "november", "december",
25 + ];
26 + const MONTH_ABBRS: [&str; 12] = [
27 + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
28 + ];
29 +
30 + /// Parse a natural-language date string relative to `now` (local wall clock).
31 + ///
32 + /// Returns the resolved local `NaiveDateTime`, or `None` if unrecognized.
33 + pub fn parse_natural_date(input: &str, now: NaiveDateTime) -> Option<NaiveDateTime> {
34 + let trimmed = input.trim();
35 + if trimmed.is_empty() {
36 + return None;
37 + }
38 +
39 + // Already an ISO-ish datetime (YYYY-MM-DDTHH:MM or with a space / seconds).
40 + if let Some(dt) = parse_iso_datetime(trimmed) {
41 + return Some(dt);
42 + }
43 +
44 + let lower = trimmed.to_lowercase();
45 + let default_time = NaiveTime::from_hms_opt(DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE, 0)?;
46 + let today = now.date();
47 + let tokens: Vec<&str> = lower.split_whitespace().collect();
48 +
49 + // Plain YYYY-MM-DD with an optional trailing time ("2026-12-25", "2026-12-25 3pm").
50 + if let Ok(date) = NaiveDate::parse_from_str(tokens[0], "%Y-%m-%d") {
51 + let time = parse_time_tokens(&tokens[1..], default_time)?;
52 + return Some(date.and_time(time));
53 + }
54 +
55 + // Relative keywords.
56 + match lower.as_str() {
57 + "today" => return Some(today.and_time(default_time)),
58 + "tomorrow" => return Some((today + Duration::days(1)).and_time(default_time)),
59 + "yesterday" => return Some((today - Duration::days(1)).and_time(default_time)),
60 + "next week" => {
61 + // Next Monday at the default time.
62 + let day = today.weekday().num_days_from_sunday() as i64;
63 + let mut days_until_mon = (8 - day) % 7;
64 + if days_until_mon == 0 {
65 + days_until_mon = 7;
66 + }
67 + return Some((today + Duration::days(days_until_mon)).and_time(default_time));
68 + }
69 + _ => {}
70 + }
71 +
72 + // "in N days".
73 + if tokens.len() == 3 && tokens[0] == "in" && (tokens[2] == "day" || tokens[2] == "days") {
74 + if let Ok(n) = tokens[1].parse::<i64>()
75 + && n >= 0
76 + {
77 + return Some((today + Duration::days(n)).and_time(default_time));
78 + }
79 + return None;
80 + }
81 +
82 + // Weekday names ("friday", "next friday", "friday 3pm").
83 + if let Some(dt) = parse_weekday(&tokens, now, default_time) {
84 + return Some(dt);
85 + }
86 +
87 + // Month + day ("dec 25", "december 25", "jan 5 3pm").
88 + parse_month_day(&tokens, now, default_time)
89 + }
90 +
91 + /// Parse an explicit ISO-ish datetime in local wall-clock terms.
92 + fn parse_iso_datetime(s: &str) -> Option<NaiveDateTime> {
93 + const FORMATS: [&str; 4] = [
94 + "%Y-%m-%dT%H:%M:%S",
95 + "%Y-%m-%dT%H:%M",
96 + "%Y-%m-%d %H:%M:%S",
97 + "%Y-%m-%d %H:%M",
98 + ];
99 + FORMATS
100 + .iter()
101 + .find_map(|fmt| NaiveDateTime::parse_from_str(s, fmt).ok())
102 + }
103 +
104 + /// Resolve the next occurrence of a weekday, with optional `next` prefix and time.
105 + fn parse_weekday(tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime) -> Option<NaiveDateTime> {
106 + let is_next = tokens.first() == Some(&"next");
107 + let name_idx = if is_next { 1 } else { 0 };
108 + let target = WEEKDAYS.iter().position(|d| Some(d) == tokens.get(name_idx))? as i64;
109 +
110 + let today = now.date();
111 + let current = today.weekday().num_days_from_sunday() as i64;
112 + // Mirrors the previous JS offset logic exactly, including how "next" skips
113 + // an extra week.
114 + let mut diff = target - current;
115 + if diff <= 0 || is_next {
116 + diff += 7;
117 + }
118 + if is_next && diff <= 7 {
119 + diff += 7;
120 + }
121 +
122 + let time = parse_time_tokens(&tokens[name_idx + 1..], default_time)?;
123 + Some((today + Duration::days(diff)).and_time(time))
124 + }
125 +
126 + /// Resolve a "month day" reference, rolling to next year if already past.
127 + fn parse_month_day(tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime) -> Option<NaiveDateTime> {
128 + if tokens.len() < 2 {
129 + return None;
130 + }
131 + let month_idx = MONTH_ABBRS
132 + .iter()
133 + .position(|m| m == &tokens[0])
134 + .or_else(|| MONTHS.iter().position(|m| m == &tokens[0]))?;
135 + let day: u32 = tokens[1].parse().ok()?;
136 +
137 + let year = now.year();
138 + let mut date = NaiveDate::from_ymd_opt(year, month_idx as u32 + 1, day)?;
139 + // Compare at midnight (as the JS did) before applying the time-of-day.
140 + if date.and_hms_opt(0, 0, 0)? < now {
141 + date = NaiveDate::from_ymd_opt(year + 1, month_idx as u32 + 1, day)?;
142 + }
143 +
144 + let time = parse_time_tokens(&tokens[2..], default_time)?;
145 + Some(date.and_time(time))
146 + }
147 +
148 + /// Parse an optional trailing time ("3pm", "3:30pm", "15:30", "3 pm").
149 + ///
150 + /// Returns `default` when no tokens remain, or `None` when the trailing tokens
151 + /// are present but not a valid time (so the whole input is rejected, as the old
152 + /// anchored regexes did).
153 + fn parse_time_tokens(tokens: &[&str], default: NaiveTime) -> Option<NaiveTime> {
154 + if tokens.is_empty() {
155 + return Some(default);
156 + }
157 + parse_clock(&tokens.concat())
158 + }
159 +
160 + /// Parse a clock string with an optional am/pm suffix and optional minutes.
161 + fn parse_clock(raw: &str) -> Option<NaiveTime> {
162 + let mut s = raw;
163 + let mut ampm: Option<bool> = None; // Some(true) = pm, Some(false) = am
164 + if let Some(stripped) = s.strip_suffix("pm") {
165 + ampm = Some(true);
166 + s = stripped;
167 + } else if let Some(stripped) = s.strip_suffix("am") {
168 + ampm = Some(false);
169 + s = stripped;
170 + }
171 + let s = s.trim();
172 +
173 + let (h_str, m_str) = match s.split_once(':') {
174 + Some((h, m)) => (h, Some(m)),
175 + None => (s, None),
176 + };
177 + let mut h: u32 = h_str.trim().parse().ok()?;
178 + let m: u32 = match m_str {
179 + Some(m) => m.trim().parse().ok()?,
180 + None => 0,
181 + };
182 +
183 + match ampm {
184 + Some(true) if h < 12 => h += 12,
185 + Some(false) if h == 12 => h = 0,
186 + _ => {}
187 + }
188 + if h > 23 || m > 59 {
189 + return None;
190 + }
191 + NaiveTime::from_hms_opt(h, m, 0)
192 + }
193 +
194 + #[cfg(test)]
195 + mod tests {
196 + use super::*;
197 +
198 + /// Reference "now": Wednesday 2026-06-17 at 14:30 local.
199 + fn now() -> NaiveDateTime {
200 + NaiveDate::from_ymd_opt(2026, 6, 17)
201 + .unwrap()
202 + .and_hms_opt(14, 30, 0)
203 + .unwrap()
204 + }
205 +
206 + fn parse(s: &str) -> Option<NaiveDateTime> {
207 + parse_natural_date(s, now())
208 + }
209 +
210 + #[test]
211 + fn relative_keywords() {
212 + assert_eq!(parse("today"), Some(NaiveDate::from_ymd_opt(2026, 6, 17).unwrap().and_hms_opt(9, 0, 0).unwrap()));
213 + assert_eq!(parse("tomorrow"), Some(NaiveDate::from_ymd_opt(2026, 6, 18).unwrap().and_hms_opt(9, 0, 0).unwrap()));
214 + assert_eq!(parse("yesterday"), Some(NaiveDate::from_ymd_opt(2026, 6, 16).unwrap().and_hms_opt(9, 0, 0).unwrap()));
215 + }
216 +
217 + #[test]
218 + fn blank_and_garbage_are_none() {
219 + assert_eq!(parse(""), None);
220 + assert_eq!(parse(" "), None);
221 + assert_eq!(parse("someday maybe"), None);
222 + assert_eq!(parse("friday at noon"), None); // "at noon" is not a valid time suffix
223 + }
224 +
225 + #[test]
226 + fn iso_datetime_preserves_wall_clock() {
227 + assert_eq!(
228 + parse("2026-12-25T15:30"),
229 + Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap())
230 + );
231 + assert_eq!(
232 + parse("2026-12-25 15:30"),
233 + Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap())
234 + );
235 + }
236 +
237 + #[test]
238 + fn iso_date_defaults_to_nine_am() {
239 + assert_eq!(
240 + parse("2026-12-25"),
241 + Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(9, 0, 0).unwrap())
242 + );
243 + assert_eq!(
244 + parse("2026-12-25 3pm"),
245 + Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 0, 0).unwrap())
246 + );
247 + }
248 +
249 + #[test]
250 + fn in_n_days() {
251 + assert_eq!(parse("in 3 days"), Some(NaiveDate::from_ymd_opt(2026, 6, 20).unwrap().and_hms_opt(9, 0, 0).unwrap()));
252 + assert_eq!(parse("in 1 day"), Some(NaiveDate::from_ymd_opt(2026, 6, 18).unwrap().and_hms_opt(9, 0, 0).unwrap()));
253 + }
254 +
255 + #[test]
256 + fn next_week_is_next_monday() {
257 + // 2026-06-17 is a Wednesday; next Monday is 2026-06-22.
258 + assert_eq!(parse("next week"), Some(NaiveDate::from_ymd_opt(2026, 6, 22).unwrap().and_hms_opt(9, 0, 0).unwrap()));
259 + }
260 +
261 + #[test]
262 + fn weekday_finds_next_occurrence() {
263 + // From Wednesday, "friday" is 2026-06-19.
264 + assert_eq!(parse("friday"), Some(NaiveDate::from_ymd_opt(2026, 6, 19).unwrap().and_hms_opt(9, 0, 0).unwrap()));
265 + // Same weekday goes to next week (Wednesday -> 2026-06-24).
266 + assert_eq!(parse("wednesday"), Some(NaiveDate::from_ymd_opt(2026, 6, 24).unwrap().and_hms_opt(9, 0, 0).unwrap()));
267 + // "friday 3pm" applies the time.
268 + assert_eq!(parse("friday 3pm"), Some(NaiveDate::from_ymd_opt(2026, 6, 19).unwrap().and_hms_opt(15, 0, 0).unwrap()));
269 + }
270 +
271 + #[test]
272 + fn next_weekday_skips_an_extra_week() {
273 + // "next friday" skips the coming Friday (matches the prior JS quirk):
274 + // coming Friday is 06-19, so "next friday" -> 06-26.
275 + assert_eq!(parse("next friday"), Some(NaiveDate::from_ymd_opt(2026, 6, 26).unwrap().and_hms_opt(9, 0, 0).unwrap()));
276 + }
277 +
278 + #[test]
279 + fn month_day_rolls_to_next_year_when_past() {
280 + // June 17 is past at 14:30; "jan 5" rolls to 2027.
281 + assert_eq!(parse("jan 5"), Some(NaiveDate::from_ymd_opt(2027, 1, 5).unwrap().and_hms_opt(9, 0, 0).unwrap()));
282 + // A future month stays this year.
283 + assert_eq!(parse("december 25"), Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(9, 0, 0).unwrap()));
284 + assert_eq!(parse("dec 25 3pm"), Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 0, 0).unwrap()));
285 + }
286 +
287 + #[test]
288 + fn clock_parsing_handles_am_pm_and_24h() {
289 + assert_eq!(parse_clock("3pm"), NaiveTime::from_hms_opt(15, 0, 0));
290 + assert_eq!(parse_clock("3:30pm"), NaiveTime::from_hms_opt(15, 30, 0));
291 + assert_eq!(parse_clock("12am"), NaiveTime::from_hms_opt(0, 0, 0));
292 + assert_eq!(parse_clock("12pm"), NaiveTime::from_hms_opt(12, 0, 0));
293 + assert_eq!(parse_clock("15:30"), NaiveTime::from_hms_opt(15, 30, 0));
294 + assert_eq!(parse_clock("25:00"), None);
295 + assert_eq!(parse_clock("noon"), None);
296 + }
297 + }
@@ -33,6 +33,7 @@
33 33 pub mod backup_restore;
34 34 pub mod constants;
35 35 pub mod contact;
36 + pub mod date_parser;
36 37 pub mod date_utils;
37 38 pub mod day_planning;
38 39 pub mod email_id;
@@ -77,6 +78,7 @@ pub use models::{
77 78 format_file_size, mime_from_extension,
78 79 };
79 80 pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult};
81 + pub use date_parser::parse_natural_date;
80 82 pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
81 83 pub use recurrence::{calculate_next_due, calculate_next_due_with_day, calculate_next_due_rich, expand_recurrence, should_recur};
82 84 pub use repository::*;
@@ -358,9 +358,17 @@ impl TaskRepository for SqliteTaskRepository {
358 358 return Ok((vec![], 0));
359 359 }
360 360
361 - // Build paginated query with parameterized LIMIT/OFFSET
361 + // Build paginated query with parameterized LIMIT/OFFSET.
362 + //
363 + // Defense-in-depth: clamp before binding. A negative LIMIT means
364 + // "unbounded" in SQLite and an absurd LIMIT/negative OFFSET could blow up
365 + // the result set. The UI already paginates; this is a backstop against a
366 + // bad caller, not the primary guard.
367 + const MAX_PAGE_LIMIT: i64 = 1000;
362 368 let mut pagination_binds: Vec<i64> = Vec::new();
363 - let pagination = match (query.limit, query.offset) {
369 + let limit = query.limit.map(|l| l.clamp(0, MAX_PAGE_LIMIT));
370 + let offset = query.offset.map(|o| o.max(0));
371 + let pagination = match (limit, offset) {
364 372 (Some(limit), Some(offset)) => {
365 373 pagination_binds.push(limit);
366 374 pagination_binds.push(offset);
@@ -3,7 +3,7 @@
3 3 mod common;
4 4
5 5 use chrono::{Duration, Utc};
6 - use goingson_core::{NewTask, Priority, Recurrence, TaskRepository, TaskStatus, UpdateTask};
6 + use goingson_core::{NewTask, Priority, Recurrence, TaskFilterQuery, TaskRepository, TaskStatus, UpdateTask};
7 7 use goingson_db_sqlite::SqliteTaskRepository;
8 8
9 9 #[tokio::test]
@@ -450,3 +450,25 @@ async fn test_list_unscheduled_due_between_respects_window() {
450 450 assert_eq!(found.len(), 1, "only the in-window task should match");
451 451 assert_eq!(found[0].id, inside.id);
452 452 }
453 +
454 + #[tokio::test]
455 + async fn list_filtered_clamps_negative_limit() {
456 + let pool = common::setup_test_db().await;
457 + let user_id = common::create_test_user(&pool).await;
458 + let repo = SqliteTaskRepository::new(pool);
459 +
460 + for i in 0..3 {
461 + let task = NewTask::builder(format!("Task {i}")).build();
462 + repo.create(user_id, task).await.expect("Failed to create");
463 + }
464 +
465 + // A negative LIMIT means "unbounded" in SQLite; the clamp must turn it into a
466 + // bounded page (0) rather than returning every row.
467 + let query = TaskFilterQuery {
468 + limit: Some(-1),
469 + ..Default::default()
470 + };
471 + let (page, total) = repo.list_filtered(user_id, query).await.expect("list_filtered");
472 + assert!(page.is_empty(), "negative limit must not return rows unbounded");
473 + assert_eq!(total, 3, "total count is independent of the clamped limit");
474 + }
@@ -199,6 +199,7 @@ const api = {
199 199 // App metadata
200 200 app: {
201 201 getChangelog: () => invoke('get_changelog'),
202 + parseNaturalDate: (input) => invoke('parse_natural_date', { input }), // "tomorrow", "friday 3pm" -> YYYY-MM-DDTHH:MM
202 203 },
203 204
204 205 // Day Planning
@@ -172,7 +172,7 @@
172 172 value: event?.start_time
173 173 ? new Date(event.start_time).toISOString().slice(0, 16)
174 174 : localISOTime,
175 - transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
175 + transform: async (v) => (await GoingsOn.utils.parseNaturalDate(v)) || v,
176 176 onInput: GoingsOn.utils.dateParsePreview,
177 177 },
178 178 {
@@ -183,7 +183,7 @@
183 183 value: event?.end_time
184 184 ? new Date(event.end_time).toISOString().slice(0, 16)
185 185 : '',
186 - transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
186 + transform: async (v) => (await GoingsOn.utils.parseNaturalDate(v)) || v,
187 187 onInput: GoingsOn.utils.dateParsePreview,
188 188 validate: (v, data) => {
189 189 if (v && data.start_time && new Date(v) < new Date(data.start_time)) {
@@ -137,9 +137,10 @@ function openFormModal(config) {
137 137 formData[field.name] = el.value;
138 138 }
139 139
140 - // Apply field transform if defined
140 + // Apply field transform if defined (may be async, e.g. the
141 + // Rust-backed natural-date parse).
141 142 if (field.transform && formData[field.name]) {
142 - formData[field.name] = field.transform(formData[field.name]);
143 + formData[field.name] = await field.transform(formData[field.name]);
143 144 }
144 145 }
145 146
@@ -205,10 +206,12 @@ function openFormModal(config) {
205 206 const el = document.getElementById(inputId);
206 207 const previewEl = document.getElementById(`${inputId}-preview`);
207 208 if (el && previewEl) {
208 - const handler = () => field.onInput(el.value, previewEl);
209 - el.addEventListener('input', handler, { signal: modalAbortController.signal });
209 + // onInput may be async (e.g. the Rust-backed date parse). Debounce
210 + // so typing doesn't fire an IPC call per keystroke.
211 + const debounced = GoingsOn.utils.debounce((v) => field.onInput(v, previewEl), 200);
212 + el.addEventListener('input', () => debounced(el.value), { signal: modalAbortController.signal });
210 213 // Run once on open to show preview for pre-filled values
211 - if (el.value) handler();
214 + if (el.value) field.onInput(el.value, previewEl);
212 215 }
213 216 }
214 217
@@ -429,7 +429,7 @@
429 429 fields: [
430 430 { name: 'name', type: 'text', label: 'Name', required: true, value: '' },
431 431 { name: 'description', type: 'textarea', label: 'Description', value: '' },
432 - { name: 'targetDate', type: 'text', label: 'Target Date', value: '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
432 + { name: 'targetDate', type: 'text', label: 'Target Date', value: '', placeholder: 'next friday, 2026-03-01...', transform: async (v) => (await GoingsOn.utils.parseNaturalDate(v)) || v, onInput: GoingsOn.utils.dateParsePreview },
433 433 ],
434 434 onSubmit: async (data) => {
435 435 await GoingsOn.ui.apiCall(
@@ -465,7 +465,7 @@
465 465 fields: [
466 466 { name: 'name', type: 'text', label: 'Name', required: true, value: m.name },
467 467 { name: 'description', type: 'textarea', label: 'Description', value: m.description },
468 - { name: 'targetDate', type: 'text', label: 'Target Date', value: m.targetDate || '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
468 + { name: 'targetDate', type: 'text', label: 'Target Date', value: m.targetDate || '', placeholder: 'next friday, 2026-03-01...', transform: async (v) => (await GoingsOn.utils.parseNaturalDate(v)) || v, onInput: GoingsOn.utils.dateParsePreview },
469 469 { name: 'status', type: 'select', label: 'Status', value: m.status, options: [
470 470 { value: 'open', label: 'Open' },
471 471 { value: 'completed', label: 'Completed' },
@@ -334,12 +334,13 @@
334 334 label: 'Due Date (optional)',
335 335 placeholder: 'tomorrow, friday 3pm, 2026-12-25...',
336 336 value: dueValue,
337 - transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
337 + transform: async (v) => (await GoingsOn.utils.parseNaturalDate(v)) || v,
338 338 onInput: GoingsOn.utils.dateParsePreview,
339 339 validate: (v) => {
340 340 if (!v || !v.trim()) return null;
341 - const parsed = GoingsOn.utils.parseNaturalDate(v);
342 - if (!parsed && !/^\d{4}-\d{2}-\d{2}/.test(v.trim())) {
341 + // `v` has already been through the async transform; a recognized
342 + // date is now an ISO string, so anything else was unparseable.
343 + if (!/^\d{4}-\d{2}-\d{2}/.test(v.trim())) {
343 344 return 'Date not recognized. Try "tomorrow", "friday 3pm", or "2026-12-25".';
344 345 }
345 346 return null;
@@ -254,10 +254,10 @@
254 254 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
255 255 }
256 256
257 - GoingsOn.cache.invalidate('tasks');
258 257 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.create(input), {
259 258 successMessage: 'Task created!',
260 259 errorMessage: 'Failed to create task',
260 + onSuccess: () => GoingsOn.cache.invalidate('tasks'),
261 261 reload: reloadFns,
262 262 });
263 263 }
@@ -359,10 +359,10 @@
359 359 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
360 360 }
361 361
362 - GoingsOn.cache.invalidate('tasks');
363 362 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(id, input), {
364 363 successMessage: 'Task updated!',
365 364 errorMessage: 'Failed to update task',
365 + onSuccess: () => GoingsOn.cache.invalidate('tasks'),
366 366 reload: reloadFns,
367 367 });
368 368 }
@@ -595,10 +595,10 @@
595 595 * @param {string} id - Task ID to start
596 596 */
597 597 async function start(id) {
598 - GoingsOn.cache.invalidate('tasks');
599 598 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.start(id), {
600 599 successMessage: 'Task started!',
601 600 errorMessage: 'Failed to start task',
601 + onSuccess: () => GoingsOn.cache.invalidate('tasks'),
602 602 reload: load,
603 603 });
604 604 }
@@ -557,91 +557,15 @@ function autoGrow(textarea) {
557 557 * @param {string} str - Natural date string
558 558 * @returns {string|null} - ISO datetime string or null if unparseable
559 559 */
560 - function parseNaturalDate(str) {
561 - if (!str) return null;
562 - const input = str.trim().toLowerCase();
563 - if (!input) return null;
564 -
565 - // Already ISO format (YYYY-MM-DDTHH:MM or YYYY-MM-DD HH:MM)
566 - if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(str.trim())) {
567 - const d = new Date(str.trim());
568 - if (!isNaN(d)) return toLocalISOString(d);
560 + async function parseNaturalDate(str) {
561 + if (!str || !str.trim()) return null;
562 + try {
563 + // The Rust `parse_natural_date` command is the single source of truth
564 + // for the grammar; returns "YYYY-MM-DDTHH:MM" or null.
565 + return await GoingsOn.api.app.parseNaturalDate(str);
566 + } catch (e) {
567 + return null;
569 568 }
570 -
571 - // YYYY-MM-DD with optional time
572 - const dateMatch = input.match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
573 - if (dateMatch) {
574 - const d = new Date(dateMatch[1] + 'T12:00:00');
575 - if (!isNaN(d)) {
576 - if (dateMatch[2]) applyTime(d, dateMatch[2], dateMatch[3], dateMatch[4]);
577 - else d.setHours(9, 0, 0, 0);
578 - return toLocalISOString(d);
579 - }
580 - }
581 -
582 - const now = new Date();
583 -
584 - // Relative: today, tomorrow, yesterday
585 - if (input === 'today') { now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
586 - if (input === 'tomorrow') { now.setDate(now.getDate() + 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
587 - if (input === 'yesterday') { now.setDate(now.getDate() - 1); now.setHours(9, 0, 0, 0); return toLocalISOString(now); }
588 -
589 - // "next week" = next Monday 9am
590 - if (input === 'next week') {
591 - const daysUntilMon = (8 - now.getDay()) % 7 || 7;
592 - now.setDate(now.getDate() + daysUntilMon);
593 - now.setHours(9, 0, 0, 0);
594 - return toLocalISOString(now);
595 - }
596 -
597 - // "in N days"
598 - const inDaysMatch = input.match(/^in\s+(\d+)\s+days?$/);
599 - if (inDaysMatch) {
600 - now.setDate(now.getDate() + parseInt(inDaysMatch[1]));
601 - now.setHours(9, 0, 0, 0);
602 - return toLocalISOString(now);
603 - }
604 -
605 - // Weekday names: "friday", "next friday", "friday 3pm"
606 - const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
607 - const dayMatch = input.match(/^(?:next\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
608 - if (dayMatch) {
609 - const targetDay = days.indexOf(dayMatch[1]);
610 - const isNext = input.startsWith('next');
611 - let diff = targetDay - now.getDay();
612 - if (diff <= 0 || isNext) diff += 7;
613 - if (isNext && diff <= 7) diff += 7;
614 - now.setDate(now.getDate() + diff);
615 - if (dayMatch[2]) applyTime(now, dayMatch[2], dayMatch[3], dayMatch[4]);
616 - else now.setHours(9, 0, 0, 0);
617 - return toLocalISOString(now);
618 - }
619 -
620 - // Month + day: "dec 25", "december 25", "jan 5 3pm"
621 - const months = ['january','february','march','april','may','june','july','august','september','october','november','december'];
622 - const monthAbbrs = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
623 - const monthMatch = input.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/);
624 - if (monthMatch) {
625 - let monthIdx = monthAbbrs.indexOf(monthMatch[1]);
626 - if (monthIdx === -1) monthIdx = months.indexOf(monthMatch[1]);
627 - if (monthIdx !== -1) {
628 - const d = new Date(now.getFullYear(), monthIdx, parseInt(monthMatch[2]));
629 - if (d < now) d.setFullYear(d.getFullYear() + 1);
630 - if (monthMatch[3]) applyTime(d, monthMatch[3], monthMatch[4], monthMatch[5]);
631 - else d.setHours(9, 0, 0, 0);
632 - return toLocalISOString(d);
633 - }
634 - }
635 -
636 - return null;
637 - }
638 -
639 - function applyTime(date, hours, minutes, ampm) {
640 - let h = parseInt(hours);
641 - const m = minutes ? parseInt(minutes) : 0;
642 - if (ampm === 'pm' && h < 12) h += 12;
643 - if (ampm === 'am' && h === 12) h = 0;
644 - date.setHours(h, m, 0, 0);
645 569 }
646 570
647 571 // ============ Tag Normalization ============
@@ -672,12 +596,12 @@ function normalizeTags(tagString) {
672 596 * @param {string} value - Current input value
673 597 * @param {HTMLElement} previewEl - Element to show parsed result
674 598 */
675 - function dateParsePreview(value, previewEl) {
599 + async function dateParsePreview(value, previewEl) {
676 600 if (!value || !value.trim()) {
677 601 previewEl.textContent = '';
678 602 return;
679 603 }
680 - const parsed = parseNaturalDate(value);
604 + const parsed = await parseNaturalDate(value);
681 605 if (parsed) {
682 606 const d = new Date(parsed);
683 607 const display = d.toLocaleDateString(undefined, {
@@ -10,6 +10,20 @@ use tracing::instrument;
10 10 /// The project changelog, baked into the binary at build time.
11 11 const CHANGELOG: &str = include_str!("../../../CHANGELOG.md");
12 12
13 + /// Parse a free-text date ("tomorrow", "friday 3pm", "dec 25", ISO) into a
14 + /// local `YYYY-MM-DDTHH:MM` string, or `None` if unrecognized.
15 + ///
16 + /// This is the single source of truth for date-field parsing; the frontend
17 + /// calls it instead of duplicating the grammar in JavaScript. Resolution is
18 + /// relative to the machine's local wall-clock time.
19 + #[tauri::command]
20 + #[instrument]
21 + pub async fn parse_natural_date(input: String) -> Option<String> {
22 + let now = chrono::Local::now().naive_local();
23 + goingson_core::parse_natural_date(&input, now)
24 + .map(|dt| dt.format("%Y-%m-%dT%H:%M").to_string())
25 + }
26 +
13 27 /// Return the raw `CHANGELOG.md` contents.
14 28 ///
15 29 /// The frontend parses out the section for the freshly-installed version and
@@ -184,6 +184,7 @@ macro_rules! all_commands {
184 184 $crate::commands::get_dashboard_stats,
185 185 // App info
186 186 $crate::commands::get_changelog,
187 + $crate::commands::parse_natural_date,
187 188 // Window
188 189 $crate::commands::open_compose_window,
189 190 $crate::commands::set_window_title,