max / goingson
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, |