@remit/calendar-service 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/errors.ts CHANGED
@@ -34,7 +34,8 @@ export type CalendarValidationCode =
34
34
  /** A per-occurrence scope against an event that has one occurrence. */
35
35
  | "NotRecurring"
36
36
  /** A RECURRENCE-ID naming no occurrence this series produces. */
37
- | "UnknownOccurrence";
37
+ | "UnknownOccurrence"
38
+ | "UnmovableRecurrenceRule";
38
39
 
39
40
  export interface CalendarValidationError {
40
41
  code: CalendarValidationCode;
package/src/scope.test.ts CHANGED
@@ -686,3 +686,736 @@ describe("splitting a series whose DTSTART is not a UTC instant", () => {
686
686
  assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260608T085959Z");
687
687
  });
688
688
  });
689
+
690
+ const series = (...eventLines: string[]) =>
691
+ ical(
692
+ "BEGIN:VCALENDAR",
693
+ "VERSION:2.0",
694
+ "BEGIN:VEVENT",
695
+ "UID:series@example.com",
696
+ "SUMMARY:Stand-up",
697
+ ...eventLines,
698
+ "END:VEVENT",
699
+ "END:VCALENDAR",
700
+ );
701
+
702
+ const withOverride = (masterLines: string[], overrideLines: string[]) =>
703
+ ical(
704
+ "BEGIN:VCALENDAR",
705
+ "VERSION:2.0",
706
+ ...AMSTERDAM_VTIMEZONE,
707
+ "BEGIN:VEVENT",
708
+ "UID:series@example.com",
709
+ "SUMMARY:Stand-up",
710
+ ...masterLines,
711
+ "END:VEVENT",
712
+ "BEGIN:VEVENT",
713
+ "UID:series@example.com",
714
+ ...overrideLines,
715
+ "END:VEVENT",
716
+ "END:VCALENDAR",
717
+ );
718
+
719
+ const starts = async (icalData: string, collectionTimezone = "") =>
720
+ expandCalendar(await read(icalData), collectionTimezone).occurrences.map(
721
+ (occurrence) => occurrence.startAt,
722
+ );
723
+
724
+ const replaced = (
725
+ write: Awaited<ReturnType<typeof applyScopedUpdate>>,
726
+ ): string => {
727
+ assert.ok(write.ok, `expected a write, got ${JSON.stringify(write)}`);
728
+ assert.ok(write.value.kind === "Replace");
729
+ return write.value.icalData;
730
+ };
731
+
732
+ describe("a whole-series edit to another day", () => {
733
+ it("moves a weekly rule's weekday with a Monday series moved to Tuesday", async () => {
734
+ const calendar = await read(
735
+ series(
736
+ "DTSTART:20260907T090000Z",
737
+ "DTEND:20260907T100000Z",
738
+ "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4",
739
+ ),
740
+ );
741
+
742
+ const icalData = replaced(
743
+ await applyScopedUpdate(
744
+ calendar,
745
+ "",
746
+ input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
747
+ { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
748
+ ),
749
+ );
750
+
751
+ const [master] = events(icalData);
752
+ assert.ok(
753
+ master?.includes("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=TU"),
754
+ `the rule names the new day: ${master?.join(" | ")}`,
755
+ );
756
+ assert.deepEqual(await starts(icalData), [
757
+ "2026-09-08T09:00:00Z",
758
+ "2026-09-15T09:00:00Z",
759
+ "2026-09-22T09:00:00Z",
760
+ "2026-09-29T09:00:00Z",
761
+ ]);
762
+ });
763
+
764
+ it("moves a weekly rule's weekday when the edit names no occurrence", async () => {
765
+ const calendar = await read(
766
+ series(
767
+ "DTSTART:20260907T090000Z",
768
+ "DTEND:20260907T100000Z",
769
+ "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
770
+ ),
771
+ );
772
+
773
+ const icalData = replaced(
774
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
775
+ start: "2026-09-08T09:00:00Z",
776
+ end: "2026-09-08T10:00:00Z",
777
+ }),
778
+ );
779
+
780
+ assert.deepEqual(await starts(icalData), [
781
+ "2026-09-08T09:00:00Z",
782
+ "2026-09-15T09:00:00Z",
783
+ "2026-09-22T09:00:00Z",
784
+ ]);
785
+ });
786
+
787
+ it("moves every weekday of a rule that names several", async () => {
788
+ const calendar = await read(
789
+ series(
790
+ "DTSTART:20260907T090000Z",
791
+ "DTEND:20260907T100000Z",
792
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
793
+ ),
794
+ );
795
+
796
+ const icalData = replaced(
797
+ await applyScopedUpdate(
798
+ calendar,
799
+ "",
800
+ input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
801
+ { start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
802
+ ),
803
+ );
804
+
805
+ assert.deepEqual(await starts(icalData), [
806
+ "2026-09-08T09:00:00Z",
807
+ "2026-09-10T09:00:00Z",
808
+ "2026-09-15T09:00:00Z",
809
+ "2026-09-17T09:00:00Z",
810
+ ]);
811
+ });
812
+
813
+ it("names the new weekday and its place in the month for a monthly rule", async () => {
814
+ const calendar = await read(
815
+ series(
816
+ "DTSTART:20260914T090000Z",
817
+ "DTEND:20260914T100000Z",
818
+ "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
819
+ ),
820
+ );
821
+
822
+ const icalData = replaced(
823
+ await applyScopedUpdate(
824
+ calendar,
825
+ "",
826
+ input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
827
+ { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
828
+ ),
829
+ );
830
+
831
+ assert.deepEqual(await starts(icalData), [
832
+ "2026-09-15T09:00:00Z",
833
+ "2026-10-20T09:00:00Z",
834
+ "2026-11-17T09:00:00Z",
835
+ ]);
836
+ });
837
+
838
+ it("keeps a monthly rule counting from the end of the month", async () => {
839
+ const calendar = await read(
840
+ series(
841
+ "DTSTART:20260925T090000Z",
842
+ "DTEND:20260925T100000Z",
843
+ "RRULE:FREQ=MONTHLY;BYDAY=-1FR;COUNT=3",
844
+ ),
845
+ );
846
+
847
+ const icalData = replaced(
848
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
849
+ start: "2026-09-24T09:00:00Z",
850
+ end: "2026-09-24T10:00:00Z",
851
+ }),
852
+ );
853
+
854
+ assert.deepEqual(await starts(icalData), [
855
+ "2026-09-24T09:00:00Z",
856
+ "2026-10-29T09:00:00Z",
857
+ "2026-11-26T09:00:00Z",
858
+ ]);
859
+ });
860
+
861
+ it("moves the month of a yearly rule pinned to a month and weekday", async () => {
862
+ const calendar = await read(
863
+ series(
864
+ "DTSTART:20260930T090000Z",
865
+ "DTEND:20260930T100000Z",
866
+ "RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1WE;COUNT=2",
867
+ ),
868
+ );
869
+
870
+ const icalData = replaced(
871
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
872
+ start: "2026-10-01T09:00:00Z",
873
+ end: "2026-10-01T10:00:00Z",
874
+ }),
875
+ );
876
+
877
+ assert.deepEqual(await starts(icalData), [
878
+ "2026-10-01T09:00:00Z",
879
+ "2027-10-07T09:00:00Z",
880
+ ]);
881
+ });
882
+
883
+ it("leaves a rule alone when the edit also sets the rule", async () => {
884
+ const calendar = await read(
885
+ series(
886
+ "DTSTART:20260907T090000Z",
887
+ "DTEND:20260907T100000Z",
888
+ "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
889
+ ),
890
+ );
891
+
892
+ const icalData = replaced(
893
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
894
+ start: "2026-09-08T09:00:00Z",
895
+ end: "2026-09-08T10:00:00Z",
896
+ recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=3",
897
+ }),
898
+ );
899
+
900
+ assert.deepEqual(await starts(icalData), [
901
+ "2026-09-08T09:00:00Z",
902
+ "2026-09-10T09:00:00Z",
903
+ "2026-09-15T09:00:00Z",
904
+ ]);
905
+ });
906
+
907
+ it("refuses a move a rule with two numbered weekdays cannot follow", async () => {
908
+ const calendar = await read(
909
+ series(
910
+ "DTSTART:20260907T090000Z",
911
+ "DTEND:20260907T100000Z",
912
+ "RRULE:FREQ=MONTHLY;BYDAY=1MO,3MO;COUNT=4",
913
+ ),
914
+ );
915
+
916
+ const write = await applyScopedUpdate(
917
+ calendar,
918
+ "",
919
+ input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
920
+ { start: "2026-09-08T09:00:00Z", end: "2026-09-08T10:00:00Z" },
921
+ );
922
+
923
+ assert.equal(write.ok, false);
924
+ assert.ok(!write.ok);
925
+ assert.equal(write.error.code, "UnmovableRecurrenceRule");
926
+ assert.match(write.error.message, /Pick a new repeat/);
927
+ assert.match(write.error.message, /move only this event/);
928
+ assert.doesNotMatch(write.error.message, /recurrenceRule|scope=|BYDAY/);
929
+ });
930
+
931
+ it("refuses a move a rule pinned by BYSETPOS cannot follow", async () => {
932
+ const calendar = await read(
933
+ series(
934
+ "DTSTART:20260930T090000Z",
935
+ "DTEND:20260930T100000Z",
936
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3",
937
+ ),
938
+ );
939
+
940
+ const write = await applyScopedUpdate(
941
+ calendar,
942
+ "",
943
+ input(RecurrenceScope.All),
944
+ { start: "2026-09-29T09:00:00Z", end: "2026-09-29T10:00:00Z" },
945
+ );
946
+
947
+ assert.ok(!write.ok);
948
+ assert.equal(write.error.code, "UnmovableRecurrenceRule");
949
+ });
950
+
951
+ it("keeps a weekly rule without a weekday on the new start's day", async () => {
952
+ const calendar = await read(WEEKLY);
953
+
954
+ const icalData = replaced(
955
+ await applyScopedUpdate(
956
+ calendar,
957
+ "",
958
+ input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
959
+ { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
960
+ ),
961
+ );
962
+
963
+ const [master] = events(icalData);
964
+ assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
965
+ assert.equal((await starts(icalData))[0], "2026-09-08T09:00:00Z");
966
+ });
967
+ });
968
+
969
+ describe("a whole-series time change with per-occurrence changes", () => {
970
+ const deletedAndEdited = withOverride(
971
+ [
972
+ "DTSTART:20260907T090000Z",
973
+ "DTEND:20260907T100000Z",
974
+ "RRULE:FREQ=WEEKLY;COUNT=5",
975
+ "EXDATE:20260914T090000Z",
976
+ ],
977
+ [
978
+ "RECURRENCE-ID:20260921T090000Z",
979
+ "DTSTART:20260921T130000Z",
980
+ "DTEND:20260921T140000Z",
981
+ "SUMMARY:Stand-up, moved",
982
+ ],
983
+ );
984
+
985
+ it("keeps a deleted occurrence deleted and an edited one shown once", async () => {
986
+ const calendar = await read(deletedAndEdited);
987
+
988
+ const icalData = replaced(
989
+ await applyScopedUpdate(
990
+ calendar,
991
+ "",
992
+ input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
993
+ { start: "2026-09-28T10:00:00Z", end: "2026-09-28T11:00:00Z" },
994
+ ),
995
+ );
996
+
997
+ assert.deepEqual(await starts(icalData), [
998
+ "2026-09-07T10:00:00Z",
999
+ "2026-09-21T13:00:00Z",
1000
+ "2026-09-28T10:00:00Z",
1001
+ "2026-10-05T10:00:00Z",
1002
+ ]);
1003
+ });
1004
+
1005
+ it("keeps them when the edit names no occurrence", async () => {
1006
+ const calendar = await read(deletedAndEdited);
1007
+
1008
+ const icalData = replaced(
1009
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1010
+ start: "2026-09-07T10:00:00Z",
1011
+ end: "2026-09-07T11:00:00Z",
1012
+ }),
1013
+ );
1014
+
1015
+ assert.deepEqual(await starts(icalData), [
1016
+ "2026-09-07T10:00:00Z",
1017
+ "2026-09-21T13:00:00Z",
1018
+ "2026-09-28T10:00:00Z",
1019
+ "2026-10-05T10:00:00Z",
1020
+ ]);
1021
+ });
1022
+
1023
+ it("keeps them when the series moves to another weekday", async () => {
1024
+ const calendar = await read(
1025
+ withOverride(
1026
+ [
1027
+ "DTSTART:20260907T090000Z",
1028
+ "DTEND:20260907T100000Z",
1029
+ "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5",
1030
+ "EXDATE:20260914T090000Z",
1031
+ ],
1032
+ [
1033
+ "RECURRENCE-ID:20260921T090000Z",
1034
+ "DTSTART:20260921T130000Z",
1035
+ "DTEND:20260921T140000Z",
1036
+ "SUMMARY:Stand-up, moved",
1037
+ ],
1038
+ ),
1039
+ );
1040
+
1041
+ const icalData = replaced(
1042
+ await applyScopedUpdate(
1043
+ calendar,
1044
+ "",
1045
+ input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
1046
+ { start: "2026-09-29T10:00:00Z", end: "2026-09-29T11:00:00Z" },
1047
+ ),
1048
+ );
1049
+
1050
+ assert.deepEqual(await starts(icalData), [
1051
+ "2026-09-08T10:00:00Z",
1052
+ "2026-09-21T13:00:00Z",
1053
+ "2026-09-29T10:00:00Z",
1054
+ "2026-10-06T10:00:00Z",
1055
+ ]);
1056
+ });
1057
+
1058
+ it("keeps them on a monthly rule whose occurrences do not move by the same days", async () => {
1059
+ const calendar = await read(
1060
+ series(
1061
+ "DTSTART:20260914T090000Z",
1062
+ "DTEND:20260914T100000Z",
1063
+ "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
1064
+ "EXDATE:20261012T090000Z",
1065
+ ),
1066
+ );
1067
+
1068
+ const icalData = replaced(
1069
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1070
+ start: "2026-09-15T09:00:00Z",
1071
+ end: "2026-09-15T10:00:00Z",
1072
+ }),
1073
+ );
1074
+
1075
+ assert.deepEqual(await starts(icalData), [
1076
+ "2026-09-15T09:00:00Z",
1077
+ "2026-11-17T09:00:00Z",
1078
+ ]);
1079
+ });
1080
+
1081
+ it("moves an occurrence edited only in its title along with the series", async () => {
1082
+ const calendar = await read(
1083
+ withOverride(
1084
+ [
1085
+ "DTSTART:20260907T090000Z",
1086
+ "DTEND:20260907T100000Z",
1087
+ "RRULE:FREQ=WEEKLY;COUNT=3",
1088
+ ],
1089
+ [
1090
+ "RECURRENCE-ID:20260914T090000Z",
1091
+ "DTSTART:20260914T090000Z",
1092
+ "DTEND:20260914T100000Z",
1093
+ "SUMMARY:Stand-up, renamed",
1094
+ ],
1095
+ ),
1096
+ );
1097
+
1098
+ const icalData = replaced(
1099
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1100
+ start: "2026-09-07T10:00:00Z",
1101
+ end: "2026-09-07T11:30:00Z",
1102
+ }),
1103
+ );
1104
+
1105
+ const expanded = expandCalendar(await read(icalData), "").occurrences;
1106
+ assert.deepEqual(
1107
+ expanded.map((occurrence) => [occurrence.startAt, occurrence.endAt]),
1108
+ [
1109
+ ["2026-09-07T10:00:00Z", "2026-09-07T11:30:00Z"],
1110
+ ["2026-09-14T10:00:00Z", "2026-09-14T11:30:00Z"],
1111
+ ["2026-09-21T10:00:00Z", "2026-09-21T11:30:00Z"],
1112
+ ],
1113
+ );
1114
+ });
1115
+
1116
+ it("keeps them on a zoned series across a clock change", async () => {
1117
+ const calendar = await read(
1118
+ withOverride(
1119
+ [
1120
+ "DTSTART;TZID=Europe/Amsterdam:20261015T090000",
1121
+ "DTEND;TZID=Europe/Amsterdam:20261015T100000",
1122
+ "RRULE:FREQ=WEEKLY;COUNT=4",
1123
+ "EXDATE;TZID=Europe/Amsterdam:20261022T090000",
1124
+ ],
1125
+ [
1126
+ "RECURRENCE-ID;TZID=Europe/Amsterdam:20261029T090000",
1127
+ "DTSTART;TZID=Europe/Amsterdam:20261029T150000",
1128
+ "DTEND;TZID=Europe/Amsterdam:20261029T160000",
1129
+ "SUMMARY:Stand-up, moved",
1130
+ ],
1131
+ ),
1132
+ );
1133
+
1134
+ const icalData = replaced(
1135
+ await applyScopedUpdate(
1136
+ calendar,
1137
+ "Europe/Amsterdam",
1138
+ input(RecurrenceScope.All, "2026-11-05T08:00:00Z"),
1139
+ {
1140
+ start: "2026-11-05T11:00:00+01:00",
1141
+ end: "2026-11-05T12:00:00+01:00",
1142
+ timeZone: "Europe/Amsterdam",
1143
+ },
1144
+ ),
1145
+ );
1146
+
1147
+ assert.deepEqual(await starts(icalData, "Europe/Amsterdam"), [
1148
+ "2026-10-15T09:00:00Z",
1149
+ "2026-10-29T14:00:00Z",
1150
+ "2026-11-05T10:00:00Z",
1151
+ ]);
1152
+ });
1153
+
1154
+ it("keeps them when the series becomes all-day", async () => {
1155
+ const calendar = await read(deletedAndEdited);
1156
+
1157
+ const icalData = replaced(
1158
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1159
+ allDay: true,
1160
+ start: "2026-09-07",
1161
+ end: "2026-09-08",
1162
+ }),
1163
+ );
1164
+
1165
+ const expanded = await starts(icalData);
1166
+ assert.equal(expanded.length, 4);
1167
+ assert.ok(
1168
+ !expanded.some((start) => start.startsWith("2026-09-14")),
1169
+ `the deleted occurrence stays deleted: ${expanded.join(", ")}`,
1170
+ );
1171
+ assert.equal(
1172
+ expanded.filter((start) => start.startsWith("2026-09-21")).length,
1173
+ 1,
1174
+ `the edited occurrence shows once: ${expanded.join(", ")}`,
1175
+ );
1176
+ });
1177
+ });
1178
+
1179
+ describe("a whole-series move the review found", () => {
1180
+ const firstMonday = () =>
1181
+ read(
1182
+ series(
1183
+ "DTSTART:20260907T090000Z",
1184
+ "DTEND:20260907T100000Z",
1185
+ "RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=4",
1186
+ ),
1187
+ );
1188
+
1189
+ it("takes a monthly weekday from the moved second occurrence", async () => {
1190
+ const icalData = replaced(
1191
+ await applyScopedUpdate(
1192
+ await firstMonday(),
1193
+ "",
1194
+ input(RecurrenceScope.All, "2026-10-05T09:00:00Z"),
1195
+ { start: "2026-10-06T09:00:00Z", end: "2026-10-06T10:00:00Z" },
1196
+ ),
1197
+ );
1198
+
1199
+ const [master] = events(icalData);
1200
+ assert.ok(
1201
+ master?.includes("RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=1TU"),
1202
+ `the rule is the first Tuesday: ${master?.join(" | ")}`,
1203
+ );
1204
+ assert.deepEqual(await starts(icalData), [
1205
+ "2026-09-01T09:00:00Z",
1206
+ "2026-10-06T09:00:00Z",
1207
+ "2026-11-03T09:00:00Z",
1208
+ "2026-12-01T09:00:00Z",
1209
+ ]);
1210
+ });
1211
+
1212
+ it("takes a monthly weekday from the moved third occurrence", async () => {
1213
+ const icalData = replaced(
1214
+ await applyScopedUpdate(
1215
+ await firstMonday(),
1216
+ "",
1217
+ input(RecurrenceScope.All, "2026-11-02T09:00:00Z"),
1218
+ { start: "2026-11-03T09:00:00Z", end: "2026-11-03T10:00:00Z" },
1219
+ ),
1220
+ );
1221
+
1222
+ assert.deepEqual(await starts(icalData), [
1223
+ "2026-09-01T09:00:00Z",
1224
+ "2026-10-06T09:00:00Z",
1225
+ "2026-11-03T09:00:00Z",
1226
+ "2026-12-01T09:00:00Z",
1227
+ ]);
1228
+ });
1229
+
1230
+ it("keeps the moved occurrence's place in its month for a second weekday", async () => {
1231
+ const calendar = await read(
1232
+ series(
1233
+ "DTSTART:20260914T090000Z",
1234
+ "DTEND:20260914T100000Z",
1235
+ "RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
1236
+ ),
1237
+ );
1238
+
1239
+ const icalData = replaced(
1240
+ await applyScopedUpdate(
1241
+ calendar,
1242
+ "",
1243
+ input(RecurrenceScope.All, "2026-10-12T09:00:00Z"),
1244
+ { start: "2026-10-13T09:00:00Z", end: "2026-10-13T10:00:00Z" },
1245
+ ),
1246
+ );
1247
+
1248
+ assert.deepEqual(await starts(icalData), [
1249
+ "2026-09-08T09:00:00Z",
1250
+ "2026-10-13T09:00:00Z",
1251
+ "2026-11-10T09:00:00Z",
1252
+ ]);
1253
+ });
1254
+
1255
+ it("keeps a fifth weekday a fifth weekday", async () => {
1256
+ const calendar = await read(
1257
+ series(
1258
+ "DTSTART:20260330T090000Z",
1259
+ "DTEND:20260330T100000Z",
1260
+ "RRULE:FREQ=MONTHLY;BYDAY=5MO;COUNT=2",
1261
+ ),
1262
+ );
1263
+
1264
+ const icalData = replaced(
1265
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1266
+ start: "2026-03-31T09:00:00Z",
1267
+ end: "2026-03-31T10:00:00Z",
1268
+ }),
1269
+ );
1270
+
1271
+ const [master] = events(icalData);
1272
+ assert.ok(
1273
+ master?.includes("RRULE:FREQ=MONTHLY;COUNT=2;BYDAY=5TU"),
1274
+ `the rule is the fifth Tuesday: ${master?.join(" | ")}`,
1275
+ );
1276
+ assert.deepEqual(await starts(icalData), [
1277
+ "2026-03-31T09:00:00Z",
1278
+ "2026-06-30T09:00:00Z",
1279
+ ]);
1280
+ });
1281
+
1282
+ it("keeps a daily Monday-to-Friday rule when the move stays on a weekday", async () => {
1283
+ const calendar = await read(
1284
+ series(
1285
+ "DTSTART:20260907T090000Z",
1286
+ "DTEND:20260907T100000Z",
1287
+ "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1288
+ ),
1289
+ );
1290
+
1291
+ const icalData = replaced(
1292
+ await applyScopedUpdate(
1293
+ calendar,
1294
+ "",
1295
+ input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
1296
+ { start: "2026-09-08T10:00:00Z", end: "2026-09-08T11:00:00Z" },
1297
+ ),
1298
+ );
1299
+
1300
+ const [master] = events(icalData);
1301
+ assert.ok(
1302
+ master?.includes("RRULE:FREQ=DAILY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
1303
+ `the weekday set is kept: ${master?.join(" | ")}`,
1304
+ );
1305
+ assert.deepEqual(await starts(icalData), [
1306
+ "2026-09-07T10:00:00Z",
1307
+ "2026-09-08T10:00:00Z",
1308
+ "2026-09-09T10:00:00Z",
1309
+ "2026-09-10T10:00:00Z",
1310
+ "2026-09-11T10:00:00Z",
1311
+ "2026-09-14T10:00:00Z",
1312
+ ]);
1313
+ });
1314
+
1315
+ it("keeps the app's every-weekday rule when the move stays on a weekday", async () => {
1316
+ const calendar = await read(
1317
+ series(
1318
+ "DTSTART:20260907T090000Z",
1319
+ "DTEND:20260907T100000Z",
1320
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1321
+ ),
1322
+ );
1323
+
1324
+ const icalData = replaced(
1325
+ await applyScopedUpdate(
1326
+ calendar,
1327
+ "",
1328
+ input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
1329
+ { start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
1330
+ ),
1331
+ );
1332
+
1333
+ const [master] = events(icalData);
1334
+ assert.ok(
1335
+ master?.includes("RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
1336
+ );
1337
+ assert.deepEqual(await starts(icalData), [
1338
+ "2026-09-07T09:00:00Z",
1339
+ "2026-09-08T09:00:00Z",
1340
+ "2026-09-09T09:00:00Z",
1341
+ "2026-09-10T09:00:00Z",
1342
+ "2026-09-11T09:00:00Z",
1343
+ "2026-09-14T09:00:00Z",
1344
+ ]);
1345
+ });
1346
+
1347
+ it("refuses to move an every-weekday series onto a weekend", async () => {
1348
+ const calendar = await read(
1349
+ series(
1350
+ "DTSTART:20260907T090000Z",
1351
+ "DTEND:20260907T100000Z",
1352
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
1353
+ ),
1354
+ );
1355
+
1356
+ const write = await applyScopedUpdate(
1357
+ calendar,
1358
+ "",
1359
+ input(RecurrenceScope.All, "2026-09-11T09:00:00Z"),
1360
+ { start: "2026-09-12T09:00:00Z", end: "2026-09-12T10:00:00Z" },
1361
+ );
1362
+
1363
+ assert.ok(!write.ok);
1364
+ assert.equal(write.error.code, "UnmovableRecurrenceRule");
1365
+ });
1366
+
1367
+ it("moves an end date with the series so the last occurrence stays", async () => {
1368
+ const calendar = await read(
1369
+ series(
1370
+ "DTSTART:20260907T090000Z",
1371
+ "DTEND:20260907T100000Z",
1372
+ "RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260928T090000Z",
1373
+ "EXDATE:20260928T090000Z",
1374
+ ),
1375
+ );
1376
+
1377
+ const icalData = replaced(
1378
+ await applyScopedUpdate(
1379
+ calendar,
1380
+ "",
1381
+ input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
1382
+ { start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
1383
+ ),
1384
+ );
1385
+
1386
+ const [master] = events(icalData);
1387
+ assert.ok(
1388
+ master?.includes("RRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20260929T090000Z"),
1389
+ `the end date moves with the series: ${master?.join(" | ")}`,
1390
+ );
1391
+ assert.deepEqual(await starts(icalData), [
1392
+ "2026-09-08T09:00:00Z",
1393
+ "2026-09-15T09:00:00Z",
1394
+ "2026-09-22T09:00:00Z",
1395
+ ]);
1396
+ });
1397
+
1398
+ it("keeps the last occurrence of a series with an end date moved later in the day", async () => {
1399
+ const calendar = await read(
1400
+ series(
1401
+ "DTSTART:20260907T090000Z",
1402
+ "DTEND:20260907T100000Z",
1403
+ "RRULE:FREQ=WEEKLY;UNTIL=20260928T090000Z",
1404
+ "EXDATE:20260921T090000Z",
1405
+ ),
1406
+ );
1407
+
1408
+ const icalData = replaced(
1409
+ await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
1410
+ start: "2026-09-07T10:00:00Z",
1411
+ end: "2026-09-07T11:00:00Z",
1412
+ }),
1413
+ );
1414
+
1415
+ assert.deepEqual(await starts(icalData), [
1416
+ "2026-09-07T10:00:00Z",
1417
+ "2026-09-14T10:00:00Z",
1418
+ "2026-09-28T10:00:00Z",
1419
+ ]);
1420
+ });
1421
+ });
package/src/scope.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  serializeCalendar,
10
10
  } from "./parse.js";
