max / goingson
7 files changed,
+318 insertions,
-23 deletions
| @@ -391,6 +391,16 @@ | |||
| 391 | 391 | None => calculate_next_due_in_tz(Some(&anchor), &task.recurrence, tz), | |
| 392 | 392 | }; | |
| 393 | 393 | ||
| 394 | + | // A bounded series ends by opening no successor: the instance that was just | |
| 395 | + | // completed was the last one. `until` is inclusive, so a successor landing | |
| 396 | + | // exactly on it still opens. A task with no due date anchored to `now` | |
| 397 | + | // above, so the same comparison covers it. | |
| 398 | + | if let Some(until) = task.recurrence_rule.as_ref().and_then(|r| r.until) | |
| 399 | + | && next_due.is_none_or(|due| due > until) | |
| 400 | + | { | |
| 401 | + | return None; | |
| 402 | + | } | |
| 403 | + | ||
| 394 | 404 | // Urgency is recomputed rather than copied: it scores age and distance to | |
| 395 | 405 | // the due date, both of which just moved. | |
| 396 | 406 | let urgency = crate::urgency::calculate_urgency( | |
| @@ -463,9 +473,15 @@ | |||
| 463 | 473 | // start_time is >500 days before range_start burns all 500 iterations on | |
| 464 | 474 | // occurrences long before the window and renders empty. The seek is cheap | |
| 465 | 475 | // (no clone/push) and separately capped so a degenerate rule can't spin. | |
| 476 | + | // A bounded series stops here, inclusive of an occurrence landing exactly on | |
| 477 | + | // `until`. It narrows both walks; the caps below stay as the guard against a | |
| 478 | + | // degenerate rule, since most rules carry no end at all. | |
| 479 | + | let until = rule.until; | |
| 480 | + | let past_end = |cursor: DateTime<Utc>| until.is_some_and(|until| cursor > until); | |
| 481 | + | ||
| 466 | 482 | let seek_cap = 100_000; | |
| 467 | 483 | let mut seeked = 0; | |
| 468 | - | while cursor + event_duration < range_start && seeked < seek_cap { | |
| 484 | + | while cursor + event_duration < range_start && seeked < seek_cap && !past_end(cursor) { | |
| 469 | 485 | match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) { | |
| 470 | 486 | Some(next) if next > cursor => cursor = next, | |
| 471 | 487 | _ => break, | |
| @@ -476,7 +492,7 @@ | |||
| 476 | 492 | let max_iterations = 500; | |
| 477 | 493 | ||
| 478 | 494 | for _ in 0..max_iterations { | |
| 479 | - | if cursor > range_end { | |
| 495 | + | if cursor > range_end || past_end(cursor) { | |
| 480 | 496 | break; | |
| 481 | 497 | } | |
| 482 | 498 | ||
| @@ -848,6 +864,7 @@ | |||
| 848 | 864 | interval: 3, | |
| 849 | 865 | weekdays: vec![], | |
| 850 | 866 | monthly_spec: None, | |
| 867 | + | until: None, | |
| 851 | 868 | }; | |
| 852 | 869 | let next = calculate_next_due_rich(Some(&now), &rule).unwrap(); | |
| 853 | 870 | assert_eq!(next.day(), 4); // 3 days later | |
| @@ -863,6 +880,7 @@ | |||
| 863 | 880 | interval: 1, | |
| 864 | 881 | weekdays: vec![0, 2, 4], // Mon, Wed, Fri | |
| 865 | 882 | monthly_spec: None, | |
| 883 | + | until: None, | |
| 866 | 884 | }; | |
| 867 | 885 | // Next after Monday should be Wednesday | |
| 868 | 886 | let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); | |
| @@ -890,6 +908,7 @@ | |||
| 890 | 908 | interval: 1, | |
| 891 | 909 | weekdays: vec![200, 2], // garbage + Wed | |
| 892 | 910 | monthly_spec: None, | |
| 911 | + | until: None, | |
| 893 | 912 | }; | |
| 894 | 913 | let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); | |
| 895 | 914 | assert_eq!(next.weekday(), chrono::Weekday::Wed); | |
| @@ -909,6 +928,7 @@ | |||
| 909 | 928 | interval: 1, | |
| 910 | 929 | weekdays: vec![99, 200], | |
| 911 | 930 | monthly_spec: None, | |
| 931 | + | until: None, | |
| 912 | 932 | }; | |
| 913 | 933 | let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); | |
| 914 | 934 | assert_eq!((next - mon).num_days(), 7); | |
| @@ -923,6 +943,7 @@ | |||
| 923 | 943 | interval: u32::MAX, | |
| 924 | 944 | weekdays: vec![], | |
| 925 | 945 | monthly_spec: None, | |
| 946 | + | until: None, | |
| 926 | 947 | }; | |
| 927 | 948 | // Clamped to 10_000 days; just assert it produces a finite future date. | |
| 928 | 949 | let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); | |
| @@ -938,6 +959,7 @@ | |||
| 938 | 959 | interval: 2, | |
| 939 | 960 | weekdays: vec![0, 4], // Mon, Fri | |
| 940 | 961 | monthly_spec: None, | |
| 962 | + | until: None, | |
| 941 | 963 | }; | |
| 942 | 964 | // Next after Friday: wrap to Mon of 2-weeks-later | |
| 943 | 965 | let next = calculate_next_due_rich(Some(&fri), &rule).unwrap(); | |
| @@ -953,6 +975,7 @@ | |||
| 953 | 975 | interval: 1, | |
| 954 | 976 | weekdays: vec![], | |
| 955 | 977 | monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }), | |
| 978 | + | until: None, | |
| 956 | 979 | }; | |
| 957 | 980 | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 958 | 981 | assert_eq!(next.month(), 2); | |
| @@ -972,6 +995,7 @@ | |||
| 972 | 995 | week: 2, | |
| 973 | 996 | weekday: 4, | |
| 974 | 997 | }), // 2nd Friday | |
| 998 | + | until: None, | |
| 975 | 999 | }; | |
| 976 | 1000 | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 977 | 1001 | // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri) | |
| @@ -990,6 +1014,7 @@ | |||
| 990 | 1014 | week: -1, | |
| 991 | 1015 | weekday: 0, | |
| 992 | 1016 | }), // Last Monday | |
| 1017 | + | until: None, | |
| 993 | 1018 | }; | |
| 994 | 1019 | let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); | |
| 995 | 1020 | // Feb 2026: last Monday is Feb 23 | |
| @@ -1006,6 +1031,7 @@ | |||
| 1006 | 1031 | interval: 1, | |
| 1007 | 1032 | weekdays: vec![], | |
| 1008 | 1033 | monthly_spec: Some(MonthlySpec::NthWeekday { week, weekday: 4 }), | |
| 1034 | + | until: None, | |
| 1009 | 1035 | }; | |
| 1010 | 1036 | // Previously this silently yielded the un-adjusted base (Feb 9). | |
| 1011 | 1037 | assert_eq!( | |
| @@ -1027,6 +1053,7 @@ | |||
| 1027 | 1053 | week: 2, | |
| 1028 | 1054 | weekday: 200, | |
| 1029 | 1055 | }), | |
| 1056 | + | until: None, | |
| 1030 | 1057 | }; | |
| 1031 | 1058 | assert_eq!(calculate_next_due_rich(Some(&jan), &rule), None); | |
| 1032 | 1059 | } | |
| @@ -1045,15 +1072,17 @@ | |||
| 1045 | 1072 | week: 5, | |
| 1046 | 1073 | weekday: 0, | |
| 1047 | 1074 | }), | |
| 1075 | + | until: None, | |
| 1048 | 1076 | }; | |
| 1049 | 1077 | let next = calculate_next_due_rich(Some(&mar), &rule).unwrap(); | |
| 1050 | 1078 | assert_eq!((next.month(), next.day()), (6, 29)); | |
| 1051 | 1079 | } | |
| 1052 | 1080 | ||
| 1053 | - | #[test] | |
| 1054 | - | fn test_expand_recurrence_weekly() { | |
| 1055 | - | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday | |
| 1056 | - | let event = Event { | |
| 1081 | + | /// A one-hour weekly event, optionally bounded. Written as a helper because | |
| 1082 | + | /// the `Event` literal is 25 fields and every expansion test wants the same | |
| 1083 | + | /// one; a new field on `Event` should not mean editing four tests. | |
| 1084 | + | fn weekly_event(start: DateTime<Utc>, until: Option<DateTime<Utc>>) -> Event { | |
| 1085 | + | Event { | |
| 1057 | 1086 | id: crate::id_types::EventId::new(), | |
| 1058 | 1087 | user_id: None, | |
| 1059 | 1088 | project_id: None, | |
| @@ -1072,6 +1101,7 @@ | |||
| 1072 | 1101 | interval: 1, | |
| 1073 | 1102 | weekdays: vec![], | |
| 1074 | 1103 | monthly_spec: None, | |
| 1104 | + | until, | |
| 1075 | 1105 | }), | |
| 1076 | 1106 | recurrence_parent_id: None, | |
| 1077 | 1107 | is_recurring_instance: false, | |
| @@ -1085,7 +1115,13 @@ | |||
| 1085 | 1115 | timezone: None, | |
| 1086 | 1116 | start_local: None, | |
| 1087 | 1117 | end_local: None, | |
| 1088 | - | }; | |
| 1118 | + | } | |
| 1119 | + | } | |
| 1120 | + | ||
| 1121 | + | #[test] | |
| 1122 | + | fn test_expand_recurrence_weekly() { | |
| 1123 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday | |
| 1124 | + | let event = weekly_event(start, None); | |
| 1089 | 1125 | ||
| 1090 | 1126 | let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); | |
| 1091 | 1127 | let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(); | |
| @@ -1131,6 +1167,7 @@ | |||
| 1131 | 1167 | interval: 1, | |
| 1132 | 1168 | weekdays: vec![], | |
| 1133 | 1169 | monthly_spec: None, | |
| 1170 | + | until: None, | |
| 1134 | 1171 | }), | |
| 1135 | 1172 | recurrence_parent_id: None, | |
| 1136 | 1173 | is_recurring_instance: false, | |
| @@ -1282,6 +1319,133 @@ | |||
| 1282 | 1319 | assert_eq!(next_local.hour(), 9); | |
| 1283 | 1320 | } | |
| 1284 | 1321 | ||
| 1322 | + | // A bounded series: `until` ends it, inclusive of an occurrence landing | |
| 1323 | + | // exactly on the boundary. | |
| 1324 | + | ||
| 1325 | + | #[test] | |
| 1326 | + | fn until_includes_an_occurrence_landing_exactly_on_it() { | |
| 1327 | + | // Mon Mar 2, weekly, bounded at Mar 23 10:00 — the fourth occurrence to | |
| 1328 | + | // the minute. Mar 9, 16 and 23 expand; Mar 30 does not. | |
| 1329 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); | |
| 1330 | + | let until = Utc.with_ymd_and_hms(2026, 3, 23, 10, 0, 0).unwrap(); | |
| 1331 | + | let event = weekly_event(start, Some(until)); | |
| 1332 | + | ||
| 1333 | + | let instances = expand_recurrence( | |
| 1334 | + | &event, | |
| 1335 | + | Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), | |
| 1336 | + | Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), | |
| 1337 | + | ); | |
| 1338 | + | ||
| 1339 | + | let days: Vec<u32> = instances.iter().map(|e| e.start_time.day()).collect(); | |
| 1340 | + | assert_eq!(days, vec![9, 16, 23]); | |
| 1341 | + | } | |
| 1342 | + | ||
| 1343 | + | #[test] | |
| 1344 | + | fn an_occurrence_one_second_past_until_is_excluded() { | |
| 1345 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); | |
| 1346 | + | // One second before the Mar 23 occurrence, so only Mar 9 and 16 survive. | |
| 1347 | + | let until = Utc.with_ymd_and_hms(2026, 3, 23, 9, 59, 59).unwrap(); | |
| 1348 | + | let event = weekly_event(start, Some(until)); | |
| 1349 | + | ||
| 1350 | + | let instances = expand_recurrence( | |
| 1351 | + | &event, | |
| 1352 | + | Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), | |
| 1353 | + | Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), | |
| 1354 | + | ); | |
| 1355 | + | ||
| 1356 | + | let days: Vec<u32> = instances.iter().map(|e| e.start_time.day()).collect(); | |
| 1357 | + | assert_eq!(days, vec![9, 16]); | |
| 1358 | + | } | |
| 1359 | + | ||
| 1360 | + | #[test] | |
| 1361 | + | fn until_before_the_first_occurrence_expands_to_nothing() { | |
| 1362 | + | // A rule that ends before it begins is a caller mistake the wire layer | |
| 1363 | + | // cannot catch (it never sees the start_time), so the honest reading is | |
| 1364 | + | // an empty series rather than an infinite one. | |
| 1365 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); | |
| 1366 | + | let until = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap(); | |
| 1367 | + | let event = weekly_event(start, Some(until)); | |
| 1368 | + | ||
| 1369 | + | let instances = expand_recurrence( | |
| 1370 | + | &event, | |
| 1371 | + | Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), | |
| 1372 | + | Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), | |
| 1373 | + | ); | |
| 1374 | + | ||
| 1375 | + | assert!(instances.is_empty()); | |
| 1376 | + | } | |
| 1377 | + | ||
| 1378 | + | #[test] | |
| 1379 | + | fn until_stops_the_seek_rather_than_burning_the_budget() { | |
| 1380 | + | // The window opens years after a bounded series closed. The seek loop | |
| 1381 | + | // has to notice `until` too: without the check it walks toward | |
| 1382 | + | // range_start one occurrence at a time, up to the 100k seek cap, and | |
| 1383 | + | // then renders empty by accident rather than on purpose. | |
| 1384 | + | let start = Utc.with_ymd_and_hms(2020, 1, 6, 10, 0, 0).unwrap(); | |
| 1385 | + | let until = Utc.with_ymd_and_hms(2020, 3, 2, 10, 0, 0).unwrap(); | |
| 1386 | + | let event = weekly_event(start, Some(until)); | |
| 1387 | + | ||
| 1388 | + | let instances = expand_recurrence( | |
| 1389 | + | &event, | |
| 1390 | + | Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), | |
| 1391 | + | Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(), | |
| 1392 | + | ); | |
| 1393 | + | ||
| 1394 | + | assert!(instances.is_empty()); | |
| 1395 | + | } | |
| 1396 | + | ||
| 1397 | + | #[test] | |
| 1398 | + | fn a_rule_with_no_until_still_repeats_forever() { | |
| 1399 | + | // The default every rule written before this field existed reads back as. | |
| 1400 | + | let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); | |
| 1401 | + | let event = weekly_event(start, None); | |
| 1402 | + | ||
| 1403 | + | let instances = expand_recurrence( | |
| 1404 | + | &event, | |
| 1405 | + | Utc.with_ymd_and_hms(2030, 3, 1, 0, 0, 0).unwrap(), | |
| 1406 | + | Utc.with_ymd_and_hms(2030, 3, 31, 23, 59, 59).unwrap(), | |
| 1407 | + | ); | |
| 1408 | + | ||
| 1409 | + | assert_eq!(instances.len(), 4); | |
| 1410 | + | } | |
| 1411 | + | ||
| 1412 | + | #[test] | |
| 1413 | + | fn next_recurring_task_opens_no_successor_past_until() { | |
| 1414 | + | // The final instance closes the chain: completing it writes no successor, | |
| 1415 | + | // which is what makes a bounded series stop without anyone deleting it. | |
| 1416 | + | let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); | |
| 1417 | + | let mut task = recurring_task(due, Recurrence::Weekly); | |
| 1418 | + | task.recurrence_rule = Some(RecurrenceRule { | |
| 1419 | + | pattern: Recurrence::Weekly, | |
| 1420 | + | interval: 1, | |
| 1421 | + | weekdays: vec![], | |
| 1422 | + | monthly_spec: None, | |
| 1423 | + | // The successor would fall on Aug 10, one day past this. | |
| 1424 | + | until: Some(Utc.with_ymd_and_hms(2026, 8, 9, 9, 0, 0).unwrap()), | |
| 1425 | + | }); | |
| 1426 | + | ||
| 1427 | + | assert!(next_recurring_task(&task, Tz::UTC, due).is_none()); | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | #[test] | |
| 1431 | + | fn next_recurring_task_opens_a_successor_landing_on_until() { | |
| 1432 | + | // Same inclusivity as the expansion side: the boundary occurrence is | |
| 1433 | + | // part of the series. | |
| 1434 | + | let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap(); | |
| 1435 | + | let until = Utc.with_ymd_and_hms(2026, 8, 10, 9, 0, 0).unwrap(); | |
| 1436 | + | let mut task = recurring_task(due, Recurrence::Weekly); | |
| 1437 | + | task.recurrence_rule = Some(RecurrenceRule { | |
| 1438 | + | pattern: Recurrence::Weekly, | |
| 1439 | + | interval: 1, | |
| 1440 | + | weekdays: vec![], | |
| 1441 | + | monthly_spec: None, | |
| 1442 | + | until: Some(until), | |
| 1443 | + | }); | |
| 1444 | + | ||
| 1445 | + | let next = next_recurring_task(&task, Tz::UTC, due).expect("successor on the boundary"); | |
| 1446 | + | assert_eq!(next.due, Some(until)); | |
| 1447 | + | } | |
| 1448 | + | ||
| 1285 | 1449 | #[test] | |
| 1286 | 1450 | fn test_utc_wrapper_unaffected_by_dst_logic() { | |
| 1287 | 1451 | // The legacy UTC entry points must still add a fixed 24h (no zone involved), |
| @@ -792,6 +792,7 @@ | |||
| 792 | 792 | interval: 2, | |
| 793 | 793 | weekdays: vec![0, 2], | |
| 794 | 794 | monthly_spec: None, | |
| 795 | + | until: None, | |
| 795 | 796 | }) | |
| 796 | 797 | .build(); | |
| 797 | 798 | let task = repo.create(user_id, new_task).await.expect("create"); | |
| @@ -817,6 +818,7 @@ | |||
| 817 | 818 | interval: 3, | |
| 818 | 819 | weekdays: vec![4], | |
| 819 | 820 | monthly_spec: None, | |
| 821 | + | until: None, | |
| 820 | 822 | }), | |
| 821 | 823 | urgency: task.urgency, | |
| 822 | 824 | scheduled_start: None, | |
| @@ -861,6 +863,51 @@ | |||
| 861 | 863 | ); | |
| 862 | 864 | } | |
| 863 | 865 | ||
| 866 | + | #[tokio::test] | |
| 867 | + | async fn a_rule_written_before_until_existed_reads_back_unbounded() { | |
| 868 | + | // `recurrence_rule` is a JSON TEXT column, so adding a field is not a | |
| 869 | + | // migration: every row written before it has no `until` key. `#[serde(default)]` | |
| 870 | + | // is what makes those rows read as an unbounded series rather than failing | |
| 871 | + | // to deserialize, and this is the test that says so. | |
| 872 | + | // | |
| 873 | + | // The reverse direction is the risk worth knowing: serde drops unknown | |
| 874 | + | // fields, so a build predating this field that reads a bounded rule and | |
| 875 | + | // writes it back strips `until` and silently un-bounds the series. These | |
| 876 | + | // rows sync, so this wants to land in a release required before the next | |
| 877 | + | // sync-schema change. | |
| 878 | + | let pool = common::setup_test_db().await; | |
| 879 | + | let user_id = common::create_test_user(&pool).await; | |
| 880 | + | let repo = SqliteTaskRepository::new(pool.clone()); | |
| 881 | + | ||
| 882 | + | let task = repo | |
| 883 | + | .create( | |
| 884 | + | user_id, | |
| 885 | + | NewTask::builder("Standup") | |
| 886 | + | .recurrence(Recurrence::Weekly) | |
| 887 | + | .build(), | |
| 888 | + | ) | |
| 889 | + | .await | |
| 890 | + | .expect("create"); | |
| 891 | + | ||
| 892 | + | // The exact JSON a pre-`until` build wrote: no key at all. | |
| 893 | + | sqlx::query("UPDATE tasks SET recurrence_rule = ? WHERE id = ?") | |
| 894 | + | .bind(r#"{"pattern":"Weekly","interval":2,"weekdays":[0,2],"monthlySpec":null}"#) | |
| 895 | + | .bind(task.id.to_string()) | |
| 896 | + | .execute(&pool) | |
| 897 | + | .await | |
| 898 | + | .expect("write the legacy shape"); | |
| 899 | + | ||
| 900 | + | let read = repo | |
| 901 | + | .get_by_id(task.id, user_id) | |
| 902 | + | .await | |
| 903 | + | .expect("read") | |
| 904 | + | .expect("task still there"); | |
| 905 | + | let rule = read.recurrence_rule.expect("legacy rule deserializes"); | |
| 906 | + | assert_eq!(rule.interval, 2, "the fields that were there still read"); | |
| 907 | + | assert_eq!(rule.weekdays, vec![0, 2]); | |
| 908 | + | assert!(rule.until.is_none(), "a missing key is an unbounded series"); | |
| 909 | + | } | |
| 910 | + | ||
| 864 | 911 | #[tokio::test] | |
| 865 | 912 | async fn the_migration_backfills_title_out_of_the_legacy_description() { | |
| 866 | 913 | // Rows written before 062 existed carry the old packed shape. The migration |
| @@ -267,6 +267,26 @@ | |||
| 267 | 267 | Some(v) => Some(parse_monthly_spec(tool, v)?), | |
| 268 | 268 | }; | |
| 269 | 269 | ||
| 270 | + | // The end of a bounded series, inclusive. Parsed here but not range-checked | |
| 271 | + | // against the series start: this function is handed the rule alone and never | |
| 272 | + | // the task's due date or the event's start_time, and the three write | |
| 273 | + | // surfaces that do know it resolve the start differently. A caller who sends | |
| 274 | + | // an `until` before the first occurrence gets an empty expansion, which is | |
| 275 | + | // the honest reading of what they asked for. | |
| 276 | + | let until = match obj.get("until").filter(|v| !v.is_null()) { | |
| 277 | + | None => None, | |
| 278 | + | Some(v) => { | |
| 279 | + | let text = v.as_str().ok_or_else(|| { | |
| 280 | + | invalid("`recurrence.until` must be an RFC 3339 string".to_string()) | |
| 281 | + | })?; | |
| 282 | + | Some( | |
| 283 | + | DateTime::parse_from_rfc3339(text) | |
| 284 | + | .map_err(|e| invalid(format!("`recurrence.until` is not RFC 3339: {e}")))? | |
| 285 | + | .with_timezone(&Utc), | |
| 286 | + | ) | |
| 287 | + | } | |
| 288 | + | }; | |
| 289 | + | ||
| 270 | 290 | Ok(( | |
| 271 | 291 | pattern.clone(), | |
| 272 | 292 | Some(RecurrenceRule { | |
| @@ -274,6 +294,7 @@ | |||
| 274 | 294 | interval, | |
| 275 | 295 | weekdays, | |
| 276 | 296 | monthly_spec, | |
| 297 | + | until, | |
| 277 | 298 | }), | |
| 278 | 299 | )) | |
| 279 | 300 | } | |
| @@ -554,6 +575,7 @@ | |||
| 554 | 575 | "interval": rule.interval, | |
| 555 | 576 | "weekdays": rule.weekdays, | |
| 556 | 577 | "monthly_spec": serde_json::to_value(&rule.monthly_spec).unwrap_or(Value::Null), | |
| 578 | + | "until": rule.until.map(|u| u.to_rfc3339()), | |
| 557 | 579 | "display": rule.display(), | |
| 558 | 580 | }), | |
| 559 | 581 | } |
| @@ -57,8 +57,11 @@ | |||
| 57 | 57 | ics_event.location(location); | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | // Add recurrence rule if applicable | |
| 61 | - | if let Some(rrule) = recurrence_to_rrule(&event.recurrence) { | |
| 60 | + | // Add recurrence rule if applicable. Reads the effective rule rather | |
| 61 | + | // than the legacy column so a bounded series exports its UNTIL; a row | |
| 62 | + | // carrying only the legacy column synthesizes the same simple rule it | |
| 63 | + | // always did. | |
| 64 | + | if let Some(rrule) = recurrence_to_rrule(event.effective_recurrence_rule().as_ref()) { | |
| 62 | 65 | ics_event.add_property("RRULE", &rrule); | |
| 63 | 66 | } | |
| 64 | 67 | ||
| @@ -86,14 +89,26 @@ | |||
| 86 | 89 | } | |
| 87 | 90 | } | |
| 88 | 91 | ||
| 89 | - | /// Converts GoingsOn recurrence to iCalendar RRULE string. | |
| 90 | - | fn recurrence_to_rrule(recurrence: &goingson_core::Recurrence) -> Option<String> { | |
| 91 | - | match recurrence { | |
| 92 | - | goingson_core::Recurrence::None => None, | |
| 93 | - | goingson_core::Recurrence::Daily => Some("FREQ=DAILY".to_string()), | |
| 94 | - | goingson_core::Recurrence::Weekly => Some("FREQ=WEEKLY".to_string()), | |
| 95 | - | goingson_core::Recurrence::Monthly => Some("FREQ=MONTHLY".to_string()), | |
| 96 | - | } | |
| 92 | + | /// Converts a GoingsOn recurrence rule to an iCalendar RRULE string. | |
| 93 | + | /// | |
| 94 | + | /// FREQ and UNTIL only. The rule's `interval`, `weekdays` and `monthly_spec` | |
| 95 | + | /// have never been exported and are not exported here either; widening this to | |
| 96 | + | /// take the rule was what UNTIL needed, not a decision to finish the mapping. | |
| 97 | + | fn recurrence_to_rrule(rule: Option<&goingson_core::RecurrenceRule>) -> Option<String> { | |
| 98 | + | let rule = rule?; | |
| 99 | + | let freq = match rule.pattern { | |
| 100 | + | goingson_core::Recurrence::None => return None, | |
| 101 | + | goingson_core::Recurrence::Daily => "FREQ=DAILY", | |
| 102 | + | goingson_core::Recurrence::Weekly => "FREQ=WEEKLY", | |
| 103 | + | goingson_core::Recurrence::Monthly => "FREQ=MONTHLY", | |
| 104 | + | }; | |
| 105 | + | ||
| 106 | + | // RFC 5545 basic format, UTC. A floating or TZID-qualified UNTIL is only | |
| 107 | + | // legal against a matching DTSTART, and DTSTART here is always a UTC instant. | |
| 108 | + | Some(match rule.until { | |
| 109 | + | None => freq.to_string(), | |
| 110 | + | Some(until) => format!("{freq};UNTIL={}", until.format("%Y%m%dT%H%M%SZ")), | |
| 111 | + | }) | |
| 97 | 112 | } | |
| 98 | 113 | ||
| 99 | 114 | #[cfg(test)] | |
| @@ -171,20 +186,44 @@ | |||
| 171 | 186 | assert_eq!(count, 1); | |
| 172 | 187 | } | |
| 173 | 188 | ||
| 189 | + | /// An unbounded rule of the given pattern, the shape `from_legacy` | |
| 190 | + | /// synthesizes for a row carrying only the legacy column. | |
| 191 | + | fn plain(pattern: Recurrence) -> goingson_core::RecurrenceRule { | |
| 192 | + | goingson_core::RecurrenceRule { | |
| 193 | + | pattern, | |
| 194 | + | interval: 1, | |
| 195 | + | weekdays: vec![], | |
| 196 | + | monthly_spec: None, | |
| 197 | + | until: None, | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 174 | 201 | #[test] | |
| 175 | 202 | fn test_recurrence_to_rrule() { | |
| 176 | - | assert_eq!(recurrence_to_rrule(&Recurrence::None), None); | |
| 203 | + | assert_eq!(recurrence_to_rrule(None), None); | |
| 204 | + | assert_eq!(recurrence_to_rrule(Some(&plain(Recurrence::None))), None); | |
| 177 | 205 | assert_eq!( | |
| 178 | - | recurrence_to_rrule(&Recurrence::Daily), | |
| 206 | + | recurrence_to_rrule(Some(&plain(Recurrence::Daily))), | |
| 179 | 207 | Some("FREQ=DAILY".to_string()) | |
| 180 | 208 | ); | |
| 181 | 209 | assert_eq!( | |
| 182 | - | recurrence_to_rrule(&Recurrence::Weekly), | |
| 210 | + | recurrence_to_rrule(Some(&plain(Recurrence::Weekly))), | |
| 183 | 211 | Some("FREQ=WEEKLY".to_string()) | |
| 184 | 212 | ); | |
| 185 | 213 | assert_eq!( | |
| 186 | - | recurrence_to_rrule(&Recurrence::Monthly), | |
| 214 | + | recurrence_to_rrule(Some(&plain(Recurrence::Monthly))), | |
| 187 | 215 | Some("FREQ=MONTHLY".to_string()) | |
| 188 | 216 | ); | |
| 189 | 217 | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn bounded_series_exports_until_in_basic_utc_format() { | |
| 221 | + | let mut rule = plain(Recurrence::Weekly); | |
| 222 | + | rule.until = | |
| 223 | + | Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2026, 12, 31, 17, 30, 0).unwrap()); | |
| 224 | + | assert_eq!( | |
| 225 | + | recurrence_to_rrule(Some(&rule)), | |
| 226 | + | Some("FREQ=WEEKLY;UNTIL=20261231T173000Z".to_string()) | |
| 227 | + | ); | |
| 228 | + | } | |
| 190 | 229 | } |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Cross-domain shared types and traits. | |
| 2 | 2 | ||
| 3 | + | use chrono::{DateTime, Utc}; | |
| 3 | 4 | use serde::{Deserialize, Serialize}; | |
| 4 | 5 | use strum_macros::EnumString; | |
| 5 | 6 | ||
| @@ -203,6 +204,22 @@ | |||
| 203 | 204 | /// For Monthly: day-of-month or Nth weekday specification. | |
| 204 | 205 | #[serde(default)] | |
| 205 | 206 | pub monthly_spec: Option<MonthlySpec>, | |
| 207 | + | /// End of the series, inclusive: an occurrence landing exactly on it is | |
| 208 | + | /// produced, the next one is not. `None` repeats forever, which is what | |
| 209 | + | /// every rule written before this field existed reads back as. | |
| 210 | + | /// | |
| 211 | + | /// There is no COUNT counterpart and there should not be one. RFC 5545 | |
| 212 | + | /// makes UNTIL and COUNT mutually exclusive, and COUNT needs an occurrence | |
| 213 | + | /// ordinal that neither expansion path carries: `expand_recurrence_in_tz` | |
| 214 | + | /// walks a bare cursor, and `next_recurring_task` is handed one task with | |
| 215 | + | /// no view of its chain. | |
| 216 | + | /// | |
| 217 | + | /// Skipped when absent rather than written as `null`, unlike `monthly_spec` | |
| 218 | + | /// above. These rows sync and are compared as stored text, so a rule that | |
| 219 | + | /// predates the field has to re-serialize to the same bytes it was read as; | |
| 220 | + | /// `backup_roundtrip_preserves_every_column` is what says so out loud. | |
| 221 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 222 | + | pub until: Option<DateTime<Utc>>, | |
| 206 | 223 | } | |
| 207 | 224 | ||
| 208 | 225 | fn default_interval() -> u32 { | |
| @@ -220,6 +237,7 @@ | |||
| 220 | 237 | interval: 1, | |
| 221 | 238 | weekdays: vec![], | |
| 222 | 239 | monthly_spec: None, | |
| 240 | + | until: None, | |
| 223 | 241 | }) | |
| 224 | 242 | } | |
| 225 | 243 | ||
| @@ -277,6 +295,10 @@ | |||
| 277 | 295 | } | |
| 278 | 296 | } | |
| 279 | 297 | ||
| 298 | + | if let Some(until) = self.until { | |
| 299 | + | parts.push(format!("until {}", until.format("%Y-%m-%d"))); | |
| 300 | + | } | |
| 301 | + | ||
| 280 | 302 | parts.join(" ") | |
| 281 | 303 | } | |
| 282 | 304 | } | |
| @@ -354,6 +376,7 @@ | |||
| 354 | 376 | interval, | |
| 355 | 377 | weekdays, | |
| 356 | 378 | monthly_spec, | |
| 379 | + | until: None, | |
| 357 | 380 | } | |
| 358 | 381 | } | |
| 359 | 382 |
| @@ -428,7 +428,7 @@ | |||
| 428 | 428 | "project_id": project_id_field(), | |
| 429 | 429 | "location": { "type": "string" }, | |
| 430 | 430 | "recurrence": { | |
| 431 | - | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last)." | |
| 431 | + | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?, until?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). until is RFC 3339 and ends the series inclusively; omit it to repeat forever." | |
| 432 | 432 | }, | |
| 433 | 433 | "tz_kind": { | |
| 434 | 434 | "type": "string", |
| @@ -35,7 +35,7 @@ | |||
| 35 | 35 | /// worded the same as the event surface's, since it is the same rule type. | |
| 36 | 36 | fn recurrence_field() -> Value { | |
| 37 | 37 | json!({ | |
| 38 | - | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). Completing a recurring task opens its successor rather than editing it." | |
| 38 | + | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?, until?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last). until is RFC 3339 and ends the series inclusively; omit it to repeat forever. Completing a recurring task opens its successor rather than editing it, and the instance landing on until opens none." | |
| 39 | 39 | }) | |
| 40 | 40 | } | |
| 41 | 41 |