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
Signed with PGP, not checked
Commit: 7025358f2acf5b608fc4d8e1d7b8b20d5672df64
Parent: 622bc51
13 files changed, +377 insertions, -104 deletions
@@ -184,6 +184,7 @@
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,
@@ -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 @@
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::*;
@@ -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 @@
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 @@
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 @@
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 @@
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;