max / goingson
187 files changed,
+11894 insertions,
-5841 deletions
| @@ -6,11 +6,11 @@ | |||
| 6 | 6 | //! and sub-collections are written verbatim; this module only defines the types that | |
| 7 | 7 | //! cross the command -> storage boundary. | |
| 8 | 8 | ||
| 9 | + | use crate::Contact; | |
| 9 | 10 | use crate::models::{ | |
| 10 | 11 | Attachment, DailyNote, Email, Event, Milestone, MonthlyGoal, MonthlyReflection, Project, | |
| 11 | 12 | SavedView, SyncAccount, Task, TimeSession, WeeklyReview, | |
| 12 | 13 | }; | |
| 13 | - | use crate::Contact; | |
| 14 | 14 | ||
| 15 | 15 | /// Result of a restore operation. | |
| 16 | 16 | #[derive(Debug, Default)] |
| @@ -4,9 +4,9 @@ | |||
| 4 | 4 | //! and social handles. Sub-collections are stored in separate tables to | |
| 5 | 5 | //! enable querying by email address for future integration features. | |
| 6 | 6 | ||
| 7 | + | use crate::id_types::{ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, SocialHandleId}; | |
| 7 | 8 | use chrono::{DateTime, NaiveDate, Utc}; | |
| 8 | 9 | use serde::{Deserialize, Serialize}; | |
| 9 | - | use crate::id_types::{ContactId, ContactEmailId, ContactPhoneId, SocialHandleId, CustomFieldId}; | |
| 10 | 10 | ||
| 11 | 11 | // ============ Main Entity ============ | |
| 12 | 12 |
| @@ -17,11 +17,27 @@ use crate::constants::{DEFAULT_PARSE_HOUR, DEFAULT_PARSE_MINUTE}; | |||
| 17 | 17 | ||
| 18 | 18 | /// Weekday names indexed Sunday=0..Saturday=6 (matches JS `Date.getDay`). | |
| 19 | 19 | const WEEKDAYS: [&str; 7] = [ | |
| 20 | - | "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", | |
| 20 | + | "sunday", | |
| 21 | + | "monday", | |
| 22 | + | "tuesday", | |
| 23 | + | "wednesday", | |
| 24 | + | "thursday", | |
| 25 | + | "friday", | |
| 26 | + | "saturday", | |
| 21 | 27 | ]; | |
| 22 | 28 | const MONTHS: [&str; 12] = [ | |
| 23 | - | "january", "february", "march", "april", "may", "june", "july", "august", | |
| 24 | - | "september", "october", "november", "december", | |
| 29 | + | "january", | |
| 30 | + | "february", | |
| 31 | + | "march", | |
| 32 | + | "april", | |
| 33 | + | "may", | |
| 34 | + | "june", | |
| 35 | + | "july", | |
| 36 | + | "august", | |
| 37 | + | "september", | |
| 38 | + | "october", | |
| 39 | + | "november", | |
| 40 | + | "december", | |
| 25 | 41 | ]; | |
| 26 | 42 | const MONTH_ABBRS: [&str; 12] = [ | |
| 27 | 43 | "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", | |
| @@ -102,10 +118,16 @@ fn parse_iso_datetime(s: &str) -> Option<NaiveDateTime> { | |||
| 102 | 118 | } | |
| 103 | 119 | ||
| 104 | 120 | /// 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> { | |
| 121 | + | fn parse_weekday( | |
| 122 | + | tokens: &[&str], | |
| 123 | + | now: NaiveDateTime, | |
| 124 | + | default_time: NaiveTime, | |
| 125 | + | ) -> Option<NaiveDateTime> { | |
| 106 | 126 | let is_next = tokens.first() == Some(&"next"); | |
| 107 | 127 | let name_idx = if is_next { 1 } else { 0 }; | |
| 108 | - | let target = WEEKDAYS.iter().position(|d| Some(d) == tokens.get(name_idx))? as i64; | |
| 128 | + | let target = WEEKDAYS | |
| 129 | + | .iter() | |
| 130 | + | .position(|d| Some(d) == tokens.get(name_idx))? as i64; | |
| 109 | 131 | ||
| 110 | 132 | let today = now.date(); | |
| 111 | 133 | let current = today.weekday().num_days_from_sunday() as i64; | |
| @@ -124,7 +146,11 @@ fn parse_weekday(tokens: &[&str], now: NaiveDateTime, default_time: NaiveTime) - | |||
| 124 | 146 | } | |
| 125 | 147 | ||
| 126 | 148 | /// 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> { | |
| 149 | + | fn parse_month_day( | |
| 150 | + | tokens: &[&str], | |
| 151 | + | now: NaiveDateTime, | |
| 152 | + | default_time: NaiveTime, | |
| 153 | + | ) -> Option<NaiveDateTime> { | |
| 128 | 154 | if tokens.len() < 2 { | |
| 129 | 155 | return None; | |
| 130 | 156 | } | |
| @@ -209,9 +235,33 @@ mod tests { | |||
| 209 | 235 | ||
| 210 | 236 | #[test] | |
| 211 | 237 | 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())); | |
| 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 | + | ); | |
| 215 | 265 | } | |
| 216 | 266 | ||
| 217 | 267 | #[test] | |
| @@ -226,11 +276,21 @@ mod tests { | |||
| 226 | 276 | fn iso_datetime_preserves_wall_clock() { | |
| 227 | 277 | assert_eq!( | |
| 228 | 278 | parse("2026-12-25T15:30"), | |
| 229 | - | Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap()) | |
| 279 | + | Some( | |
| 280 | + | NaiveDate::from_ymd_opt(2026, 12, 25) | |
| 281 | + | .unwrap() | |
| 282 | + | .and_hms_opt(15, 30, 0) | |
| 283 | + | .unwrap() | |
| 284 | + | ) | |
| 230 | 285 | ); | |
| 231 | 286 | assert_eq!( | |
| 232 | 287 | parse("2026-12-25 15:30"), | |
| 233 | - | Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 30, 0).unwrap()) | |
| 288 | + | Some( | |
| 289 | + | NaiveDate::from_ymd_opt(2026, 12, 25) | |
| 290 | + | .unwrap() | |
| 291 | + | .and_hms_opt(15, 30, 0) | |
| 292 | + | .unwrap() | |
| 293 | + | ) | |
| 234 | 294 | ); | |
| 235 | 295 | } | |
| 236 | 296 | ||
| @@ -238,50 +298,140 @@ mod tests { | |||
| 238 | 298 | fn iso_date_defaults_to_nine_am() { | |
| 239 | 299 | assert_eq!( | |
| 240 | 300 | parse("2026-12-25"), | |
| 241 | - | Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(9, 0, 0).unwrap()) | |
| 301 | + | Some( | |
| 302 | + | NaiveDate::from_ymd_opt(2026, 12, 25) | |
| 303 | + | .unwrap() | |
| 304 | + | .and_hms_opt(9, 0, 0) | |
| 305 | + | .unwrap() | |
| 306 | + | ) | |
| 242 | 307 | ); | |
| 243 | 308 | assert_eq!( | |
| 244 | 309 | parse("2026-12-25 3pm"), | |
| 245 | - | Some(NaiveDate::from_ymd_opt(2026, 12, 25).unwrap().and_hms_opt(15, 0, 0).unwrap()) | |
| 310 | + | Some( | |
| 311 | + | NaiveDate::from_ymd_opt(2026, 12, 25) | |
| 312 | + | .unwrap() | |
| 313 | + | .and_hms_opt(15, 0, 0) | |
| 314 | + | .unwrap() | |
| 315 | + | ) | |
| 246 | 316 | ); | |
| 247 | 317 | } | |
| 248 | 318 | ||
| 249 | 319 | #[test] | |
| 250 | 320 | 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())); | |
| 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 | + | ); | |
| 253 | 339 | } | |
| 254 | 340 | ||
| 255 | 341 | #[test] | |
| 256 | 342 | fn next_week_is_next_monday() { | |
| 257 | 343 | // 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())); | |
| 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 | + | ); | |
| 259 | 353 | } | |
| 260 | 354 | ||
| 261 | 355 | #[test] | |
| 262 | 356 | fn weekday_finds_next_occurrence() { | |
| 263 | 357 | // 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())); | |
| 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 | + | ); | |
| 265 | 367 | // 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())); | |
| 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 | + | ); | |
| 267 | 377 | // "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())); | |
| 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 | + | ); | |
| 269 | 387 | } | |
| 270 | 388 | ||
| 271 | 389 | #[test] | |
| 272 | 390 | fn next_weekday_skips_an_extra_week() { | |
| 273 | 391 | // "next friday" skips the coming Friday (matches the prior JS quirk): | |
| 274 | 392 | // 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())); | |
| 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 | + | ); | |
| 276 | 402 | } | |
| 277 | 403 | ||
| 278 | 404 | #[test] | |
| 279 | 405 | fn month_day_rolls_to_next_year_when_past() { | |
| 280 | 406 | // 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())); | |
| 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 | + | ); | |
| 282 | 416 | // 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())); | |
| 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 | + | ); | |
| 285 | 435 | } | |
| 286 | 436 | ||
| 287 | 437 | #[test] |
| @@ -208,7 +208,10 @@ mod tests { | |||
| 208 | 208 | let dt = now - Duration::days(14); | |
| 209 | 209 | // Exact format depends on local timezone, but should contain month abbreviation | |
| 210 | 210 | let result = format_elapsed_time(dt, now); | |
| 211 | - | assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}"); | |
| 211 | + | assert!( | |
| 212 | + | result.contains("Apr") || result.contains("Mar"), | |
| 213 | + | "Expected month abbrev, got: {result}" | |
| 214 | + | ); | |
| 212 | 215 | } | |
| 213 | 216 | ||
| 214 | 217 | #[test] |
| @@ -48,7 +48,8 @@ pub fn detect_conflicts(items: &[TimelineItem]) -> Vec<Conflict> { | |||
| 48 | 48 | ||
| 49 | 49 | for item2 in items.iter().skip(i + 1) { | |
| 50 | 50 | let end2 = item2.end_time.unwrap_or_else(|| { | |
| 51 | - | item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64) | |
| 51 | + | item2.start_time | |
| 52 | + | + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64) | |
| 52 | 53 | }); | |
| 53 | 54 | ||
| 54 | 55 | // Standard interval overlap test: two intervals [s1,e1) and [s2,e2) | |
| @@ -103,10 +104,7 @@ mod tests { | |||
| 103 | 104 | ||
| 104 | 105 | #[test] | |
| 105 | 106 | fn test_direct_overlap() { | |
| 106 | - | let items = vec![ | |
| 107 | - | make_timeline_item(9, 0, 60), | |
| 108 | - | make_timeline_item(9, 30, 60), | |
| 109 | - | ]; | |
| 107 | + | let items = vec![make_timeline_item(9, 0, 60), make_timeline_item(9, 30, 60)]; | |
| 110 | 108 | let conflicts = detect_conflicts(&items); | |
| 111 | 109 | assert_eq!(conflicts.len(), 1); | |
| 112 | 110 | let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; | |
| @@ -115,10 +113,7 @@ mod tests { | |||
| 115 | 113 | ||
| 116 | 114 | #[test] | |
| 117 | 115 | fn test_complete_containment() { | |
| 118 | - | let items = vec![ | |
| 119 | - | make_timeline_item(9, 0, 120), | |
| 120 | - | make_timeline_item(9, 30, 30), | |
| 121 | - | ]; | |
| 116 | + | let items = vec![make_timeline_item(9, 0, 120), make_timeline_item(9, 30, 30)]; | |
| 122 | 117 | let conflicts = detect_conflicts(&items); | |
| 123 | 118 | assert_eq!(conflicts.len(), 1); | |
| 124 | 119 | let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; | |
| @@ -137,10 +132,7 @@ mod tests { | |||
| 137 | 132 | ||
| 138 | 133 | #[test] | |
| 139 | 134 | fn test_adjacent_no_conflict() { | |
| 140 | - | let items = vec![ | |
| 141 | - | make_timeline_item(9, 0, 60), | |
| 142 | - | make_timeline_item(10, 0, 60), | |
| 143 | - | ]; | |
| 135 | + | let items = vec![make_timeline_item(9, 0, 60), make_timeline_item(10, 0, 60)]; | |
| 144 | 136 | assert!(detect_conflicts(&items).is_empty()); | |
| 145 | 137 | } | |
| 146 | 138 |
| @@ -115,9 +115,18 @@ mod tests { | |||
| 115 | 115 | ||
| 116 | 116 | #[test] | |
| 117 | 117 | fn extract_handles_angle_brackets_and_plain() { | |
| 118 | - | assert_eq!(extract_email_address("Max <max@example.com>"), "max@example.com"); | |
| 119 | - | assert_eq!(extract_email_address(" plain@example.com "), "plain@example.com"); | |
| 120 | - | assert_eq!(extract_email_address("Name < spaced@example.com >"), "spaced@example.com"); | |
| 118 | + | assert_eq!( | |
| 119 | + | extract_email_address("Max <max@example.com>"), | |
| 120 | + | "max@example.com" | |
| 121 | + | ); | |
| 122 | + | assert_eq!( | |
| 123 | + | extract_email_address(" plain@example.com "), | |
| 124 | + | "plain@example.com" | |
| 125 | + | ); | |
| 126 | + | assert_eq!( | |
| 127 | + | extract_email_address("Name < spaced@example.com >"), | |
| 128 | + | "spaced@example.com" | |
| 129 | + | ); | |
| 121 | 130 | assert_eq!(extract_email_address(""), ""); | |
| 122 | 131 | } | |
| 123 | 132 | ||
| @@ -160,7 +169,10 @@ mod tests { | |||
| 160 | 169 | #[test] | |
| 161 | 170 | fn quoted_reply_body_prefixes_each_line() { | |
| 162 | 171 | let body = quoted_reply_body("Alice <a@x.com>", "Mar 1", "line1\nline2"); | |
| 163 | - | assert_eq!(body, "\n\nOn Mar 1, Alice <a@x.com> wrote:\n>\n> line1\n> line2"); | |
| 172 | + | assert_eq!( | |
| 173 | + | body, | |
| 174 | + | "\n\nOn Mar 1, Alice <a@x.com> wrote:\n>\n> line1\n> line2" | |
| 175 | + | ); | |
| 164 | 176 | } | |
| 165 | 177 | ||
| 166 | 178 | #[test] |
| @@ -9,8 +9,7 @@ use uuid::Uuid; | |||
| 9 | 9 | ||
| 10 | 10 | /// Fixed namespace UUID for GoingsOn email IDs (generated once, never changes). | |
| 11 | 11 | pub const GOINGSON_EMAIL_NS: Uuid = Uuid::from_bytes([ | |
| 12 | - | 0x7a, 0x3b, 0x8c, 0x2d, 0x4e, 0x5f, 0x6a, 0x1b, | |
| 13 | - | 0x9c, 0x0d, 0x8e, 0x7f, 0xa2, 0xb3, 0xc4, 0xd5, | |
| 12 | + | 0x7a, 0x3b, 0x8c, 0x2d, 0x4e, 0x5f, 0x6a, 0x1b, 0x9c, 0x0d, 0x8e, 0x7f, 0xa2, 0xb3, 0xc4, 0xd5, | |
| 14 | 13 | ]); | |
| 15 | 14 | ||
| 16 | 15 | /// Generate a deterministic email ID from a Message-ID header. |
| @@ -66,7 +66,7 @@ pub async fn process_fetched_emails( | |||
| 66 | 66 | for e in &mut emails { | |
| 67 | 67 | if e.message_id.is_none() { | |
| 68 | 68 | // Use SHA-256 for a stable hash across Rust versions (DefaultHasher is not stable). | |
| 69 | - | use sha2::{Sha256, Digest}; | |
| 69 | + | use sha2::{Digest, Sha256}; | |
| 70 | 70 | let mut h = Sha256::new(); | |
| 71 | 71 | h.update(e.from.as_bytes()); | |
| 72 | 72 | h.update(b"\x00"); | |
| @@ -76,19 +76,20 @@ pub async fn process_fetched_emails( | |||
| 76 | 76 | h.update(b"\x00"); | |
| 77 | 77 | h.update(e.date.to_rfc3339().as_bytes()); | |
| 78 | 78 | let hash = h.finalize(); | |
| 79 | - | e.message_id = Some(format!("synth-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", | |
| 80 | - | hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7])); | |
| 79 | + | e.message_id = Some(format!( | |
| 80 | + | "synth-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", | |
| 81 | + | hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7] | |
| 82 | + | )); | |
| 81 | 83 | } | |
| 82 | 84 | } | |
| 83 | 85 | ||
| 84 | 86 | // Batch check existing message IDs | |
| 85 | - | let msg_ids: Vec<&str> = emails.iter() | |
| 87 | + | let msg_ids: Vec<&str> = emails | |
| 88 | + | .iter() | |
| 86 | 89 | .filter_map(|e| e.message_id.as_deref()) | |
| 87 | 90 | .collect(); | |
| 88 | 91 | ||
| 89 | - | let existing_ids = email_repo | |
| 90 | - | .exists_by_message_ids(user_id, &msg_ids) | |
| 91 | - | .await?; | |
| 92 | + | let existing_ids = email_repo.exists_by_message_ids(user_id, &msg_ids).await?; | |
| 92 | 93 | ||
| 93 | 94 | // Collect new emails for batch insert, track reply-to IDs for waiting-status clearing | |
| 94 | 95 | let mut new_emails = Vec::new(); | |
| @@ -96,14 +97,17 @@ pub async fn process_fetched_emails( | |||
| 96 | 97 | ||
| 97 | 98 | for email in emails { | |
| 98 | 99 | if let Some(ref msg_id) = email.message_id | |
| 99 | - | && existing_ids.contains(msg_id) { | |
| 100 | - | continue; | |
| 101 | - | } | |
| 100 | + | && existing_ids.contains(msg_id) | |
| 101 | + | { | |
| 102 | + | continue; | |
| 103 | + | } | |
| 102 | 104 | ||
| 103 | 105 | // thread_id groups conversations: use in_reply_to if this is a reply, | |
| 104 | 106 | // otherwise fall back to message_id (starts a new thread). This means | |
| 105 | 107 | // the first email in a thread has thread_id == message_id. | |
| 106 | - | let thread_id = email.references_root.clone() | |
| 108 | + | let thread_id = email | |
| 109 | + | .references_root | |
| 110 | + | .clone() | |
| 107 | 111 | .or_else(|| email.in_reply_to.clone()) | |
| 108 | 112 | .or_else(|| email.message_id.clone()); | |
| 109 | 113 | ||
| @@ -135,15 +139,18 @@ pub async fn process_fetched_emails( | |||
| 135 | 139 | } | |
| 136 | 140 | ||
| 137 | 141 | // Batch insert — single transaction, no post-insert SELECTs | |
| 138 | - | result.emails_saved = email_repo.create_with_tracking_batch(user_id, new_emails).await?; | |
| 142 | + | result.emails_saved = email_repo | |
| 143 | + | .create_with_tracking_batch(user_id, new_emails) | |
| 144 | + | .await?; | |
| 139 | 145 | ||
| 140 | 146 | // Clear waiting status for emails that received replies | |
| 141 | 147 | for reply_to_msg_id in &reply_to_ids { | |
| 142 | 148 | if let Ok(Some(original)) = email_repo.get_by_message_id(user_id, reply_to_msg_id).await | |
| 143 | - | && original.waiting_for_response { | |
| 144 | - | let _ = email_repo.clear_waiting(original.id, user_id).await; | |
| 145 | - | result.waiting_cleared += 1; | |
| 146 | - | } | |
| 149 | + | && original.waiting_for_response | |
| 150 | + | { | |
| 151 | + | let _ = email_repo.clear_waiting(original.id, user_id).await; | |
| 152 | + | result.waiting_cleared += 1; | |
| 153 | + | } | |
| 147 | 154 | } | |
| 148 | 155 | ||
| 149 | 156 | Ok(result) |
| @@ -17,10 +17,7 @@ pub enum CoreError { | |||
| 17 | 17 | ||
| 18 | 18 | /// Resource not found with type and identifier context. | |
| 19 | 19 | #[error("Not found: {resource} with id {id}")] | |
| 20 | - | NotFound { | |
| 21 | - | resource: &'static str, | |
| 22 | - | id: String, | |
| 23 | - | }, | |
| 20 | + | NotFound { resource: &'static str, id: String }, | |
| 24 | 21 | ||
| 25 | 22 | /// Validation error with field and message context. | |
| 26 | 23 | #[error("Validation error: {field} - {message}")] |
| @@ -41,14 +41,14 @@ pub mod email_id; | |||
| 41 | 41 | pub mod email_sync; | |
| 42 | 42 | pub mod error; | |
| 43 | 43 | pub mod id_types; | |
| 44 | + | pub mod import; | |
| 44 | 45 | pub mod models; | |
| 46 | + | pub mod monthly_review; | |
| 45 | 47 | pub mod parser; | |
| 46 | - | pub mod import; | |
| 47 | 48 | pub mod recurrence; | |
| 48 | 49 | pub mod repository; | |
| 49 | 50 | pub mod search_parser; | |
| 50 | 51 | pub mod text_utils; | |
| 51 | - | pub mod monthly_review; | |
| 52 | 52 | pub mod urgency; | |
| 53 | 53 | pub mod validation; | |
| 54 | 54 | pub mod weekly_review; | |
| @@ -58,44 +58,45 @@ pub use contact::{ | |||
| 58 | 58 | ContactPhone, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, | |
| 59 | 59 | NewSocialHandle, SocialHandle, UpdateContact, merge_activity, | |
| 60 | 60 | }; | |
| 61 | + | pub use date_parser::parse_natural_date; | |
| 62 | + | pub use day_planning::{Conflict, TimelineItem, detect_conflicts}; | |
| 63 | + | pub use email_compose::{ | |
| 64 | + | ComposePrefill, forward_body, forward_subject, quoted_reply_body, reply_recipients, | |
| 65 | + | reply_subject, | |
| 66 | + | }; | |
| 67 | + | pub use email_id::deterministic_email_id; | |
| 61 | 68 | pub use error::CoreError; | |
| 62 | 69 | pub use id_types::{ | |
| 63 | 70 | AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, | |
| 64 | - | DailyNoteId, EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, | |
| 65 | - | MonthlyReflectionId, ProjectId, SavedViewId, SocialHandleId, | |
| 66 | - | StatusTokenId, SubtaskId, SyncAccountId, TaskId, TimeSessionId, UserId, WeeklyReviewId, | |
| 67 | - | }; | |
| 68 | - | pub use models::{ | |
| 69 | - | Annotation, Attachment, BackupSettings, BlockType, CssClass, DailyNote, DbValue, Email, EmailAccount, | |
| 70 | - | EmailAuthType, EmailThread, Event, FolderSyncState, Milestone, | |
| 71 | - | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, | |
| 72 | - | AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, | |
| 73 | - | NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, | |
| 74 | - | Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, MonthlySpec, | |
| 75 | - | SavedView, SortDirection, | |
| 76 | - | SyncAccount, | |
| 77 | - | PositiveMinutes, SortField, StatusToken, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession, | |
| 78 | - | TokenState, TOKEN_KIND_COMMIT, | |
| 79 | - | TimeSummaryPanel, TimeSummaryProject, TimeTrackingSummary, roll_up_time_summary, | |
| 80 | - | UpdateEvent, UpdateProject, UpdateTask, User, | |
| 81 | - | ViewFilters, ViewType, WeeklyReview, | |
| 82 | - | format_file_size, mime_from_extension, snap_all_day_span, | |
| 71 | + | DailyNoteId, EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId, MonthlyReflectionId, | |
| 72 | + | ProjectId, SavedViewId, SocialHandleId, StatusTokenId, SubtaskId, SyncAccountId, TaskId, | |
| 73 | + | TimeSessionId, UserId, WeeklyReviewId, | |
| 83 | 74 | }; | |
| 84 | - | pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult}; | |
| 85 | - | pub use date_parser::parse_natural_date; | |
| 86 | - | pub use day_planning::{Conflict, TimelineItem, detect_conflicts}; | |
| 87 | - | pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur}; | |
| 88 | - | pub use repository::*; | |
| 89 | - | pub use urgency::calculate_urgency; | |
| 90 | 75 | pub use import::{ | |
| 91 | 76 | ImportEntityType, ImportEventData, ImportExecuteResult, ImportFailure, ImportItem, | |
| 92 | 77 | ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData, | |
| 93 | 78 | }; | |
| 94 | - | pub use email_id::deterministic_email_id; | |
| 95 | - | pub use email_compose::{ | |
| 96 | - | forward_body, forward_subject, quoted_reply_body, reply_recipients, reply_subject, | |
| 97 | - | ComposePrefill, | |
| 79 | + | pub use models::{ | |
| 80 | + | Annotation, Attachment, AttachmentMeta, BackupSettings, BlockType, CssClass, DailyNote, | |
| 81 | + | DbValue, Email, EmailAccount, EmailAuthType, EmailThread, Event, FolderSyncState, Milestone, | |
| 82 | + | MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection, MonthlySpec, NewAttachment, | |
| 83 | + | NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, NewMilestone, | |
| 84 | + | NewProject, NewSavedView, NewTask, NewTaskBuilder, ParseableEnum, PositiveMinutes, Priority, | |
| 85 | + | Project, ProjectStatus, ProjectType, Recurrence, RecurrenceRule, SavedView, SortDirection, | |
| 86 | + | SortField, StatusToken, Subtask, SyncAccount, TOKEN_KIND_COMMIT, Task, TaskFilterQuery, | |
| 87 | + | TaskSortColumn, TaskStatus, TimeSession, TimeSummaryPanel, TimeSummaryProject, | |
| 88 | + | TimeTrackingSummary, TokenState, UpdateEvent, UpdateProject, UpdateTask, User, ViewFilters, | |
| 89 | + | ViewType, WeeklyReview, format_file_size, mime_from_extension, roll_up_time_summary, | |
| 90 | + | snap_all_day_span, | |
| 91 | + | }; | |
| 92 | + | pub use parser::{ParseResult, ParsedTask, parse_quick_add, parse_quick_add_with_warnings}; | |
| 93 | + | pub use recurrence::{ | |
| 94 | + | calculate_next_due, calculate_next_due_in_tz, calculate_next_due_rich, | |
| 95 | + | calculate_next_due_rich_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, | |
| 96 | + | expand_recurrence, expand_recurrence_in_tz, should_recur, | |
| 98 | 97 | }; | |
| 98 | + | pub use repository::*; | |
| 99 | + | pub use urgency::calculate_urgency; | |
| 99 | 100 | pub use validation::Validate; | |
| 100 | 101 | ||
| 101 | 102 | /// A specialized `Result` type for core operations. |
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | //! File attachment domain types. | |
| 2 | 2 | ||
| 3 | + | use crate::id_types::{AttachmentId, EmailId, ProjectId, TaskId, UserId}; | |
| 3 | 4 | use chrono::{DateTime, Utc}; | |
| 4 | 5 | use serde::{Deserialize, Serialize}; | |
| 5 | - | use crate::id_types::{AttachmentId, EmailId, ProjectId, TaskId, UserId}; | |
| 6 | 6 | ||
| 7 | 7 | /// A file attachment linked to a task or project. | |
| 8 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| @@ -43,7 +43,11 @@ pub struct AttachmentMeta { | |||
| 43 | 43 | ||
| 44 | 44 | /// Detect MIME type from file extension. | |
| 45 | 45 | pub fn mime_from_extension(filename: &str) -> &'static str { | |
| 46 | - | let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); | |
| 46 | + | let ext = filename | |
| 47 | + | .rsplit('.') | |
| 48 | + | .next() | |
| 49 | + | .unwrap_or("") | |
| 50 | + | .to_ascii_lowercase(); | |
| 47 | 51 | match ext.as_str() { | |
| 48 | 52 | "pdf" => "application/pdf", | |
| 49 | 53 | "doc" | "docx" => "application/msword", | |
| @@ -129,7 +133,10 @@ mod tests { | |||
| 129 | 133 | ||
| 130 | 134 | #[test] | |
| 131 | 135 | fn mime_from_dotfile() { | |
| 132 | - | assert_eq!(mime_from_extension(".gitignore"), "application/octet-stream"); | |
| 136 | + | assert_eq!( | |
| 137 | + | mime_from_extension(".gitignore"), | |
| 138 | + | "application/octet-stream" | |
| 139 | + | ); | |
| 133 | 140 | } | |
| 134 | 141 | ||
| 135 | 142 | #[test] |
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | //! Daily review note domain types. | |
| 2 | 2 | ||
| 3 | + | use crate::id_types::{DailyNoteId, UserId}; | |
| 3 | 4 | use chrono::{DateTime, NaiveDate, Utc}; | |
| 4 | 5 | use serde::{Deserialize, Serialize}; | |
| 5 | - | use crate::id_types::{DailyNoteId, UserId}; | |
| 6 | 6 | ||
| 7 | 7 | /// A daily review note capturing end-of-day reflection. | |
| 8 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| @@ -6,10 +6,10 @@ | |||
| 6 | 6 | //! project linking, snoozing, and waiting-for-response tracking. JMAP-style | |
| 7 | 7 | //! thread aggregation is available via `EmailThread` for efficient list rendering. | |
| 8 | 8 | ||
| 9 | + | use crate::constants::{DAYS_THRESHOLD_SHORT_FORMAT, EMAIL_BODY_PREVIEW_LENGTH}; | |
| 10 | + | use crate::id_types::{EmailAccountId, EmailId, ProjectId}; | |
| 9 | 11 | use chrono::{DateTime, Utc}; | |
| 10 | 12 | use serde::{Deserialize, Serialize}; | |
| 11 | - | use crate::constants::{DAYS_THRESHOLD_SHORT_FORMAT, EMAIL_BODY_PREVIEW_LENGTH}; | |
| 12 | - | use crate::id_types::{EmailId, ProjectId, EmailAccountId}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Email ============ | |
| 15 | 15 | ||
| @@ -156,7 +156,8 @@ impl Email { | |||
| 156 | 156 | /// Returns true if the email is waiting and the expected response date has passed. | |
| 157 | 157 | pub fn is_response_overdue(&self) -> bool { | |
| 158 | 158 | self.waiting_for_response | |
| 159 | - | && self.expected_response_date | |
| 159 | + | && self | |
| 160 | + | .expected_response_date | |
| 160 | 161 | .map(|date| date < Utc::now()) | |
| 161 | 162 | .unwrap_or(false) | |
| 162 | 163 | } |
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | //! Email account domain types. | |
| 2 | 2 | ||
| 3 | + | use crate::id_types::{EmailAccountId, UserId}; | |
| 3 | 4 | use chrono::{DateTime, Utc}; | |
| 4 | 5 | use serde::{Deserialize, Serialize}; | |
| 5 | 6 | use strum_macros::EnumString; | |
| 6 | - | use crate::id_types::{EmailAccountId, UserId}; | |
| 7 | 7 | ||
| 8 | 8 | use super::shared::DbValue; | |
| 9 | 9 | ||
| @@ -232,9 +232,15 @@ mod tests { | |||
| 232 | 232 | ||
| 233 | 233 | #[test] | |
| 234 | 234 | fn provider_id_oauth_variants() { | |
| 235 | - | assert_eq!(EmailAuthType::OAuth2Fastmail.provider_id(), Some("fastmail")); | |
| 235 | + | assert_eq!( | |
| 236 | + | EmailAuthType::OAuth2Fastmail.provider_id(), | |
| 237 | + | Some("fastmail") | |
| 238 | + | ); | |
| 236 | 239 | assert_eq!(EmailAuthType::OAuth2Google.provider_id(), Some("google")); | |
| 237 | - | assert_eq!(EmailAuthType::OAuth2Microsoft.provider_id(), Some("microsoft")); | |
| 240 | + | assert_eq!( | |
| 241 | + | EmailAuthType::OAuth2Microsoft.provider_id(), | |
| 242 | + | Some("microsoft") | |
| 243 | + | ); | |
| 238 | 244 | assert_eq!(EmailAuthType::OAuth2Yahoo.provider_id(), Some("yahoo")); | |
| 239 | 245 | } | |
| 240 | 246 |
| @@ -5,11 +5,11 @@ | |||
| 5 | 5 | //! optional project and contact associations, and block-type classification | |
| 6 | 6 | //! (focus, meeting, break, etc.). | |
| 7 | 7 | ||
| 8 | + | use super::shared::{BlockType, Recurrence, RecurrenceRule}; | |
| 9 | + | use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT; | |
| 10 | + | use crate::id_types::{ContactId, EventId, ProjectId, TaskId, UserId}; | |
| 8 | 11 | use chrono::{DateTime, Duration, TimeZone, Timelike, Utc}; | |
| 9 | 12 | use serde::{Deserialize, Serialize}; | |
| 10 | - | use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT; | |
| 11 | - | use crate::id_types::{EventId, UserId, ProjectId, ContactId, TaskId}; | |
| 12 | - | use super::shared::{BlockType, Recurrence, RecurrenceRule}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Event ============ | |
| 15 | 15 | ||
| @@ -206,7 +206,10 @@ pub fn snap_all_day_span<Tz: TimeZone>( | |||
| 206 | 206 | } | |
| 207 | 207 | } | |
| 208 | 208 | }; | |
| 209 | - | (local_midnight_utc(start_day, tz), local_midnight_utc(end_exclusive, tz)) | |
| 209 | + | ( | |
| 210 | + | local_midnight_utc(start_day, tz), | |
| 211 | + | local_midnight_utc(end_exclusive, tz), | |
| 212 | + | ) | |
| 210 | 213 | } | |
| 211 | 214 | ||
| 212 | 215 | /// Convert a calendar date's local midnight in `tz` to the corresponding UTC instant. |
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | //! Milestone domain types and DTOs. | |
| 2 | 2 | ||
| 3 | + | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 4 | + | use crate::id_types::{MilestoneId, ProjectId, UserId}; | |
| 3 | 5 | use chrono::{DateTime, Utc}; | |
| 4 | 6 | use serde::{Deserialize, Serialize}; | |
| 5 | 7 | use strum_macros::EnumString; | |
| 6 | - | use crate::id_types::{MilestoneId, UserId, ProjectId}; | |
| 7 | - | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 8 | 8 | ||
| 9 | 9 | // ============ Milestones ============ | |
| 10 | 10 | ||
| @@ -32,7 +32,6 @@ impl MilestoneStatus { | |||
| 32 | 32 | MilestoneStatus::Completed => "Completed", | |
| 33 | 33 | } | |
| 34 | 34 | } | |
| 35 | - | ||
| 36 | 35 | } | |
| 37 | 36 | ||
| 38 | 37 | impl ParseableEnum for MilestoneStatus {} |
| @@ -7,6 +7,7 @@ mod email; | |||
| 7 | 7 | mod email_account; | |
| 8 | 8 | mod event; | |
| 9 | 9 | mod milestone; | |
| 10 | + | mod monthly_review; | |
| 10 | 11 | mod project; | |
| 11 | 12 | mod saved_view; | |
| 12 | 13 | mod shared; | |
| @@ -14,7 +15,6 @@ mod sync_account; | |||
| 14 | 15 | mod task; | |
| 15 | 16 | mod time_session; | |
| 16 | 17 | mod user; | |
| 17 | - | mod monthly_review; | |
| 18 | 18 | mod weekly_review; | |
| 19 | 19 | ||
| 20 | 20 | pub use attachment::*; |
| @@ -1,70 +1,70 @@ | |||
| 1 | - | //! Monthly review domain types. | |
| 2 | - | ||
| 3 | - | use chrono::{DateTime, Utc}; | |
| 4 | - | use serde::{Deserialize, Serialize}; | |
| 5 | - | use crate::id_types::{MonthlyGoalId, MonthlyReflectionId, UserId}; | |
| 6 | - | ||
| 7 | - | // ============ Monthly Goal ============ | |
| 8 | - | ||
| 9 | - | /// Status of a monthly goal. | |
| 10 | - | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 11 | - | #[serde(rename_all = "lowercase")] | |
| 12 | - | pub enum MonthlyGoalStatus { | |
| 13 | - | Active, | |
| 14 | - | Done, | |
| 15 | - | Abandoned, | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | impl MonthlyGoalStatus { | |
| 19 | - | pub fn as_str(&self) -> &str { | |
| 20 | - | match self { | |
| 21 | - | Self::Active => "active", | |
| 22 | - | Self::Done => "done", | |
| 23 | - | Self::Abandoned => "abandoned", | |
| 24 | - | } | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | impl std::str::FromStr for MonthlyGoalStatus { | |
| 29 | - | type Err = crate::CoreError; | |
| 30 | - | ||
| 31 | - | fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { | |
| 32 | - | match s { | |
| 33 | - | "active" => Ok(Self::Active), | |
| 34 | - | "done" => Ok(Self::Done), | |
| 35 | - | "abandoned" => Ok(Self::Abandoned), | |
| 36 | - | _ => Err(crate::CoreError::parse(format!("Invalid goal status: {s}"))), | |
| 37 | - | } | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | /// A monthly goal — free-text item set at the start of each month. | |
| 42 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 43 | - | #[serde(rename_all = "camelCase")] | |
| 44 | - | pub struct MonthlyGoal { | |
| 45 | - | pub id: MonthlyGoalId, | |
| 46 | - | pub user_id: UserId, | |
| 47 | - | /// Month in YYYY-MM format. | |
| 48 | - | pub month: String, | |
| 49 | - | pub text: String, | |
| 50 | - | pub status: MonthlyGoalStatus, | |
| 51 | - | /// Position 1-3. | |
| 52 | - | pub position: i32, | |
| 53 | - | pub created_at: DateTime<Utc>, | |
| 54 | - | pub updated_at: DateTime<Utc>, | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // ============ Monthly Reflection ============ | |
| 58 | - | ||
| 59 | - | /// A monthly reflection capturing highlights and areas for improvement. | |
| 60 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 61 | - | #[serde(rename_all = "camelCase")] | |
| 62 | - | pub struct MonthlyReflection { | |
| 63 | - | pub id: MonthlyReflectionId, | |
| 64 | - | pub user_id: UserId, | |
| 65 | - | /// Month in YYYY-MM format. | |
| 66 | - | pub month: String, | |
| 67 | - | pub highlight_text: String, | |
| 68 | - | pub change_text: String, | |
| 69 | - | pub completed_at: DateTime<Utc>, | |
| 70 | - | } | |
| 1 | + | //! Monthly review domain types. | |
| 2 | + | ||
| 3 | + | use crate::id_types::{MonthlyGoalId, MonthlyReflectionId, UserId}; | |
| 4 | + | use chrono::{DateTime, Utc}; | |
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | // ============ Monthly Goal ============ | |
| 8 | + | ||
| 9 | + | /// Status of a monthly goal. | |
| 10 | + | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 11 | + | #[serde(rename_all = "lowercase")] | |
| 12 | + | pub enum MonthlyGoalStatus { | |
| 13 | + | Active, | |
| 14 | + | Done, | |
| 15 | + | Abandoned, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl MonthlyGoalStatus { | |
| 19 | + | pub fn as_str(&self) -> &str { | |
| 20 | + | match self { | |
| 21 | + | Self::Active => "active", | |
| 22 | + | Self::Done => "done", | |
| 23 | + | Self::Abandoned => "abandoned", | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl std::str::FromStr for MonthlyGoalStatus { | |
| 29 | + | type Err = crate::CoreError; | |
| 30 | + | ||
| 31 | + | fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { | |
| 32 | + | match s { | |
| 33 | + | "active" => Ok(Self::Active), | |
| 34 | + | "done" => Ok(Self::Done), | |
| 35 | + | "abandoned" => Ok(Self::Abandoned), | |
| 36 | + | _ => Err(crate::CoreError::parse(format!("Invalid goal status: {s}"))), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A monthly goal — free-text item set at the start of each month. | |
| 42 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 43 | + | #[serde(rename_all = "camelCase")] | |
| 44 | + | pub struct MonthlyGoal { | |
| 45 | + | pub id: MonthlyGoalId, | |
| 46 | + | pub user_id: UserId, | |
| 47 | + | /// Month in YYYY-MM format. | |
| 48 | + | pub month: String, | |
| 49 | + | pub text: String, | |
| 50 | + | pub status: MonthlyGoalStatus, | |
| 51 | + | /// Position 1-3. | |
| 52 | + | pub position: i32, | |
| 53 | + | pub created_at: DateTime<Utc>, | |
| 54 | + | pub updated_at: DateTime<Utc>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // ============ Monthly Reflection ============ | |
| 58 | + | ||
| 59 | + | /// A monthly reflection capturing highlights and areas for improvement. | |
| 60 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 61 | + | #[serde(rename_all = "camelCase")] | |
| 62 | + | pub struct MonthlyReflection { | |
| 63 | + | pub id: MonthlyReflectionId, | |
| 64 | + | pub user_id: UserId, | |
| 65 | + | /// Month in YYYY-MM format. | |
| 66 | + | pub month: String, | |
| 67 | + | pub highlight_text: String, | |
| 68 | + | pub change_text: String, | |
| 69 | + | pub completed_at: DateTime<Utc>, | |
| 70 | + | } |
| @@ -5,11 +5,11 @@ | |||
| 5 | 5 | //! Article, Painting, Other) and a lifecycle status (Active, OnHold, Completed, | |
| 6 | 6 | //! Archived). Projects can contain milestones for phased tracking. | |
| 7 | 7 | ||
| 8 | + | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 9 | + | use crate::id_types::ProjectId; | |
| 8 | 10 | use chrono::{DateTime, Utc}; | |
| 9 | 11 | use serde::{Deserialize, Serialize}; | |
| 10 | 12 | use strum_macros::EnumString; | |
| 11 | - | use crate::id_types::ProjectId; | |
| 12 | - | use super::shared::{CssClass, DbValue, ParseableEnum}; | |
| 13 | 13 | ||
| 14 | 14 | // ============ Project Types ============ | |
| 15 | 15 | ||
| @@ -53,7 +53,6 @@ impl ProjectType { | |||
| 53 | 53 | ProjectType::Other => "Other", | |
| 54 | 54 | } | |
| 55 | 55 | } | |
| 56 | - | ||
| 57 | 56 | } | |
| 58 | 57 | ||
| 59 | 58 | impl ParseableEnum for ProjectType {} | |
| @@ -117,7 +116,6 @@ impl ProjectStatus { | |||
| 117 | 116 | ProjectStatus::Archived => "Archived", | |
| 118 | 117 | } | |
| 119 | 118 | } | |
| 120 | - | ||
| 121 | 119 | } | |
| 122 | 120 | ||
| 123 | 121 | impl ParseableEnum for ProjectStatus {} | |
| @@ -218,8 +216,14 @@ mod tests { | |||
| 218 | 216 | ||
| 219 | 217 | #[test] | |
| 220 | 218 | fn unknown_project_enum_strings_fall_back_to_default() { | |
| 221 | - | assert_eq!(ProjectType::from_str_or_default("bogus"), ProjectType::Other); | |
| 222 | - | assert_eq!(ProjectStatus::from_str_or_default("bogus"), ProjectStatus::Active); | |
| 219 | + | assert_eq!( | |
| 220 | + | ProjectType::from_str_or_default("bogus"), | |
| 221 | + | ProjectType::Other | |
| 222 | + | ); | |
| 223 | + | assert_eq!( | |
| 224 | + | ProjectStatus::from_str_or_default("bogus"), | |
| 225 | + | ProjectStatus::Active | |
| 226 | + | ); | |
| 223 | 227 | } | |
| 224 | 228 | ||
| 225 | 229 | #[test] |
| @@ -4,10 +4,10 @@ | |||
| 4 | 4 | //! to the sidebar for quick access. Each view targets a specific domain | |
| 5 | 5 | //! (tasks, emails, or events) and stores its filter criteria as JSON. | |
| 6 | 6 | ||
| 7 | + | use crate::id_types::{ProjectId, SavedViewId, UserId}; | |
| 7 | 8 | use chrono::{DateTime, Utc}; | |
| 8 | 9 | use serde::{Deserialize, Serialize}; | |
| 9 | 10 | use strum_macros::EnumString; | |
| 10 | - | use crate::id_types::{SavedViewId, UserId, ProjectId}; | |
| 11 | 11 | ||
| 12 | 12 | use super::shared::{DbValue, ParseableEnum, SortDirection}; | |
| 13 | 13 | ||
| @@ -38,7 +38,6 @@ impl ViewType { | |||
| 38 | 38 | ViewType::Events => "Events", | |
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | - | ||
| 42 | 41 | } | |
| 43 | 42 | ||
| 44 | 43 | impl ParseableEnum for ViewType {} |