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; }); }