max / goingson
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
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; |