mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep occurrence changes on their days when a series moves or ends
Three faults in how a series' changed and cancelled occurrences follow a change of the whole series (found in review, live on 1.0 and 0.16.23 for the web's version of the same code): - A rule that names its days - a series on Mondays and Tuesdays, one on the 5th and the 6th of each month - keeps its occurrences on those days when its start moves. Moving every occurrence by a day moved each override along anyway, so the Monday's landed on the Tuesday's key and one silently overwrote the other: the cancelled Tuesday came back with the Monday's title, and the Monday lost its change. Under such a rule every override but the first occurrence's now stays on its day, at the series' new time; the same holds for the new series of "this and following". Two overrides that still meet on one key (an hourly series turned all-day) no longer overwrite each other: the one that stays where it is, else the first, is kept, and the others are left out and logged. - "Does not repeat" for every occurrence kept the first occurrence's override on the single event, because a series' start counts as an occurrence of any rule: cancelled, 1.0 left the event out of every range query (still stored); retitled, 0.16.23 listed it twice. A series made single now keeps no overrides. - The SEQUENCE a change of every occurrence raises (0.16.6 to 0.16.23 refuse a lower one) came from the series as the app held it. A series opened from a reminder or a link was that copy, possibly older than the server's. The store now reads a series from the server every time a scope edit asks for it, so the raise, the overrides a split or a move rewrites and the rule all start from what the server holds.
This commit is contained in:
@@ -1239,3 +1239,144 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series
|
|||||||
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A rule that names its days keeps its occurrences on them: a Monday and
|
||||||
|
// Tuesday series moved by a day for every occurrence still meets on Mondays
|
||||||
|
// and Tuesdays. Moved along, the Monday's override landed on the Tuesday's
|
||||||
|
// and one overwrote the other: the cancelled Tuesday came back with the
|
||||||
|
// Monday's title, and the Monday lost its change (live on 1.0 and 0.16.23).
|
||||||
|
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides of a series whose rule names its days on Stalwart %s', (_label, version) => {
|
||||||
|
const participants = { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'accepted') };
|
||||||
|
const special = (start: string, title: string) => (version === '1.0'
|
||||||
|
? { title }
|
||||||
|
: { updated: '2026-11-20T09:00:00Z', start, duration: 'PT1H', participants, title });
|
||||||
|
function withDays(rule: Record<string, unknown>, overrides: Record<string, unknown>, start = '2026-12-07T10:00:00'): CalendarEvent {
|
||||||
|
return {
|
||||||
|
...series(version), start, utcStart: berlinUtc(start), utcEnd: berlinUtc(start, 1),
|
||||||
|
participants, recurrenceRules: [{ '@type': 'RecurrenceRule', ...rule }], recurrenceOverrides: overrides,
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
}
|
||||||
|
const mondayAndTuesday = () => withDays(
|
||||||
|
{ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'tu' }], count: 8 },
|
||||||
|
{ '2026-12-14T10:00:00': special('2026-12-14T10:00:00', 'Planning, Monday special'), '2026-12-15T10:00:00': { excluded: true } },
|
||||||
|
);
|
||||||
|
const allFrom = (master: CalendarEvent, recurrenceId: string, hours: number) => {
|
||||||
|
const occurrence = {
|
||||||
|
...master, id: 'maaaaaf', baseEventId: 'b', start: recurrenceId, recurrenceId, recurrenceIdTimeZone: 'Europe/Berlin',
|
||||||
|
utcStart: berlinUtc(recurrenceId), utcEnd: berlinUtc(recurrenceId, 1),
|
||||||
|
} as CalendarEvent;
|
||||||
|
const updates = seriesUpdatesFromOccurrence(occurrence, master, editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, hours)), context);
|
||||||
|
// Unchanged overrides are not written again.
|
||||||
|
return { updates, overrides: (updates.recurrenceOverrides ?? master.recurrenceOverrides) as Record<string, Record<string, unknown>> };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps each override on its day when every occurrence moves by a day', () => {
|
||||||
|
const master = mondayAndTuesday();
|
||||||
|
const { updates, overrides } = allFrom(master, '2026-12-21T10:00:00', 24);
|
||||||
|
|
||||||
|
expect(updates.start).toBe('2026-12-08T10:00:00');
|
||||||
|
expect(Object.keys(overrides).sort()).toEqual(['2026-12-14T10:00:00', '2026-12-15T10:00:00']);
|
||||||
|
expect(overrides['2026-12-14T10:00:00']).toMatchObject({ title: 'Planning, Monday special' });
|
||||||
|
if (version === '0.16') expect(overrides['2026-12-14T10:00:00'].start).toBe('2026-12-14T10:00:00');
|
||||||
|
expect(overrides['2026-12-15T10:00:00']).toEqual({ excluded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps each override on its day, at the new time, when the series also moves by an hour', () => {
|
||||||
|
const master = mondayAndTuesday();
|
||||||
|
const { overrides } = allFrom(master, '2026-12-21T10:00:00', 25);
|
||||||
|
|
||||||
|
expect(Object.keys(overrides).sort()).toEqual(['2026-12-14T11:00:00', '2026-12-15T11:00:00']);
|
||||||
|
expect(overrides['2026-12-14T11:00:00']).toMatchObject({ title: 'Planning, Monday special' });
|
||||||
|
if (version === '0.16') expect(overrides['2026-12-14T11:00:00'].start).toBe('2026-12-14T11:00:00');
|
||||||
|
expect(overrides['2026-12-15T11:00:00']).toEqual({ excluded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps month days as well', () => {
|
||||||
|
// The 5th and the 6th of each month; 5 January retitled, 6 January cancelled.
|
||||||
|
const master = withDays(
|
||||||
|
{ frequency: 'monthly', byMonthDay: [5, 6], count: 6 },
|
||||||
|
{ '2027-01-05T10:00:00': special('2027-01-05T10:00:00', 'Planning, January'), '2027-01-06T10:00:00': { excluded: true } },
|
||||||
|
'2026-12-05T10:00:00',
|
||||||
|
);
|
||||||
|
const { overrides } = allFrom(master, '2027-02-05T10:00:00', 24);
|
||||||
|
|
||||||
|
expect(Object.keys(overrides).sort()).toEqual(['2027-01-05T10:00:00', '2027-01-06T10:00:00']);
|
||||||
|
expect(overrides['2027-01-05T10:00:00']).toMatchObject({ title: 'Planning, January' });
|
||||||
|
expect(overrides['2027-01-06T10:00:00']).toEqual({ excluded: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps them on their days for this and the following occurrences too', async () => {
|
||||||
|
const master = mondayAndTuesday();
|
||||||
|
const occurrence = {
|
||||||
|
...master, id: 'maaaaaf', baseEventId: 'b', start: '2026-12-08T10:00:00', recurrenceId: '2026-12-08T10:00:00',
|
||||||
|
recurrenceIdTimeZone: 'Europe/Berlin', utcStart: berlinUtc('2026-12-08T10:00:00'), utcEnd: berlinUtc('2026-12-08T10:00:00', 1),
|
||||||
|
} as CalendarEvent;
|
||||||
|
const deps = actions(master);
|
||||||
|
|
||||||
|
// From Tuesday 8 December on, moved by a day to the Wednesday.
|
||||||
|
await saveOccurrenceEdit('this_and_future', {
|
||||||
|
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence, moveBy(occurrence, 24)),
|
||||||
|
sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
const created = deps.createEvent.mock.calls[0][0];
|
||||||
|
expect(created.start).toBe('2026-12-09T10:00:00');
|
||||||
|
expect(Object.keys(created.recurrenceOverrides ?? {}).sort()).toEqual(['2026-12-14T10:00:00', '2026-12-15T10:00:00']);
|
||||||
|
expect(created.recurrenceOverrides!['2026-12-14T10:00:00']).toMatchObject({ title: 'Planning, Monday special' });
|
||||||
|
expect(created.recurrenceOverrides!['2026-12-15T10:00:00']).toEqual({ excluded: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two occurrences can still meet on one key - an hourly series turned
|
||||||
|
// all-day puts every hour of a day on that day. One override is kept, the
|
||||||
|
// first; none overwrites another.
|
||||||
|
describe('overrides that would meet on one occurrence', () => {
|
||||||
|
it('keeps the first and leaves out the others instead of overwriting it', () => {
|
||||||
|
const master = {
|
||||||
|
...series('1.0'), start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z',
|
||||||
|
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'hourly', count: 3 }],
|
||||||
|
recurrenceOverrides: {
|
||||||
|
'2026-12-07T10:00:00': { title: 'First hour' },
|
||||||
|
'2026-12-07T11:00:00': { title: 'Second hour' },
|
||||||
|
'2026-12-07T12:00:00': { excluded: true },
|
||||||
|
},
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
const occurrence = {
|
||||||
|
...master, id: 'maaaaag', baseEventId: 'b', start: '2026-12-07T11:00:00', recurrenceId: '2026-12-07T11:00:00',
|
||||||
|
utcStart: '2026-12-07T10:00:00Z', utcEnd: '2026-12-07T11:00:00Z',
|
||||||
|
} as CalendarEvent;
|
||||||
|
const day = startOfDay(getEventStartDate(occurrence));
|
||||||
|
|
||||||
|
const updates = seriesUpdatesFromOccurrence(
|
||||||
|
occurrence, master,
|
||||||
|
editorUpdates(occurrence, { start: '2026-12-07T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }),
|
||||||
|
editorOn(occurrence, { when: { allDay: true, start: day, end: day } }), context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updates.recurrenceOverrides).toEqual({ '2026-12-07T00:00:00': { title: 'First hour' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Repeat: none" for every occurrence makes the series one event. Its start
|
||||||
|
// is an occurrence of any rule, so the first occurrence's override stayed on
|
||||||
|
// the single event: cancelled, 1.0 left the event out of every range query
|
||||||
|
// (still stored); retitled, 0.16.23 listed it twice (live).
|
||||||
|
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('a series made one event on Stalwart %s', (_label, version) => {
|
||||||
|
it('keeps none of its overrides', () => {
|
||||||
|
const first = version === '1.0'
|
||||||
|
? { excluded: true }
|
||||||
|
: { start: '2026-10-05T10:00:00', duration: 'PT1H', title: 'Kick-off', participants: series(version).participants };
|
||||||
|
const master = { ...series(version), recurrenceOverrides: { ...series(version).recurrenceOverrides, '2026-10-05T10:00:00': first } } as CalendarEvent;
|
||||||
|
const occurrence = serverOccurrence(version);
|
||||||
|
|
||||||
|
const updates = seriesUpdatesFromOccurrence(
|
||||||
|
occurrence, master,
|
||||||
|
// What the editor writes for "Does not repeat" on a series.
|
||||||
|
editorUpdates(occurrence, { recurrenceRules: null, recurrenceOverrides: null, excludedRecurrenceRules: null }),
|
||||||
|
editorOn(occurrence, { ruleChanged: true }), context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updates.recurrenceRules).toBeNull();
|
||||||
|
expect(updates.recurrenceOverrides).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
isValidTimeZone,
|
isValidTimeZone,
|
||||||
localDateTimeToInstant,
|
localDateTimeToInstant,
|
||||||
} from './calendar-timezone';
|
} from './calendar-timezone';
|
||||||
|
import { debug } from './debug';
|
||||||
|
|
||||||
/** What the event editor had when it saved, besides the event data it wrote. */
|
/** What the event editor had when it saved, besides the event data it wrote. */
|
||||||
export interface SeriesEdit {
|
export interface SeriesEdit {
|
||||||
@@ -103,6 +104,15 @@ interface SeriesFrame {
|
|||||||
recurrenceRules: CalendarEvent['recurrenceRules'];
|
recurrenceRules: CalendarEvent['recurrenceRules'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `rules` name the days their occurrences fall on (BYDAY,
|
||||||
|
* BYMONTHDAY, BYYEARDAY): a start moved by days does not take those
|
||||||
|
* occurrences along.
|
||||||
|
*/
|
||||||
|
function namesItsDays(rules: CalendarEvent['recurrenceRules']): boolean {
|
||||||
|
return (rules ?? []).some((rule) => !!rule.byDay?.length || !!rule.byMonthDay?.length || !!rule.byYearDay?.length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The key an override of the series `from` keeps in the series `to`, or
|
* The key an override of the series `from` keeps in the series `to`, or
|
||||||
* null when `to` has no such occurrence any more. It moves with its
|
* null when `to` has no such occurrence any more. It moves with its
|
||||||
@@ -112,16 +122,52 @@ interface SeriesFrame {
|
|||||||
* where it was - a weekday rule the start moved away from - keeps the key,
|
* where it was - a weekday rule the start moved away from - keeps the key,
|
||||||
* and an occurrence the new rule does not give is gone. A date the override
|
* and an occurrence the new rule does not give is gone. A date the override
|
||||||
* adds to the series, which no rule gives, moves along.
|
* adds to the series, which no rule gives, moves along.
|
||||||
|
*
|
||||||
|
* A rule that names its days keeps every occurrence but the anchor on its
|
||||||
|
* day, at the series' new time: moved by a day, the Monday of a Monday and
|
||||||
|
* Tuesday series is still that Monday. Moved along, its override landed on
|
||||||
|
* the Tuesday's and took its place: a cancelled Tuesday came back with the
|
||||||
|
* Monday's title, and the Monday lost its change (live on 1.0 and 0.16.23).
|
||||||
*/
|
*/
|
||||||
function overrideKeyIn(key: string, from: SeriesFrame, to: SeriesFrame, anchor = from.start): string | null {
|
function overrideKeyIn(key: string, from: SeriesFrame, to: SeriesFrame, anchor = from.start): string | null {
|
||||||
const shifted = movedLike(key, anchor, to.start);
|
const shifted = movedLike(key, anchor, to.start);
|
||||||
const moved = to.showWithoutTime ? `${shifted.slice(0, 10)}T00:00:00` : shifted;
|
const moved = to.showWithoutTime ? `${shifted.slice(0, 10)}T00:00:00` : shifted;
|
||||||
if (!seriesOccursAt(from.start, from.recurrenceRules, key)) return moved;
|
if (!seriesOccursAt(from.start, from.recurrenceRules, key)) return moved;
|
||||||
|
if (namesItsDays(to.recurrenceRules) && wallClockMs(key) !== wallClockMs(anchor)) {
|
||||||
|
const sameDay = `${key.slice(0, 10)}${moved.slice(10)}`;
|
||||||
|
return seriesOccursAt(to.start, to.recurrenceRules, sameDay) ? sameDay : null;
|
||||||
|
}
|
||||||
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
|
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
|
||||||
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
|
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
function rekeyed(
|
||||||
|
overrides: OverrideMap,
|
||||||
|
keyOf: (key: string) => string | null,
|
||||||
|
): Array<{ key: string; target: string; override: OverrideMap[string] }> {
|
||||||
|
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 taken = new Set<string>();
|
||||||
|
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);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `entry` with its attendees asked again (participantsAskedAgain): a whole
|
* `entry` with its attendees asked again (participantsAskedAgain): a whole
|
||||||
* participants map (0.16) with their answers back at needs-action, a
|
* participants map (0.16) with their answers back at needs-action, a
|
||||||
@@ -362,13 +408,17 @@ function seriesOverrides(
|
|||||||
showWithoutTime: series.showWithoutTime ?? !!master.showWithoutTime,
|
showWithoutTime: series.showWithoutTime ?? !!master.showWithoutTime,
|
||||||
recurrenceRules: options.ruleChanged ? series.recurrenceRules ?? null : master.recurrenceRules,
|
recurrenceRules: options.ruleChanged ? series.recurrenceRules ?? null : master.recurrenceRules,
|
||||||
};
|
};
|
||||||
|
// A series made single ("Repeat: none") keeps no overrides. Its start is
|
||||||
|
// an occurrence of any rule (seriesOccursAt), so the first occurrence's
|
||||||
|
// override stayed on the single event: cancelled, it hid the event from
|
||||||
|
// every range query on 1.0 (still stored); retitled, 0.16.23 listed it
|
||||||
|
// twice (live).
|
||||||
|
if (options.ruleChanged && !to.recurrenceRules?.length) return null;
|
||||||
const turned = from.showWithoutTime !== to.showWithoutTime;
|
const turned = from.showWithoutTime !== to.showWithoutTime;
|
||||||
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
|
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
|
||||||
|
|
||||||
const result: OverrideMap = {};
|
const result: OverrideMap = {};
|
||||||
for (const [key, override] of Object.entries(overrides)) {
|
for (const { key, target, override } of rekeyed(overrides, (key) => overrideKeyIn(key, from, to))) {
|
||||||
const target = overrideKeyIn(key, from, to);
|
|
||||||
if (!target) continue;
|
|
||||||
const { updated: _updated, ...rest } = override as OverrideEntry;
|
const { updated: _updated, ...rest } = override as OverrideEntry;
|
||||||
let entry: OverrideEntry = rest;
|
let entry: OverrideEntry = rest;
|
||||||
if (!entry.excluded) {
|
if (!entry.excluded) {
|
||||||
@@ -380,7 +430,10 @@ function seriesOverrides(
|
|||||||
const pinned = wallClockMs(entry.start) === wallClockMs(key)
|
const pinned = wallClockMs(entry.start) === wallClockMs(key)
|
||||||
&& (entry.timeZone === undefined || entry.timeZone === master.timeZone);
|
&& (entry.timeZone === undefined || entry.timeZone === master.timeZone);
|
||||||
follows = pinned || key === opened;
|
follows = pinned || key === opened;
|
||||||
if (follows) entry.start = movedLike(entry.start, from.start, to.start);
|
// The time its occurrence had is the time it has now; one of its
|
||||||
|
// own takes the editor's move when the editor was opened on it.
|
||||||
|
if (pinned) entry.start = target;
|
||||||
|
else if (follows) entry.start = movedLike(entry.start, from.start, to.start);
|
||||||
}
|
}
|
||||||
if (resized && typeof entry.duration === 'string'
|
if (resized && typeof entry.duration === 'string'
|
||||||
&& (key === opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(master.duration))) {
|
&& (key === opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(master.duration))) {
|
||||||
@@ -730,9 +783,7 @@ function followingOverrides(
|
|||||||
const turned = from.showWithoutTime !== to.showWithoutTime;
|
const turned = from.showWithoutTime !== to.showWithoutTime;
|
||||||
const resized = parseDurationSeconds(to.duration) !== parseDurationSeconds(series.duration);
|
const resized = parseDurationSeconds(to.duration) !== parseDurationSeconds(series.duration);
|
||||||
const result: OverrideMap = {};
|
const result: OverrideMap = {};
|
||||||
for (const [key, override] of Object.entries(overrides)) {
|
for (const { key, target, override } of rekeyed(overrides, (key) => overrideKeyIn(key, from, to, splitKey))) {
|
||||||
const target = overrideKeyIn(key, from, to, splitKey);
|
|
||||||
if (!target) continue;
|
|
||||||
const opened = key === splitKey;
|
const opened = key === splitKey;
|
||||||
const { updated: _updated, ...rest } = override as OverrideEntry;
|
const { updated: _updated, ...rest } = override as OverrideEntry;
|
||||||
let entry = withParticipantsOf(rest, to.participants);
|
let entry = withParticipantsOf(rest, to.participants);
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ describe('the series itself opened from a reminder on the calendar store', () =>
|
|||||||
|
|
||||||
it.each(['0.16.23', '1.0'])('overrides its first occurrence in the owner\'s account on Stalwart %s', async () => {
|
it.each(['0.16.23', '1.0'])('overrides its first occurrence in the owner\'s account on Stalwart %s', async () => {
|
||||||
useCalendarStore.setState({ events: [] });
|
useCalendarStore.setState({ events: [] });
|
||||||
|
// As the owner's account holds it: no overrides yet.
|
||||||
|
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [{ ...master, recurrenceOverrides: null }] : []));
|
||||||
const store = useCalendarStore.getState();
|
const store = useCalendarStore.getState();
|
||||||
|
|
||||||
await saveOccurrenceEdit('this', {
|
await saveOccurrenceEdit('this', {
|
||||||
@@ -192,3 +194,34 @@ describe('a change of an occurrence whose override has no organizer (Stalwart 0.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The SEQUENCE a change of every occurrence raises is the server's: a series
|
||||||
|
// opened from a reminder a while ago - or shown as itself in the grid - is
|
||||||
|
// an older copy, and a raise from its number goes out lower than what the
|
||||||
|
// attendees hold, which 0.16 (0.16.6 to 0.16.23) refuses.
|
||||||
|
describe.each(['0.16.23', '1.0'])('a change of every occurrence from a series opened a while ago on Stalwart %s', (version) => {
|
||||||
|
it('raises the SEQUENCE the server holds, not the stale copy\'s', async () => {
|
||||||
|
const overrides = version === '1.0'
|
||||||
|
? { '2026-10-19T10:00:00': { title: 'Planning, special' } }
|
||||||
|
: { '2026-10-19T10:00:00': { title: 'Planning, special', start: '2026-10-19T10:00:00', duration: 'PT1H', sequence: 2 } };
|
||||||
|
const stale = { ...master, sequence: 2, recurrenceOverrides: overrides } as unknown as CalendarEvent;
|
||||||
|
const current = {
|
||||||
|
...master, sequence: 5,
|
||||||
|
recurrenceOverrides: version === '1.0' ? overrides : { '2026-10-19T10:00:00': { ...overrides['2026-10-19T10:00:00'], sequence: 5 } },
|
||||||
|
} as unknown as CalendarEvent;
|
||||||
|
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [current] : []));
|
||||||
|
useCalendarStore.setState({ events: [] });
|
||||||
|
const store = useCalendarStore.getState();
|
||||||
|
|
||||||
|
await saveOccurrenceEdit('all', {
|
||||||
|
event: stale,
|
||||||
|
updates: { title: 'Planning, room 2', start: stale.start, duration: 'PT1H', timeZone: 'Europe/Berlin', participants },
|
||||||
|
editor: { attendees: [{ name: '', email: 'userb@example.org' }], when: editorTimesFromEvent(stale) },
|
||||||
|
sendScheduling: true, userEmails: [ORGANIZER],
|
||||||
|
}, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent });
|
||||||
|
|
||||||
|
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
|
||||||
|
['b', { title: 'Planning, room 2', sequence: 6, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 6 }, true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1034,10 +1034,18 @@ describe('calendar-store', () => {
|
|||||||
expect(master?.start).toBe('2026-03-02T09:00:00');
|
expect(master?.start).toBe('2026-03-02T09:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getMasterEvent returns a master as-is', async () => {
|
// A copy the app holds - opened from a reminder a while ago, or shown as
|
||||||
const ev = { id: 'ev1', recurrenceRules: [{ frequency: 'daily' }] } as any;
|
// itself in the grid - may be older than the server's: the scope edits
|
||||||
expect(await useCalendarStore.getState().getMasterEvent(ev)).toBe(ev);
|
// build on its overrides and its SEQUENCE.
|
||||||
expect(mockGetEvents).not.toHaveBeenCalled();
|
it('getMasterEvent reads a master again from the server', async () => {
|
||||||
|
useCalendarStore.setState({ calendars: [], events: [] });
|
||||||
|
const ev = { id: 'ev1', recurrenceRules: [{ frequency: 'daily' }], sequence: 1 } as any;
|
||||||
|
mockGetEvents.mockResolvedValue([{ ...ev, sequence: 4 }]);
|
||||||
|
|
||||||
|
const master = await useCalendarStore.getState().getMasterEvent(ev);
|
||||||
|
|
||||||
|
expect(mockGetEvents).toHaveBeenCalledWith(['ev1'], undefined);
|
||||||
|
expect(master?.sequence).toBe(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -254,8 +254,8 @@ export interface CalendarState {
|
|||||||
options?: { sendSchedulingMessages?: boolean },
|
options?: { sendSchedulingMessages?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
deleteEvent: (id: string) => Promise<void>;
|
deleteEvent: (id: string) => Promise<void>;
|
||||||
// Resolve a client-side expanded occurrence (or a master) to its master
|
// Resolve an expanded occurrence (or a master) to its master event, read
|
||||||
// event, fetching it from the server when the expansion replaced it.
|
// from the server: a copy the app holds may be older.
|
||||||
getMasterEvent: (event: CalendarEvent) => Promise<CalendarEvent | null>;
|
getMasterEvent: (event: CalendarEvent) => Promise<CalendarEvent | null>;
|
||||||
// Invitations / scheduling
|
// Invitations / scheduling
|
||||||
rsvpEvent: (
|
rsvpEvent: (
|
||||||
@@ -1044,25 +1044,20 @@ export const useCalendarStore = create<CalendarState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMasterEvent: async (event) => {
|
getMasterEvent: async (event) => {
|
||||||
if (event.recurrenceRules?.length && !event.recurrenceId && !isServerRecurrenceInstance(event)) {
|
|
||||||
// The series itself, opened from a reminder or a link rather than the
|
|
||||||
// grid: known from here on, so a change to it is sent to its own
|
|
||||||
// account under its raw id (resolveMutationTarget).
|
|
||||||
if (!get().events.some((e) => e.id === event.id)) knownMasters.set(event.id, event);
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
// Expansion replaces the master with its occurrences, each pointing back
|
// Expansion replaces the master with its occurrences, each pointing back
|
||||||
// at the stored event: through baseEventId when the server expanded it,
|
// at the stored event: through baseEventId when the server expanded it,
|
||||||
// through originalId when the device did.
|
// through originalId when the device did. The stored event is read
|
||||||
|
// again every time, also for the series itself (opened from a reminder
|
||||||
|
// or a link) and one the grid shows as itself: a copy held here can be
|
||||||
|
// older than the server's, and the scope edits build on its overrides
|
||||||
|
// and its SEQUENCE - a change that raises it from a stale one goes out
|
||||||
|
// lower than the attendees' copies, which 0.16 refuses.
|
||||||
const realId = seriesIdOf(event);
|
const realId = seriesIdOf(event);
|
||||||
const inStore = get().events.find(
|
|
||||||
(e) => !e.recurrenceId && !!e.recurrenceRules?.length && !isServerRecurrenceInstance(e)
|
|
||||||
&& seriesIdOf(e) === realId && (e.accountId ?? undefined) === (event.accountId ?? undefined),
|
|
||||||
);
|
|
||||||
if (inStore) return inStore;
|
|
||||||
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
|
const fetched = (await fetchEvents([realId], event.accountId)) ?? [];
|
||||||
const master = fetched[0];
|
const master = fetched[0];
|
||||||
if (!master) return null;
|
if (!master) return null;
|
||||||
|
// Known from here on, so a change to it is sent to its own account under
|
||||||
|
// its raw id (resolveMutationTarget), whether or not the grid shows it.
|
||||||
const mapped = mapServerEventToStoreEvent(master, get().calendars, event.accountId);
|
const mapped = mapServerEventToStoreEvent(master, get().calendars, event.accountId);
|
||||||
knownMasters.set(mapped.id, mapped);
|
knownMasters.set(mapped.id, mapped);
|
||||||
return mapped;
|
return mapped;
|
||||||
|
|||||||
Reference in New Issue
Block a user