Skip to main content

max / goingson

bound a recurrence with until An inclusive end date on RecurrenceRule: the occurrence landing on it is produced, the next is not, and an absent field reads back as repeating forever. No COUNT counterpart; RFC 5545 makes the two exclusive and neither expansion path carries an occurrence ordinal. Skipped rather than serialized as null, so a rule written before the field re-serializes to the bytes it was read as. The ICS export reads the effective rule now, which is what lets it emit UNTIL.
Author: Max Johnson <me@maxj.phd> · 2026-08-06 18:48 UTC
Signed with PGP, not checked
Commit: 962cac584ac6966864c3256d38640cb88ea9c891
Parent: 4e62819
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