Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/web/src/events/grid-event-draft.adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,20 @@ test("a patch that echoes the hydrated rule unchanged keeps the draft's recurren
});
});

test("a patch with semantically equal INTERVAL=1 drift keeps preserve", () => {
const draft = editGridEventDraft(occurrenceEvent);
if (!draft) throw new Error("Expected scheduled event draft");

// SERIES_RULES omit INTERVAL; the form rebuild re-emits INTERVAL=1.
const updated = patchGridDraftRecurrence(
draft,
["RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE"],
SERIES_RULES,
);

expect(updated.values.recurrence).toEqual({ kind: "preserve" });
});

test("a patch with a genuinely different rule converts the draft to an explicit series edit", () => {
const draft = editGridEventDraft(occurrenceEvent);
if (!draft) throw new Error("Expected scheduled event draft");
Expand Down
107 changes: 97 additions & 10 deletions packages/web/src/events/grid-event-draft.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fastDeepEqual from "fast-deep-equal/react";
import { ObjectId } from "bson";
import { type Weekday } from "rrule";
import { Origin } from "@core/constants/core.constants";
import { type Calendar } from "@core/types/calendar.contracts";
import { type CompassEvent } from "@core/types/compass-event.contracts";
Expand All @@ -10,6 +11,7 @@ import {
} from "@core/types/event-color.contracts";
import { type RecurrenceScope } from "@core/types/event-command.contracts";
import dayjs from "@core/util/date/dayjs";
import { CompassEventRRule } from "@core/util/event/compass.event.rrule";
import { type GridEvent } from "@web/common/types/web.event.types";
import { getBrowserTimeZone } from "@web/common/utils/datetime/web.date.util";
import { gridEventDefaultPosition } from "@web/common/utils/event/event.util";
Expand Down Expand Up @@ -341,6 +343,89 @@ export function resolveDraftRecurrenceRules(
return Array.isArray(rule) ? [...rule] : [];
}

export function scheduleDatesFromDraft(draft: GridEventDraft) {
const { schedule } = draft.values;

if (schedule.kind === "allDay") {
return {
startDate: dayjs(schedule.start).toYearMonthDayString(),
endDate: dayjs(schedule.end).toYearMonthDayString(),
};
}

return {
startDate:
dayjs(schedule.start).format() || dayjs().toRFC3339OffsetString(),
endDate:
dayjs(schedule.end).format() ||
dayjs().add(1, "hour").toRFC3339OffsetString(),
};
}

const weekdayNumber = (day: number | Weekday): number =>
typeof day === "number" ? day : day.weekday;

export const sortedByweekday = (
byweekday: Array<number | Weekday> | null | undefined,
): number[] => (byweekday ?? []).map(weekdayNumber).sort((a, b) => a - b);

const normalizedCount = (count: number | null | undefined): number | null =>
count == null || count === 0 ? null : count;

const untilEqual = (a: Date | null | undefined, b: Date | null | undefined) => {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return dayjs(a).isSame(b);
};

// Pattern-only RRULE equality for open-for-edit / patch: ignore serialization
// drift (INTERVAL=1, WKST defaults, param/BYDAY order) and never treat the
// occurrence's clicked dtstart as a recurrence edit.
// Whitelist matches RecurrenceSection's editable surface (freq/interval/
// weekdays/until/count). Fields the form cannot edit today (bymonthday,
// bysetpos, nth-weekday) are intentionally ignored — extend this list if
// the UI gains those controls, or edits to them will not flip preserve→series.
export function recurrenceRulesSemanticallyEqual(
a: readonly string[],
b: readonly string[],
dates: { startDate: string; endDate: string },
): boolean {
if (a.length === 0 && b.length === 0) return true;
if (a.length === 0 || b.length === 0) return false;

const shell = {
_id: new ObjectId(),
startDate: dates.startDate,
endDate: dates.endDate,
};
const optsA = new CompassEventRRule({
...shell,
recurrence: { rule: [...a] },
}).options;
const optsB = new CompassEventRRule({
...shell,
recurrence: { rule: [...b] },
}).options;

return (
optsA.freq === optsB.freq &&
(optsA.interval ?? 1) === (optsB.interval ?? 1) &&
byweekdayEqual(optsA.byweekday, optsB.byweekday) &&
normalizedCount(optsA.count) === normalizedCount(optsB.count) &&
untilEqual(optsA.until, optsB.until)
);
}

function byweekdayEqual(
a: Array<number | Weekday> | null | undefined,
b: Array<number | Weekday> | null | undefined,
): boolean {
const left = sortedByweekday(a);
const right = sortedByweekday(b);
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}

// The draft renders its own recurrence preview separately (Draft.tsx's
// getRecurringDraftPreviews), but the *saved* sibling occurrences of the
// series being edited still come through the normal week query and render
Expand Down Expand Up @@ -371,32 +456,34 @@ export function patchGridDraftRecurrence(
seriesRules?: readonly string[],
): GridEventDraft {
const currentRules = resolveDraftRecurrenceRules(draft, seriesRules);
const ruleUnchanged = fastDeepEqual(currentRules, [...nextRules]);
const ruleUnchanged = recurrenceRulesSemanticallyEqual(
currentRules,
nextRules,
scheduleDatesFromDraft(draft),
);
// Only useRecurrence calls this, and only with an explicit user edit (a
// weekday/frequency/until change, or the Repeat toggle turned off) - a
// draft that hasn't touched recurrence never reaches here, so it keeps
// "preserve" from editGridEventDraft instead. Empty rules is therefore
// always an explicit clear, on both create and edit drafts: "single",
// never "preserve" (which for an edit draft would just resolve back to
// the source event's original rules, making the Repeat toggle a no-op).
const recurrence = ruleUnchanged
? draft.values.recurrence
: nextRules.length > 0
if (ruleUnchanged) return draft;

const recurrence =
nextRules.length > 0
? { kind: "series" as const, rules: [...nextRules] }
: ({ kind: "single" } as const);

// The two branches look identical, but each is required to keep
// GridEventDraft's discriminated union narrowed (see
// replaceGridDraftSchedule above) - `recurrence` can structurally carry
// "preserve" here (from the ruleUnchanged passthrough on an edit draft),
// which isn't assignable to a create draft's NewEventRecurrenceDraft.
// replaceGridDraftSchedule above).
if (draft.kind === "create") {
return {
...draft,
values: {
...draft.values,
recurrence:
recurrence.kind === "preserve" ? { kind: "single" } : recurrence,
recurrence,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,50 @@ const baseDraft = () =>
timeZone: "UTC",
});

const mountPreserveOccurrence = (
seriesRules: string[],
seriesId = EventIdSchema.parse("0123456789abcdefaaaaaaaa"),
) => {
const source = createMockEvent({
schedule: SCHEDULE,
recurrence: {
kind: "occurrence",
seriesId,
},
});
const editedDraft = editGridEventDraft(source);
if (!editedDraft) throw new Error("expected edit draft");

expect(editedDraft.values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(editedDraft)).toBeNull();

let draft: GridEventDraft = editedDraft;
let setDraftCalls = 0;
const setDraft: Dispatch<SetStateAction<GridEventDraft | null>> = (
updater,
) => {
setDraftCalls++;
const next = typeof updater === "function" ? updater(draft) : updater;
if (next) draft = next;
};

const hook = renderHook(() =>
useRecurrence(draft, { setDraft }, seriesRules),
);

return {
draft: () => draft,
setDraftCalls: () => setDraftCalls,
result: hook.result,
rerender: hook.rerender,
assertUntouched: () => {
expect(setDraftCalls).toBe(0);
expect(draft.values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(draft)).toBeNull();
},
};
};

describe("useRecurrence hook", () => {
it("initializes with no recurrence", () => {
const draft = baseDraft();
Expand Down Expand Up @@ -220,33 +264,9 @@ describe("useRecurrence hook", () => {
// preserve→series, suppress earlier siblings, and leave only the clicked
// day + forward previews visible.
it("does not rewrite a preserve occurrence draft whose series omits INTERVAL=1", () => {
const seriesRules = ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"];
const source = createMockEvent({
schedule: SCHEDULE,
recurrence: {
kind: "occurrence",
seriesId: EventIdSchema.parse("0123456789abcdefaaaaaaaa"),
},
});
const editedDraft = editGridEventDraft(source);
if (!editedDraft) throw new Error("expected edit draft");

expect(editedDraft.values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(editedDraft)).toBeNull();

let draft: GridEventDraft = editedDraft;
let setDraftCalls = 0;
const setDraft: Dispatch<SetStateAction<GridEventDraft | null>> = (
updater,
) => {
setDraftCalls++;
const next = typeof updater === "function" ? updater(draft) : updater;
if (next) draft = next;
};

const { result, rerender } = renderHook(() =>
useRecurrence(draft, { setDraft }, seriesRules),
);
const { result, rerender, assertUntouched } = mountPreserveOccurrence([
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
]);

expect(result.current.hasRecurrence).toBe(true);
expect(result.current.freq).toBe(Frequency.WEEKLY);
Expand All @@ -257,15 +277,73 @@ describe("useRecurrence hook", () => {
"thursday",
"friday",
]);
expect(setDraftCalls).toBe(0);
expect(draft.values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(draft)).toBeNull();
assertUntouched();

rerender();
assertUntouched();
});

it("does not rewrite when series RRULE params are reordered", () => {
const { assertUntouched, rerender } = mountPreserveOccurrence([
"RRULE:INTERVAL=1;FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
]);

assertUntouched();
rerender();
assertUntouched();
});

it("does not rewrite when series BYDAY order differs from the rebuilt rule", () => {
const { assertUntouched, rerender, result } = mountPreserveOccurrence([
"RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TU,WE,TH",
]);

expect(result.current.weekDays).toEqual([
"friday",
"monday",
"tuesday",
"wednesday",
"thursday",
]);
assertUntouched();
rerender();
assertUntouched();
});

it("does not rewrite when series includes a default WKST", () => {
const { assertUntouched, rerender } = mountPreserveOccurrence([
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;WKST=MO",
]);

assertUntouched();
rerender();
assertUntouched();
});

it("writes a real weekday edit and flips preserve to series", () => {
const seriesId = EventIdSchema.parse("0123456789abcdefaaaaaaaa");
const { draft, setDraftCalls, result, rerender } = mountPreserveOccurrence(
["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"],
seriesId,
);

expect(setDraftCalls()).toBe(0);
expect(draft().values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(draft())).toBeNull();

act(() => {
result.current.setWeekDays([
"monday",
"tuesday",
"wednesday",
"thursday",
]);
});
rerender();

expect(setDraftCalls).toBe(0);
expect(draft.values.recurrence).toEqual({ kind: "preserve" });
expect(suppressedSeriesIdForDraft(draft)).toBeNull();
expect(setDraftCalls()).toBeGreaterThan(0);
expect(draft().values.recurrence).toMatchObject({ kind: "series" });
expect(suppressedSeriesIdForDraft(draft())).toBe(seriesId);
});

// Regression for React error #185 (max update depth exceeded): a timed
Expand Down
Loading