From e420e3d5767ea56336454c0d872004ed292b3cb8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:10:27 +0200 Subject: [PATCH] fix: keep an occurrence's own change when another one moves onto its day Changing every occurrence of a series takes its changed and cancelled occurrences along, and two can still meet on one occurrence (bda118b). A series on Mondays and Tuesdays that starts a day and an hour later takes its first Monday's change onto the Tuesday, at the new time, where the Tuesday's own override lands as well. Only an override that stayed exactly where it was won such a meeting. Here neither did, so the order the server listed the overrides in decided, and it could keep the Monday's change and lose the Tuesday's cancellation. The override that stays where it is now wins, then the one that stays on its own day, then the first; the others are left out and logged, as in the web app. The rest of a series split "this and following" keys its overrides the same way. --- .../__tests__/recurrence-scope-edit.test.ts | 19 +++++++++++++++++++ src/lib/recurrence-scope-edit.ts | 19 +++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/recurrence-scope-edit.test.ts b/src/lib/__tests__/recurrence-scope-edit.test.ts index 01d6c7ab..b1c6227e 100644 --- a/src/lib/__tests__/recurrence-scope-edit.test.ts +++ b/src/lib/__tests__/recurrence-scope-edit.test.ts @@ -1325,6 +1325,25 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides expect(created.recurrenceOverrides!['2026-12-14T10:00:00']).toMatchObject({ title: 'Planning, Monday special' }); expect(created.recurrenceOverrides!['2026-12-15T10:00:00']).toEqual({ excluded: true }); }); + + // The series starts on Tuesday 8 December: its first Monday is gone, and + // moved along, that Monday's override meets the Tuesday's cancellation. + // Moved an hour as well, neither stays where it was, and the server's + // order of the overrides decided which one the Tuesday kept. + it('keeps the cancellation of the Tuesday the first Monday\'s override moves onto, in either order', () => { + const entries: Array<[string, unknown]> = [ + ['2026-12-07T10:00:00', special('2026-12-07T10:00:00', 'Kick-off')], + ['2026-12-08T10:00:00', { excluded: true }], + ]; + for (const [hours, tuesday] of [[24, '2026-12-08T10:00:00'], [25, '2026-12-08T11:00:00']] as const) { + for (const order of [entries, [...entries].reverse()]) { + const master = withDays({ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'tu' }], count: 8 }, Object.fromEntries(order)); + const { updates } = allFrom(master, '2026-12-21T10:00:00', hours); + + expect(updates.recurrenceOverrides).toEqual({ [tuesday]: { excluded: true } }); + } + } + }); }); // Two occurrences can still meet on one key - an hourly series turned diff --git a/src/lib/recurrence-scope-edit.ts b/src/lib/recurrence-scope-edit.ts index a96def8f..42433cb0 100644 --- a/src/lib/recurrence-scope-edit.ts +++ b/src/lib/recurrence-scope-edit.ts @@ -144,10 +144,14 @@ function overrideKeyIn(key: string, from: SeriesFrame, to: SeriesFrame, anchor = /** * `overrides` with the key each takes (keyOf), those without one left out, - * and never two on one key: an override that stays where it is comes first, - * and one that would land on a key already taken is left out and logged - * rather than overwrite it. Two occurrences of one series never share a - * recurrence id. + * and never two on one key. Of those that meet on one, the override that + * stays where it is keeps the key, else the one that stays on its day, else + * the first; the others are left out and logged rather than overwrite it. + * Two occurrences of one series never share a recurrence id. A series on + * Mondays and Tuesdays that starts a day and an hour later takes its first + * Monday's override onto the Tuesday, at its new time, where neither stays + * where it was: the server's order of the overrides decided, and could keep + * the Monday's change over the Tuesday's cancellation. */ function rekeyed( overrides: OverrideMap, @@ -156,14 +160,17 @@ function rekeyed( const moves = Object.entries(overrides) .map(([key, override]) => ({ key, target: keyOf(key), override })) .filter((move): move is { key: string; target: string; override: OverrideMap[string] } => move.target !== null); - moves.sort((a, b) => Number(b.target === b.key) - Number(a.target === a.key)); + const rank = (move: { key: string; target: string }) => + (move.target === move.key ? 2 : move.target.slice(0, 10) === move.key.slice(0, 10) ? 1 : 0); + // Stable: within a rank, the server's order. + moves.sort((a, b) => rank(b) - rank(a)); const taken = new Set(); return moves.filter((move) => { if (!taken.has(move.target)) { taken.add(move.target); return true; } - debug.warn('calendar', 'Two recurrence overrides map to one occurrence; keeping the first', move.target, move.key); + debug.warn('calendar', 'Two recurrence overrides map to one occurrence; keeping one', move.target, move.key); return false; }); }