@remit/calendar-service 0.0.9 → 0.0.11
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 +1 -1
- package/src/errors.ts +2 -1
- package/src/index.ts +1 -0
- package/src/scope.test.ts +733 -0
- package/src/scope.ts +416 -56
- package/src/window.test.ts +73 -0
- package/src/window.ts +49 -0
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
|
+
});
|