Skip to content

Commit d787868

Browse files
authored
[codex] Fix Day recurrence update loop (#1845)
* fix(web): stop recurrence update loop * test(web): stabilize someday month label tests
1 parent af61ec0 commit d787868

5 files changed

Lines changed: 175 additions & 19 deletions

File tree

packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEventSections.test.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import "@testing-library/jest-dom";
22
import { render, screen } from "@testing-library/react";
33
import dayjs from "@core/util/date/dayjs";
4-
import { afterAll, describe, expect, it, mock } from "bun:test";
4+
import {
5+
afterAll,
6+
beforeAll,
7+
describe,
8+
expect,
9+
it,
10+
mock,
11+
setSystemTime,
12+
} from "bun:test";
513

614
mock.module("@web/components/AbsoluteOverflowLoader", () => ({
715
AbsoluteOverflowLoader: () => (
@@ -37,6 +45,10 @@ mock.module("./SomedayEvents/SomedayEvents", () => ({
3745
const { SomedayEventSections } =
3846
require("./SomedayEventSections") as typeof import("./SomedayEventSections");
3947

48+
beforeAll(() => {
49+
setSystemTime(new Date("2026-05-20T12:00:00.000Z"));
50+
});
51+
4052
describe("SomedayEventSections", () => {
4153
it("keeps the planner sidebar stable while someday events refresh", () => {
4254
render(
@@ -71,5 +83,6 @@ describe("SomedayEventSections", () => {
7183
});
7284

7385
afterAll(() => {
86+
setSystemTime();
7487
mock.restore();
7588
});

packages/web/src/views/Forms/EventForm/DateControlsSection/RecurrenceSection/useRecurrence/useRecurrence.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,30 @@ describe("useRecurrence hook", () => {
113113
expect(result.current.freq).toBe(Frequency.MONTHLY);
114114
expect(result.current.interval).toBe(2);
115115
});
116+
117+
it("does not rewrite an unchanged recurring rule when the setter changes", () => {
118+
const event = {
119+
...baseEvent(),
120+
startDate: "2026-05-31T10:00:00.000Z",
121+
endDate: "2026-05-31T11:00:00.000Z",
122+
recurrence: {
123+
rule: ["RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU;COUNT=4"],
124+
},
125+
};
126+
const setEvent = mock();
127+
const nextSetEvent = mock();
128+
129+
const { rerender } = renderHook(
130+
({ setEventProp }) => useRecurrence(event, { setEvent: setEventProp }),
131+
{
132+
initialProps: { setEventProp: setEvent },
133+
},
134+
);
135+
136+
expect(setEvent).not.toHaveBeenCalled();
137+
138+
rerender({ setEventProp: nextSetEvent });
139+
140+
expect(nextSetEvent).not.toHaveBeenCalled();
141+
});
116142
});

packages/web/src/views/Forms/EventForm/DateControlsSection/RecurrenceSection/useRecurrence/useRecurrence.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ObjectId } from "bson";
2+
import fastDeepEqual from "fast-deep-equal/react";
23
import {
34
type Dispatch,
45
type SetStateAction,
@@ -67,6 +68,7 @@ export const useRecurrence = (
6768
const endDate = _endDate ?? dayjs().add(1, "hour").toRFC3339OffsetString();
6869
const _startDate = parseCompassEventDate(startDate);
6970
const hasRecurrence = (event?.recurrence?.rule?.length ?? 0) > 0;
71+
const currentRule = event?.recurrence?.rule;
7072

7173
const { options } = useMemo(() => {
7274
if (!hasRecurrence) {
@@ -169,15 +171,22 @@ export const useRecurrence = (
169171
useEffect(() => {
170172
if (!hasRecurrence) return;
171173

174+
const nextRule = JSON.parse(rule);
175+
if (fastDeepEqual(currentRule, nextRule)) return;
176+
172177
setEvent((gridEvent): Schema_Event | null => {
173178
if (!gridEvent) return gridEvent;
174179

180+
if (fastDeepEqual(gridEvent.recurrence?.rule, nextRule)) {
181+
return gridEvent;
182+
}
183+
175184
return {
176185
...gridEvent,
177-
recurrence: { ...(gridEvent.recurrence ?? {}), rule: JSON.parse(rule) },
186+
recurrence: { ...(gridEvent.recurrence ?? {}), rule: nextRule },
178187
};
179188
});
180-
}, [rule, hasRecurrence, setEvent]);
189+
}, [currentRule, rule, hasRecurrence, setEvent]);
181190

182191
return {
183192
hasRecurrence,

packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.test.tsx

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import { configureStore } from "@reduxjs/toolkit";
12
import { act, renderHook } from "@testing-library/react";
23
import { ObjectId } from "bson";
4+
import { type PropsWithChildren } from "react";
5+
import { Provider } from "react-redux";
36
import { Origin, Priorities } from "@core/constants/core.constants";
4-
import { RecurringEventUpdateScope } from "@core/types/event.types";
7+
import {
8+
RecurringEventUpdateScope,
9+
type Schema_Event,
10+
} from "@core/types/event.types";
11+
import { createInitialState } from "@web/__tests__/utils/state/store.test.util";
512
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
13+
import { reducers } from "@web/store/reducers";
614
import { useDraftConfirmation } from "./useDraftConfirmation";
715
import { describe, expect, it, mock } from "bun:test";
816

@@ -32,18 +40,43 @@ const createDraft = (
3240

3341
const renderDraftConfirmation = ({
3442
draft = createDraft(),
43+
events = [],
3544
isInstance = false,
3645
isRecurrence = false,
3746
isSomeday = false,
3847
}: {
3948
draft?: Schema_GridEvent;
49+
events?: Schema_Event[];
4050
isInstance?: boolean;
4151
isRecurrence?: boolean;
4252
isSomeday?: boolean;
4353
} = {}) => {
4454
const discard = mock();
4555
const deleteEvent = mock();
4656
const submit = mock();
57+
const preloadedState = createInitialState();
58+
preloadedState.events.entities!.value = events.reduce<
59+
Record<string, Schema_Event>
60+
>((entities, event) => {
61+
if (event._id) {
62+
entities[event._id] = event;
63+
}
64+
65+
return entities;
66+
}, {});
67+
const store = configureStore({
68+
reducer: reducers,
69+
preloadedState,
70+
middleware: (getDefaultMiddleware) =>
71+
getDefaultMiddleware({
72+
immutableCheck: false,
73+
serializableCheck: false,
74+
thunk: false,
75+
}),
76+
});
77+
const wrapper = ({ children }: PropsWithChildren) => (
78+
<Provider store={store}>{children}</Provider>
79+
);
4780

4881
const context = {
4982
actions: {
@@ -59,7 +92,9 @@ const renderDraftConfirmation = ({
5992
},
6093
} as unknown as Parameters<typeof useDraftConfirmation>[0];
6194

62-
const { result } = renderHook(() => useDraftConfirmation(context));
95+
const { result } = renderHook(() => useDraftConfirmation(context), {
96+
wrapper,
97+
});
6398

6499
return { deleteEvent, discard, result, submit };
65100
};
@@ -88,14 +123,23 @@ describe("useDraftConfirmation", () => {
88123
expect(discard).toHaveBeenCalledTimes(1);
89124
});
90125

91-
it("opens the update scope dialog for existing multi-occurrence recurring drafts", async () => {
92-
const draft = createDraft({
126+
it("opens the update scope dialog for existing multi-occurrence recurring instances", async () => {
127+
const baseEventId = new ObjectId().toString();
128+
const baseEvent = createDraft({
129+
_id: baseEventId,
93130
recurrence: {
94-
eventId: new ObjectId().toString(),
95131
rule: ["FREQ=WEEKLY;COUNT=4"],
96132
},
97133
});
98-
const { discard, result, submit } = renderDraftConfirmation({ draft });
134+
const draft = createDraft({
135+
recurrence: {
136+
eventId: baseEventId,
137+
},
138+
});
139+
const { discard, result, submit } = renderDraftConfirmation({
140+
draft,
141+
events: [baseEvent],
142+
});
99143

100144
await act(async () => {
101145
await result.current.onSubmit(draft);
@@ -107,14 +151,23 @@ describe("useDraftConfirmation", () => {
107151
expect(discard).not.toHaveBeenCalled();
108152
});
109153

110-
it("submits a single-occurrence recurring draft without opening the update scope dialog", async () => {
111-
const draft = createDraft({
154+
it("submits a single-occurrence recurring instance without opening the update scope dialog", async () => {
155+
const baseEventId = new ObjectId().toString();
156+
const baseEvent = createDraft({
157+
_id: baseEventId,
112158
recurrence: {
113-
eventId: new ObjectId().toString(),
114159
rule: ["RRULE:FREQ=WEEKLY;COUNT=1"],
115160
},
116161
});
117-
const { discard, result, submit } = renderDraftConfirmation({ draft });
162+
const draft = createDraft({
163+
recurrence: {
164+
eventId: baseEventId,
165+
},
166+
});
167+
const { discard, result, submit } = renderDraftConfirmation({
168+
draft,
169+
events: [baseEvent],
170+
});
118171

119172
await act(async () => {
120173
await result.current.onSubmit(draft);
@@ -129,4 +182,34 @@ describe("useDraftConfirmation", () => {
129182
);
130183
expect(discard).toHaveBeenCalledTimes(1);
131184
});
185+
186+
it("opens the update scope dialog before deleting recurring timed drafts", async () => {
187+
const draft = createDraft({
188+
recurrence: {
189+
rule: ["RRULE:FREQ=WEEKLY;COUNT=4"],
190+
},
191+
});
192+
const { deleteEvent, discard, result } = renderDraftConfirmation({
193+
draft,
194+
isRecurrence: true,
195+
});
196+
197+
await act(async () => {
198+
await result.current.onDelete();
199+
});
200+
201+
expect(result.current.isRecurrenceUpdateScopeDialogOpen).toBe(true);
202+
expect(result.current.finalDraft).toBeNull();
203+
expect(deleteEvent).not.toHaveBeenCalled();
204+
expect(discard).not.toHaveBeenCalled();
205+
206+
act(() => {
207+
result.current.onUpdateScopeChange(RecurringEventUpdateScope.ALL_EVENTS);
208+
});
209+
210+
expect(deleteEvent).toHaveBeenCalledWith(
211+
RecurringEventUpdateScope.ALL_EVENTS,
212+
);
213+
expect(discard).toHaveBeenCalledTimes(1);
214+
});
132215
});

packages/web/src/views/Week/components/Draft/hooks/state/useDraftConfirmation.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { useCallback, useState } from "react";
33
import { RecurringEventUpdateScope } from "@core/types/event.types";
44
import { CompassEventRRule } from "@core/util/event/compass.event.rrule";
55
import { type Schema_GridEvent } from "@web/common/types/web.event.types";
6+
import { type Entities_Event } from "@web/ducks/events/event.types";
7+
import { selectEventEntities } from "@web/ducks/events/selectors/event.selectors";
8+
import { useAppSelector } from "@web/store/store.hooks";
69
import { type useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext";
710

8-
const hasMultipleRecurrenceOccurrences = (event: Schema_GridEvent): boolean => {
9-
const rule = event.recurrence?.rule;
10-
11+
const hasMultipleRecurrenceOccurrences = (
12+
event: Schema_GridEvent,
13+
rule: string[] | null | undefined,
14+
): boolean => {
1115
if (!Array.isArray(rule) || rule.length === 0) {
1216
return true;
1317
}
@@ -26,6 +30,23 @@ const hasMultipleRecurrenceOccurrences = (event: Schema_GridEvent): boolean => {
2630
}
2731
};
2832

33+
const getScopeDecisionRecurrenceRule = (
34+
event: Schema_GridEvent,
35+
eventEntities: Entities_Event,
36+
): string[] | null | undefined => {
37+
const rule = event.recurrence?.rule;
38+
if (Array.isArray(rule) || rule === null) {
39+
return rule;
40+
}
41+
42+
const baseEventId = event.recurrence?.eventId;
43+
if (!baseEventId) {
44+
return undefined;
45+
}
46+
47+
return eventEntities[baseEventId]?.recurrence?.rule;
48+
};
49+
2950
export const useDraftConfirmation = ({
3051
actions,
3152
state,
@@ -34,6 +55,7 @@ export const useDraftConfirmation = ({
3455
const { isInstance, isRecurrence } = actions;
3556
const { draft } = state;
3657
const isSomeday = actions.isSomeday();
58+
const eventEntities = useAppSelector(selectEventEntities);
3759

3860
const [
3961
isRecurrenceUpdateScopeDialogOpen,
@@ -59,7 +81,7 @@ export const useDraftConfirmation = ({
5981

6082
const onSubmit = useCallback(
6183
async (_draft: Schema_GridEvent) => {
62-
const rule = _draft.recurrence?.rule;
84+
const rule = getScopeDecisionRecurrenceRule(_draft, eventEntities);
6385
const draftIsInstance = ObjectId.isValid(
6486
_draft.recurrence?.eventId ?? "",
6587
);
@@ -69,7 +91,10 @@ export const useDraftConfirmation = ({
6991
isExistingDraft && (isRecurrence() || draftIsRecurring);
7092
const instanceEvent = isInstance() || draftIsInstance;
7193
const toStandAlone = instanceEvent && rule === null;
72-
const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(_draft);
94+
const hasMultipleOccurrences = hasMultipleRecurrenceOccurrences(
95+
_draft,
96+
rule,
97+
);
7398
const isSingleOccurrenceInstance =
7499
isRecurringEvent && instanceEvent && !hasMultipleOccurrences;
75100
const shouldAskForUpdateScope =
@@ -97,7 +122,7 @@ export const useDraftConfirmation = ({
97122
submit(_draft, applyTo);
98123
discard();
99124
},
100-
[submit, isRecurrence, isInstance, discard],
125+
[submit, isRecurrence, isInstance, discard, eventEntities],
101126
);
102127

103128
const onDelete = useCallback(async () => {

0 commit comments

Comments
 (0)