11
11
  import { hasRecurrence } from "./project.js";
12
- import { dtStartTzid, resolveTime, toUtcIso } from "./time.js";
12
+ import { dtStartTzid, resolveTime, toUtcIso, tzidOf } from "./time.js";
13
13
 
14
14
  export type RecurrenceScopeValue =
15
15
  (typeof RecurrenceScope)[keyof typeof RecurrenceScope];
@@ -308,17 +308,12 @@ const shiftProperty = (
308
308
  property.setValue(shifted);
309
309
  };
310
310
 
311
- /** Where an occurrence is drawn, which a moved one no longer shares with its slot. */
312
311
  const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
313
312
  if (occurrence.details) return occurrence.details.startDate;
314
313
  const start = occurrence.override?.getFirstPropertyValue("dtstart");
315
314
  return start instanceof ICAL.Time ? start : null;
316
315
  };
317
316
 
318
- /**
319
- * The slot a value named under the old DTSTART, named under the new one: the
320
- * same day of the series, at the series' new time and in its new zone.
321
- */
322
317
  const reslot = (
323
318
  value: ICAL.Time,
324
319
  before: ICAL.Time,
@@ -329,70 +324,440 @@ const reslot = (
329
324
  return moved;
330
325
  };
331
326
 
332
- /**
333
- * Keeps every override and excluded date on the slot it was written for once
334
- * the series' own start has moved. A RECURRENCE-ID or an EXDATE matches a slot
335
- * by its exact time, so leaving them behind would detach a moved occurrence
336
- * from the one it replaces and bring a deleted one back.
337
- */
327
+ interface SeriesShape {
328
+ start: ICAL.Time;
329
+ tzid: string;
330
+ rule: ICAL.Recur | null;
331
+ durationSeconds: number;
332
+ }
333
+
334
+ interface SeriesSlot {
335
+ time: ICAL.Time;
336
+ instantMs: number;
337
+ }
338
+
339
+ const seriesShape = (master: ICAL.Component): SeriesShape | null => {
340
+ const start = master.getFirstPropertyValue("dtstart");
341
+ if (!(start instanceof ICAL.Time)) return null;
342
+ const rule = master.getFirstPropertyValue("rrule");
343
+ return {
344
+ start: start.clone(),
345
+ tzid: dtStartTzid(master),
346
+ rule: rule instanceof ICAL.Recur ? rule.clone() : null,
347
+ durationSeconds: new ICAL.Event(master).duration.toSeconds(),
348
+ };
349
+ };
350
+
351
+ const ruleSlots = (
352
+ shape: SeriesShape,
353
+ collectionTimezone: string,
354
+ stop: (instantMs: number, count: number) => boolean,
355
+ ): SeriesSlot[] => {
356
+ if (!shape.rule) return [];
357
+ const slots: SeriesSlot[] = [];
358
+ const iterator = shape.rule.iterator(shape.start);
359
+ let next: ICAL.Time | null = iterator.next();
360
+ while (next && slots.length < CALENDAR_WINDOW_MAX_STEPS) {
361
+ const instantMs = resolveTime(
362
+ next,
363
+ shape.tzid,
364
+ collectionTimezone,
365
+ ).instantMs;
366
+ if (stop(instantMs, slots.length)) break;
367
+ slots.push({ time: next.clone(), instantMs });
368
+ next = iterator.next();
369
+ }
370
+ return slots;
371
+ };
372
+
373
+ const WEEKDAYS: readonly string[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
374
+
375
+ const WORKWEEK: readonly number[] = [1, 2, 3, 4, 5];
376
+
377
+ const BY_WEEKDAY = /^([+-]?\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/;
378
+
379
+ interface ByWeekday {
380
+ ordinal: number;
381
+ weekday: number;
382
+ }
383
+
384
+ interface FollowedRule {
385
+ rule: ICAL.Recur;
386
+ startShift: number;
387
+ }
388
+
389
+ const readByWeekday = (value: string): ByWeekday | null => {
390
+ const match = BY_WEEKDAY.exec(value);
391
+ if (!match) return null;
392
+ return {
393
+ ordinal: Number(match[1] ?? 0),
394
+ weekday: WEEKDAYS.indexOf(match[2] ?? ""),
395
+ };
396
+ };
397
+
398
+ const weekdayOf = (time: ICAL.Time): number => time.dayOfWeek() - 1;
399
+
400
+ const weekdayOfDate = (year: number, month: number, day: number): number =>
401
+ new Date(Date.UTC(year, month - 1, day)).getUTCDay();
402
+
403
+ const ordinalOf = (
404
+ position: number,
405
+ length: number,
406
+ original: number,
407
+ ): number => {
408
+ const fromStart = Math.floor((position - 1) / 7) + 1;
409
+ const fromLast = -(Math.floor((length - position) / 7) + 1);
410
+ const preferred = original < 0 ? fromLast : fromStart;
411
+ if (Math.abs(preferred) < 5 || Math.abs(original) >= 5) return preferred;
412
+ return original < 0 ? fromStart : fromLast;
413
+ };
414
+
415
+ const nthWeekday = (
416
+ year: number,
417
+ month: number,
418
+ weekday: number,
419
+ ordinal: number,
420
+ ): number | null => {
421
+ const length = ICAL.Time.daysInMonth(month, year);
422
+ if (ordinal > 0) {
423
+ const first = 1 + ((weekday - weekdayOfDate(year, month, 1) + 7) % 7);
424
+ const day = first + 7 * (ordinal - 1);
425
+ return day <= length ? day : null;
426
+ }
427
+ const last =
428
+ length - ((weekdayOfDate(year, month, length) - weekday + 7) % 7);
429
+ const day = last + 7 * (ordinal + 1);
430
+ return day >= 1 ? day : null;
431
+ };
432
+
433
+ const firstNumberedSlot = (
434
+ from: ICAL.Time,
435
+ month: number,
436
+ yearly: boolean,
437
+ weekday: number,
438
+ ordinal: number,
439
+ ): ICAL.Time | null => {
440
+ for (let step = 0; step < 48; step += 1) {
441
+ const monthIndex = yearly ? month - 1 : from.month - 1 + step;
442
+ const year = from.year + (yearly ? step : Math.floor(monthIndex / 12));
443
+ const candidateMonth = (monthIndex % 12) + 1;
444
+ const day = nthWeekday(year, candidateMonth, weekday, ordinal);
445
+ if (day !== null) {
446
+ const slot = from.clone();
447
+ slot.adjust(
448
+ civilDay(ICAL.Time.fromData({ year, month: candidateMonth, day })) -
449
+ civilDay(from),
450
+ 0,
451
+ 0,
452
+ 0,
453
+ );
454
+ return slot;
455
+ }
456
+ }
457
+ return null;
458
+ };
459
+
460
+ const unmovable = <T>(after: ICAL.Time, reason: string): CalendarResult<T> =>
461
+ calendarFailure(
462
+ "UnmovableRecurrenceRule",
463
+ `This series repeats ${reason}, so it cannot move to ${after.toString().slice(0, 10)}. Pick a new repeat together with the new date, or move only this event.`,
464
+ );
465
+
466
+ const followStart = (
467
+ rule: ICAL.Recur,
468
+ seriesStart: ICAL.Time,
469
+ newStart: ICAL.Time,
470
+ anchor: ICAL.Time,
471
+ ): CalendarResult<FollowedRule> => {
472
+ const days = civilDay(newStart) - civilDay(seriesStart);
473
+ if (days === 0) return { ok: true, value: { rule, startShift: 0 } };
474
+ const anchorBefore = anchor.clone();
475
+ anchorBefore.adjust(-days, 0, 0, 0);
476
+ const moved = rule.clone();
477
+ const parts = moved.parts;
478
+ const kept = (startShift: number): CalendarResult<FollowedRule> => ({
479
+ ok: true,
480
+ value: { rule: moved, startShift },
481
+ });
482
+
483
+ const pinned = (["BYSETPOS", "BYYEARDAY", "BYWEEKNO"] as const).some(
484
+ (name) => (parts[name]?.length ?? 0) > 0,
485
+ );
486
+ if (pinned) return unmovable(anchor, "on a pattern of positions");
487
+
488
+ const byMonthDay = parts.BYMONTHDAY ?? [];
489
+ if (byMonthDay.length > 0) {
490
+ if (byMonthDay.length !== 1 || byMonthDay[0] !== anchorBefore.day) {
491
+ return unmovable(anchor, "on several days of the month");
492
+ }
493
+ parts.BYMONTHDAY = [anchor.day];
494
+ }
495
+
496
+ const byMonth = parts.BYMONTH ?? [];
497
+ if (byMonth.length > 0 && anchor.month !== anchorBefore.month) {
498
+ if (byMonth.length !== 1 || byMonth[0] !== anchorBefore.month) {
499
+ return unmovable(anchor, "in several months");
500
+ }
501
+ parts.BYMONTH = [anchor.month];
502
+ }
503
+
504
+ const byDay = parts.BYDAY ?? [];
505
+ const weekdays = byDay
506
+ .map(readByWeekday)
507
+ .filter((weekday): weekday is ByWeekday => weekday !== null);
508
+ if (weekdays.length !== byDay.length) {
509
+ return unmovable(anchor, "on days the calendar cannot read");
510
+ }
511
+ if (weekdays.length === 0) return kept(0);
512
+
513
+ const numbered = weekdays.find((weekday) => weekday.ordinal !== 0);
514
+ if (numbered) {
515
+ if (weekdays.length !== 1) {
516
+ return unmovable(anchor, "on more than one numbered weekday");
517
+ }
518
+ const yearly = moved.freq === "YEARLY";
519
+ if (yearly && (parts.BYMONTH?.length ?? 0) === 0) {
520
+ return unmovable(anchor, "on a numbered weekday of the year");
521
+ }
522
+ const ordinal = ordinalOf(
523
+ anchor.day,
524
+ ICAL.Time.daysInMonth(anchor.month, anchor.year),
525
+ numbered.ordinal,
526
+ );
527
+ const weekday = weekdayOf(anchor);
528
+ parts.BYDAY = [`${ordinal}${WEEKDAYS[weekday]}`];
529
+ const first = firstNumberedSlot(
530
+ seriesStart,
531
+ anchor.month,
532
+ yearly,
533
+ weekday,
534
+ ordinal,
535
+ );
536
+ if (!first) return unmovable(anchor, "on a weekday that never comes");
537
+ return kept(civilDay(first) - civilDay(newStart));
538
+ }
539
+
540
+ const set = weekdays.map((weekday) => weekday.weekday);
541
+ const workweek =
542
+ set.length === WORKWEEK.length &&
543
+ WORKWEEK.every((weekday) => set.includes(weekday));
544
+ if (moved.freq !== "WEEKLY" || workweek) {
545
+ if (!set.includes(weekdayOf(anchor))) {
546
+ return unmovable(anchor, "on a set of weekdays without that day");
547
+ }
548
+ return kept(-days);
549
+ }
550
+
551
+ const shift = (((weekdayOf(newStart) - weekdayOf(seriesStart)) % 7) + 7) % 7;
552
+ if (moved.interval > 1 && set.length > 1) {
553
+ const weekStart = moved.wkst - 1;
554
+ const wraps = set.map(
555
+ (weekday) => ((weekday - weekStart + 7) % 7) + shift >= 7,
556
+ );
557
+ if (wraps.some((wrap) => wrap !== wraps[0])) {
558
+ return unmovable(anchor, "on several days every few weeks");
559
+ }
560
+ }
561
+ parts.BYDAY = set.map((weekday) => WEEKDAYS[(weekday + shift) % 7] ?? "");
562
+ return kept(0);
563
+ };
564
+
565
+ const shiftStart = (master: ICAL.Component, days: number): void => {
566
+ if (days === 0) return;
567
+ shiftProperty(master, "dtstart", days);
568
+ shiftProperty(master, "dtend", days);
569
+ };
570
+
571
+ const followRule = (
572
+ master: ICAL.Component,
573
+ before: SeriesShape,
574
+ daysIn: number,
575
+ ): CalendarResult<null> => {
576
+ const property = master.getFirstProperty("rrule");
577
+ const rule = property?.getFirstValue();
578
+ const start = master.getFirstPropertyValue("dtstart");
579
+ if (!property || !(rule instanceof ICAL.Recur)) {
580
+ return { ok: true, value: null };
581
+ }
582
+ if (!(start instanceof ICAL.Time)) return { ok: true, value: null };
583
+ const anchor = start.clone();
584
+ anchor.adjust(daysIn, 0, 0, 0);
585
+ const followed = followStart(rule, before.start, start, anchor);
586
+ if (!followed.ok) return followed;
587
+ property.setValue(followed.value.rule);
588
+ shiftStart(master, followed.value.startShift);
589
+ return { ok: true, value: null };
590
+ };
591
+
592
+ const untilAt = (slot: SeriesSlot, shape: SeriesShape): ICAL.Time => {
593
+ const floating =
594
+ shape.tzid === "" && slot.time.zone !== ICAL.Timezone.utcTimezone;
595
+ if (slot.time.isDate || floating) return slot.time.clone();
596
+ return ICAL.Time.fromJSDate(new Date(slot.instantMs), true);
597
+ };
598
+
599
+ const followUntil = (
600
+ master: ICAL.Component,
601
+ before: SeriesShape,
602
+ collectionTimezone: string,
603
+ ): void => {
604
+ if (!before.rule || before.rule.until === null) return;
605
+ const property = master.getFirstProperty("rrule");
606
+ const rule = property?.getFirstValue();
607
+ const now = seriesShape(master);
608
+ if (!property || !(rule instanceof ICAL.Recur) || !now) return;
609
+ if (rule.until === null) return;
610
+
611
+ const previous = ruleSlots(before, collectionTimezone, () => false);
612
+ if (previous.length === 0 || previous.length >= CALENDAR_WINDOW_MAX_STEPS) {
613
+ return;
614
+ }
615
+ const open = rule.clone();
616
+ open.until = null;
617
+ const next = ruleSlots(
618
+ { ...now, rule: open },
619
+ collectionTimezone,
620
+ (_instantMs, count) => count >= previous.length,
621
+ );
622
+ const last = next[previous.length - 1];
623
+ if (!last) return;
624
+ rule.until = untilAt(last, now);
625
+ property.setValue(rule);
626
+ };
627
+
628
+ const replaceTime = (
629
+ component: ICAL.Component,
630
+ name: string,
631
+ time: ICAL.Time,
632
+ tzid: string,
633
+ ): void => {
634
+ component.removeAllProperties(name);
635
+ component.addProperty(timeProperty(name, time, tzid));
636
+ };
637
+
338
638
  const followSlots = (
339
639
  calendar: ParsedCalendar,
340
- before: ICAL.Time,
341
- after: ICAL.Time,
640
+ collectionTimezone: string,
641
+ before: SeriesShape,
642
+ after: SeriesShape,
342
643
  ): void => {
343
- if (before.isDate !== after.isDate) return;
344
- const tzid = dtStartTzid(calendar.master);
644
+ const instantOf = (value: ICAL.Time, tzid: string): number =>
645
+ resolveTime(value, tzid, collectionTimezone).instantMs;
646
+
647
+ const exceptionInstants: number[] = [];
345
648
  for (const override of calendar.overrides) {
346
- const recurrenceId = override.getFirstPropertyValue("recurrence-id");
649
+ const property = override.getFirstProperty("recurrence-id");
650
+ const value = property?.getFirstValue();
651
+ if (value instanceof ICAL.Time) {
652
+ exceptionInstants.push(instantOf(value, tzidOf(property)));
653
+ }
654
+ }
655
+ for (const property of calendar.master.getAllProperties("exdate")) {
656
+ for (const value of property.getValues()) {
657
+ if (value instanceof ICAL.Time) {
658
+ exceptionInstants.push(instantOf(value, tzidOf(property)));
659
+ }
660
+ }
661
+ }
662
+ const latest = Math.max(Number.NEGATIVE_INFINITY, ...exceptionInstants);
663
+
664
+ const previous = ruleSlots(
665
+ before,
666
+ collectionTimezone,
667
+ (instantMs) => instantMs > latest,
668
+ );
669
+ const indexByInstant = new Map(
670
+ previous.map((slot, index) => [slot.instantMs, index]),
671
+ );
672
+ const next = ruleSlots(
673
+ after,
674
+ collectionTimezone,
675
+ (_instantMs, count) => count >= previous.length,
676
+ );
677
+ const moved = (value: ICAL.Time, tzid: string): ICAL.Time => {
678
+ const index = indexByInstant.get(instantOf(value, tzid));
679
+ const slot = index === undefined ? undefined : next[index];
680
+ return slot ? slot.time.clone() : reslot(value, before.start, after.start);
681
+ };
682
+
683
+ for (const override of calendar.overrides) {
684
+ const property = override.getFirstProperty("recurrence-id");
685
+ const recurrenceId = property?.getFirstValue();
347
686
  if (!(recurrenceId instanceof ICAL.Time)) continue;
348
- override.removeAllProperties("recurrence-id");
349
- override.addProperty(
350
- timeProperty("recurrence-id", reslot(recurrenceId, before, after), tzid),
351
- );
687
+ const slot = moved(recurrenceId, tzidOf(property));
688
+ const start = override.getFirstPropertyValue("dtstart");
689
+ const untouched =
690
+ start instanceof ICAL.Time &&
691
+ instantOf(start, dtStartTzid(override)) ===
692
+ instantOf(recurrenceId, tzidOf(property)) &&
693
+ new ICAL.Event(override).duration.toSeconds() === before.durationSeconds;
694
+ replaceTime(override, "recurrence-id", slot, after.tzid);
695
+ if (!untouched) continue;
696
+ const end = slot.clone();
697
+ end.addDuration(ICAL.Duration.fromSeconds(after.durationSeconds));
698
+ override.removeAllProperties("duration");
699
+ replaceTime(override, "dtstart", slot, after.tzid);
700
+ replaceTime(override, "dtend", end, after.tzid);
352
701
  }
702
+
353
703
  for (const name of ["exdate", "rdate"]) {
354
704
  for (const property of calendar.master.getAllProperties(name)) {
355
705
  const values = property.getValues();
356
706
  if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
357
707
  continue;
358
708
  }
709
+ const tzid = tzidOf(property);
359
710
  calendar.master.removeProperty(property);
360
711
  for (const value of values as ICAL.Time[]) {
361
- calendar.master.addProperty(
362
- timeProperty(name, reslot(value, before, after), tzid),
363
- );
712
+ const target =
713
+ name === "exdate"
714
+ ? moved(value, tzid)
715
+ : reslot(value, before.start, after.start);
716
+ calendar.master.addProperty(timeProperty(name, target, after.tzid));
364
717
  }
365
718
  }
366
719
  }
367
720
  };
368
721
 
369
- /**
370
- * A whole-series edit made from one occurrence of it.
371
- *
372
- * A start or end in the patch is that occurrence's new time, so each is moved
373
- * back by the days between where that occurrence is drawn and where the series
374
- * begins. The series keeps its first day and its weekday, and only what was
375
- * edited changes.
376
- */
377
- const applyToSeriesFrom = async (
722
+ const occurrenceOffset = (
378
723
  calendar: ParsedCalendar,
379
724
  collectionTimezone: string,
380
725
  recurrenceId: string,
381
- patch: Partial<CalendarEventFields>,
382
- ): Promise<CalendarResult<ScopedWrite>> => {
726
+ seriesStart: ICAL.Time,
727
+ ): CalendarResult<number> => {
728
+ if (recurrenceId === "") return { ok: true, value: 0 };
383
729
  const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
384
730
  if (!found.ok) return found;
385
- const seriesStart = calendar.master.getFirstPropertyValue("dtstart");
386
731
  const shown = shownStart(found.value);
387
- if (!(seriesStart instanceof ICAL.Time) || !shown) {
732
+ if (!shown) {
388
733
  return calendarFailure(
389
734
  "UnknownOccurrence",
390
735
  `${recurrenceId} has no start to move the series from`,
391
736
  );
392
737
  }
393
- const before = seriesStart.clone();
394
- const beforeTzid = dtStartTzid(calendar.master);
395
- const daysIn = civilDay(shown) - civilDay(before);
738
+ return { ok: true, value: civilDay(shown) - civilDay(seriesStart) };
739
+ };
740
+
741
+ const applyToSeries = async (
742
+ calendar: ParsedCalendar,
743
+ collectionTimezone: string,
744
+ recurrenceId: string,
745
+ patch: Partial<CalendarEventFields>,
746
+ ): Promise<CalendarResult<ScopedWrite>> => {
747
+ const before = seriesShape(calendar.master);
748
+ if (!before) {
749
+ return calendarFailure(
750
+ "UnknownOccurrence",
751
+ "this series has no start to move",
752
+ );
753
+ }
754
+ const daysIn = occurrenceOffset(
755
+ calendar,
756
+ collectionTimezone,
757
+ recurrenceId,
758
+ before.start,
759
+ );
760
+ if (!daysIn.ok) return daysIn;
396
761
 
397
762
  const applied = await applyEventFields(
398
763
  calendar.master,
@@ -401,20 +766,19 @@ const applyToSeriesFrom = async (
401
766
  );
402
767
  if (!applied.ok) return applied;
403
768
  if (patch.start !== undefined) {
404
- shiftProperty(calendar.master, "dtstart", -daysIn);
769
+ shiftProperty(calendar.master, "dtstart", -daysIn.value);
405
770
  }
406
771
  if (patch.end !== undefined) {
407
- shiftProperty(calendar.master, "dtend", -daysIn);
772
+ shiftProperty(calendar.master, "dtend", -daysIn.value);
408
773
  }
409
-
410
- const after = calendar.master.getFirstPropertyValue("dtstart");
411
- if (
412
- after instanceof ICAL.Time &&
413
- (after.toString() !== before.toString() ||
414
- dtStartTzid(calendar.master) !== beforeTzid)
415
- ) {
416
- followSlots(calendar, before, after);
774
+ if (patch.recurrenceRule === undefined) {
775
+ const followed = followRule(calendar.master, before, daysIn.value);
776
+ if (!followed.ok) return followed;
777
+ followUntil(calendar.master, before, collectionTimezone);
417
778
  }
779
+
780
+ const after = seriesShape(calendar.master);
781
+ if (after) followSlots(calendar, collectionTimezone, before, after);
418
782
  return { ok: true, value: replaceWith(calendar) };
419
783
  };
420
784
 
@@ -506,14 +870,10 @@ export const applyScopedUpdate = async (
506
870
  patch: Partial<CalendarEventFields>,
507
871
  ): Promise<CalendarResult<ScopedWrite>> => {
508
872
  if (input.scope === RecurrenceScope.All) {
509
- if (
510
- input.recurrenceId === "" ||
511
- !hasRecurrence(calendar) ||
512
- !touchesTime(patch)
513
- ) {
873
+ if (!hasRecurrence(calendar) || !touchesTime(patch)) {
514
874
  return applyToMaster(calendar, collectionTimezone, patch);
515
875
  }
516
- return applyToSeriesFrom(
876
+ return applyToSeries(
517
877
  calendar,
518
878
  collectionTimezone,
519
879
  input.recurrenceId,