From 90b78da8c89189da663feb35e6b709c216c52a94 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:44:34 +0200 Subject: [PATCH] fix: keep time zones, default alerts and calendar renames working on 1.0 Four smaller calendar changes that Stalwart 1.0 needs. - Time zone: 1.0 fails every CalendarEvent get and query whose timeZone its database lacks, with invalidArguments "Invalid timeZone". It matches IANA names case-sensitively and knows no offsets, so a "+01:00" or "europe/berlin" that Intl accepts empties the calendar. 0.16 reads such a zone as UTC. The zone is now checked with a get of no events that rides along with the first calendar list; once refused, reads ask in UTC and log one warning, and a read refused before the check answered is repeated once in UTC. The comment that said Stalwart ignores bad values is corrected. - Default alerts: 1.0 fills a calendar's default alerts into the alerts of an event that uses them. The editor wrote them back with useDefaultAlerts false on every save, freezing today's defaults into the event. It now writes nothing while the reminders are unchanged; a change turns the defaults off and saves the alerts under ids of their own, since 1.0 drops alerts saved under a default alert's id. - Calendar colour: renaming sent the colour along, and 1.0 accepts only #rgb, #rrggbb or a CSS name, so a calendar another client coloured with an 8-digit hex could not be renamed. The edit sheet and the iCal subscription settings now send only the fields that changed. - Reply stubs: 0.16 stored an attendee's answer to one occurrence without a title, place or description, and 1.0 builds that occurrence from the override alone. Hydration now takes a missing title from the series, and the place and description when the override removes the title. --- .../calendar-overrides-split.test.ts | 2 + src/api/__tests__/calendar-time-zone.test.ts | 151 ++++++++++++++ src/api/__tests__/calendar.test.ts | 6 + src/api/__tests__/stalwart-calendar-mock.ts | 8 + src/api/calendar.ts | 191 ++++++++++++++---- src/components/calendar/EventModal.tsx | 12 +- src/lib/__tests__/calendar-alerts.test.ts | 76 +++++++ src/lib/__tests__/calendar-utils.test.ts | 25 +++ .../__tests__/recurrence-instances.test.ts | 47 +++++ src/lib/calendar-alerts.ts | 58 ++++++ src/lib/calendar-utils.ts | 18 ++ src/lib/recurrence-instances.ts | 31 ++- src/screens/CalendarScreen.tsx | 10 +- .../calendar-store-extend-range.test.ts | 1 + src/stores/__tests__/calendar-store.test.ts | 1 + .../calendar-subscriptions-store.test.ts | 17 ++ src/stores/calendar-store.ts | 2 + src/stores/calendar-subscriptions-store.ts | 7 +- 18 files changed, 609 insertions(+), 54 deletions(-) create mode 100644 src/api/__tests__/calendar-time-zone.test.ts diff --git a/src/api/__tests__/calendar-overrides-split.test.ts b/src/api/__tests__/calendar-overrides-split.test.ts index c6e31394..b3fda291 100644 --- a/src/api/__tests__/calendar-overrides-split.test.ts +++ b/src/api/__tests__/calendar-overrides-split.test.ts @@ -106,6 +106,8 @@ describe.each(['0.16', '1.0'])('calendar reads on Stalwart %s', const hydration = server.gets()[1]; expect(hydration.args.ids).toEqual(['b']); expect(hydration.args.properties).not.toContain('utcStart'); + // The series' title, place and description fill reply stubs. + expect(hydration.args.properties).toEqual(expect.arrayContaining(['title', 'locations', 'description'])); }); it(`finds an invitation outside the loaded window by UID on Stalwart ${version}`, async () => { diff --git a/src/api/__tests__/calendar-time-zone.test.ts b/src/api/__tests__/calendar-time-zone.test.ts new file mode 100644 index 00000000..03f04377 --- /dev/null +++ b/src/api/__tests__/calendar-time-zone.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { + getCalendars, + getEvents, + queryEvents, + queryExpandedEvents, + resetSyntheticIdSupport, + resetTimeZoneChecks, +} from '../calendar'; +import { useSettingsStore } from '../../stores/settings-store'; +import { + createStalwartCalendarMock, + type StalwartCalendarMock, + type StalwartVersion, +} from './stalwart-calendar-mock'; + +// Stalwart 1.0 fails every CalendarEvent/get and /query whose `timeZone` its +// time zone database lacks: invalidArguments "Invalid timeZone \"X\"" +// (jmap-proto/src/object/calendar_event.rs). It matches IANA names +// case-sensitively and knows no offsets, so a zone the phone's Intl accepts +// can be refused. 0.16 reads such a zone as UTC, so the calendar kept +// working there; on 1.0 it would show nothing but an error. + +const mockRequest = jmapClient.request as ReturnType; +// Intl accepts it; Stalwart 1.0 does not. +const ODD_ZONE = 'europe/berlin'; + +const SINGLE = { + id: 'd', '@type': 'Event', uid: 'single@example.com', title: 'One off', + start: '2026-09-01T09:00:00', duration: 'PT30M', calendarIds: { cal: true }, +}; + +let server: StalwartCalendarMock; +let warn: ReturnType; + +function serve(version: StalwartVersion): void { + server = createStalwartCalendarMock({ version, objects: { d: SINGLE }, unknownTimeZones: [ODD_ZONE] }); + mockRequest.mockImplementation(server.request); +} + +const zonesOf = (calls: { args: Record }[]) => calls.map((call) => call.args.timeZone); +const reads = () => server.calls.filter((call) => call.callId !== 'time-zone-probe' + && (call.method === 'CalendarEvent/get' || call.method === 'CalendarEvent/query')); + +beforeEach(() => { + vi.clearAllMocks(); + resetSyntheticIdSupport(); + resetTimeZoneChecks(); + useSettingsStore.setState({ calendarTimeZone: ODD_ZONE }); + warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + warn.mockRestore(); +}); + +describe('a time zone Stalwart 1.0 does not know', () => { + it('is checked with the first calendar list, and reads ask in UTC from then on, on Stalwart 1.0', async () => { + serve('1.0'); + + await getCalendars(); + const [first] = mockRequest.mock.calls[0]; + expect(first).toContainEqual(['CalendarEvent/get', { accountId: 'acc-1', ids: [], properties: ['id'], timeZone: ODD_ZONE }, 'time-zone-probe']); + + const ids = await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z'); + const events = await getEvents(ids); + + expect(events.map((e) => e.id)).toEqual(['d']); + expect(zonesOf(reads())).toEqual(['UTC', 'UTC', 'UTC']); + // The bounds are UTC wall clocks now, the same instants. + expect((server.queries()[0].args.filter as { conditions: Record[] }).conditions[1]) + .toEqual({ after: '2026-09-01T00:00:00', before: '2026-09-02T00:00:00' }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('repeats a read refused before any check, once, in UTC, and remembers it on Stalwart 1.0', async () => { + serve('1.0'); + + expect(await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')).toEqual(['d']); + expect(zonesOf(server.queries())).toEqual([ODD_ZONE, 'UTC']); + + await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z'); + expect(zonesOf(server.queries())).toEqual([ODD_ZONE, 'UTC', 'UTC']); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('expands a range in UTC after a refusal on Stalwart 1.0', async () => { + serve('1.0'); + + await queryExpandedEvents('2026-09-01T00:00:00Z', '2026-09-30T00:00:00Z'); + + const [refused, repeated] = server.queries(); + expect(refused.args.timeZone).toBe(ODD_ZONE); + expect(repeated.args).toMatchObject({ + timeZone: 'UTC', + filter: { after: '2026-09-01T00:00:00', before: '2026-09-30T00:00:00' }, + }); + }); + + it('keeps asking in the zone on 0.16, which reads an unknown zone as UTC', async () => { + serve('0.16'); + + await getCalendars(); + await getEvents(await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')); + + expect(zonesOf(reads())).toEqual([ODD_ZONE, ODD_ZONE, ODD_ZONE]); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps a zone Stalwart 1.0 knows', async () => { + useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }); + serve('1.0'); + + await getCalendars(); + await queryEvents(['cal'], '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z'); + + expect(zonesOf(reads())).toEqual(['Europe/Berlin']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('checks the zone again after a calendar list that failed on Stalwart 1.0', async () => { + serve('1.0'); + mockRequest.mockRejectedValueOnce(new Error('Network request failed')); + + await expect(getCalendars()).rejects.toThrow('Network request failed'); + await getCalendars(); + + expect(mockRequest.mock.calls[1][0].some((call: [string, unknown, string]) => call[2] === 'time-zone-probe')).toBe(true); + }); + + it('sends no check for UTC', async () => { + useSettingsStore.setState({ calendarTimeZone: 'UTC' }); + serve('1.0'); + + await getCalendars(); + + expect(mockRequest.mock.calls[0][0].some((call: [string, unknown, string]) => call[2] === 'time-zone-probe')).toBe(false); + }); +}); diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index b0baa5d5..df691d97 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -23,9 +23,11 @@ import { toLocalDateTime, supportsSyntheticCalendarIds, resetSyntheticIdSupport, + resetTimeZoneChecks, queryExpandedEvents, hydrateExpandedOccurrences, } from '../calendar'; +import { useSettingsStore } from '../../stores/settings-store'; describe('toLocalDateTime', () => { it('renders an instant as wall-clock in the given zone', () => { @@ -55,6 +57,10 @@ function getAnswer(list: unknown[]) { beforeEach(() => { vi.clearAllMocks(); resetSyntheticIdSupport(); + resetTimeZoneChecks(); + // In UTC the first calendar list carries no time zone check + // (calendar-time-zone.test.ts covers that). + useSettingsStore.setState({ calendarTimeZone: 'UTC' }); }); describe('calendar operations', () => { diff --git a/src/api/__tests__/stalwart-calendar-mock.ts b/src/api/__tests__/stalwart-calendar-mock.ts index 12f8ea4a..942a6e4d 100644 --- a/src/api/__tests__/stalwart-calendar-mock.ts +++ b/src/api/__tests__/stalwart-calendar-mock.ts @@ -46,6 +46,8 @@ export interface StalwartCalendarMockOptions { calendars?: Record[]; /** Whether CalendarEvent/set takes synthetic ids (the probe answers notFound). Default true. */ syntheticIds?: boolean; + /** Zones missing from the server's time zone database: 1.0 refuses them, 0.16 reads them as UTC. */ + unknownTimeZones?: string[]; /** Answer a call yourself; return undefined for the default answer. */ answer?: (call: RecordedCall) => MethodAnswer | undefined; } @@ -98,6 +100,12 @@ export function createStalwartCalendarMock(options: StalwartCalendarMockOptions) const defaultAnswer = (call: RecordedCall): MethodAnswer => { const { method, args } = call; + // jmap-proto/src/object/calendar_event.rs, resolved_time_zone: checked + // before anything else in CalendarEvent/get and /query. + if (options.version === '1.0' && (method === 'CalendarEvent/get' || method === 'CalendarEvent/query') + && typeof args.timeZone === 'string' && options.unknownTimeZones?.includes(args.timeZone)) { + return ['error', { type: 'invalidArguments', description: `Invalid timeZone ${JSON.stringify(args.timeZone)}` }]; + } if (method === 'Calendar/get') { return ['Calendar/get', { accountId: args.accountId, list: options.calendars ?? [{ id: 'cal', name: 'Personal', isDefault: true }], notFound: [] }]; } diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 333ab376..4eda6854 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -16,12 +16,13 @@ const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; /** * IANA time zone of the user — their calendar time-zone setting when set - * (#755), otherwise the device's — sent as the `timeZone` argument on - * CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime - * filter values and computes utcStart/utcEnd for floating events in this zone, - * defaulting to UTC when absent — which shifts range boundaries and - * floating-event times for any user not in UTC. Stalwart ignores unparseable - * values, so sending it is always safe. + * (#755), otherwise the device's. Stalwart interprets LocalDateTime filter + * values and computes utcStart/utcEnd for floating events in the `timeZone` + * argument of CalendarEvent/query and CalendarEvent/get, defaulting to UTC + * when absent — which shifts range boundaries and floating-event times for + * any user not in UTC. A zone the server's time zone database lacks is read + * as UTC by 0.16 and refused by 1.0, which fails the whole call; + * calendarTimeZone decides what is actually sent. */ function getUserTimeZone(): string | undefined { try { @@ -31,6 +32,95 @@ function getUserTimeZone(): string | undefined { } } +// ─── Time zone check ───────────────────────────────────── +// Stalwart 1.0 answers a CalendarEvent/get or /query whose `timeZone` it +// does not know with invalidArguments "Invalid timeZone \"X\"". Its database +// holds the IANA names, matched case-sensitively, and no offsets, so a +// "+01:00" or "europe/berlin" that Intl may accept is refused. + +const TIME_ZONE_PROBE_CALL_ID = 'time-zone-probe'; +// Per server and zone: the check of the zone, settled or in flight. +const timeZoneProbes = new Map>(); +// Per server and zone: refused by the server. +const refusedTimeZones = new Set(); + +function timeZoneKey(timeZone: string): string { + return `${jmapClient.currentSession?.apiUrl ?? ''}|${timeZone}`; +} + +function isTimeZoneRefusal(type: string | undefined, description: string | undefined): boolean { + return type === 'invalidArguments' && /invalid timeZone/i.test(description ?? ''); +} + +/** A get of no events, which the server only answers if it knows `timeZone`. */ +function timeZoneProbeCall(accountId: string, timeZone: string): [string, Record, string] { + return ['CalendarEvent/get', { accountId, ids: [], properties: ['id'], timeZone }, TIME_ZONE_PROBE_CALL_ID]; +} + +function refuseTimeZone(key: string, timeZone: string): void { + if (refusedTimeZones.has(key)) return; + refusedTimeZones.add(key); + console.warn( + `[calendar] The server does not know the time zone "${timeZone}"; asking it for UTC times instead. ` + + 'Events without a time zone of their own may show shifted.', + ); +} + +/** + * Take the verdict on `timeZone` from a response that carried its probe. A + * request that failed outright is not remembered, so the next calendar list + * checks again. + */ +function rememberTimeZoneProbe(key: string, timeZone: string, pending: Promise): void { + const probe: Promise = pending.then( + (res) => { + const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find( + (r) => r[2] === TIME_ZONE_PROBE_CALL_ID, + ); + if (entry?.[0] === 'error' && isTimeZoneRefusal(entry[1]?.type, entry[1]?.description)) { + refuseTimeZone(key, timeZone); + } + }, + () => { + if (timeZoneProbes.get(key) === probe) timeZoneProbes.delete(key); + }, + ); + timeZoneProbes.set(key, probe); +} + +/** + * The `timeZone` argument for CalendarEvent/get and /query: the user's zone, + * or UTC once the server refused it. The first calendar list checks the zone + * on the way (getCalendars); a read that starts without that answer sends + * the user's zone and is repeated in UTC if refused (withCalendarTimeZone). + */ +async function calendarTimeZone(): Promise { + const timeZone = getUserTimeZone(); + if (!timeZone) return undefined; + const key = timeZoneKey(timeZone); + await timeZoneProbes.get(key); + return refusedTimeZones.has(key) ? 'UTC' : timeZone; +} + +/** Run a calendar read in calendarTimeZone, and once more in UTC when the server refuses that zone. */ +async function withCalendarTimeZone(read: (timeZone: string | undefined) => Promise): Promise { + const timeZone = await calendarTimeZone(); + try { + return await read(timeZone); + } catch (error) { + if (!timeZone || timeZone === 'UTC') throw error; + if (!isTimeZoneRefusal(jmapErrorType(error), error instanceof Error ? error.message : undefined)) throw error; + refuseTimeZone(timeZoneKey(timeZone), timeZone); + return read('UTC'); + } +} + +/** Forget the time zone checks: a new session may reach an upgraded server. */ +export function resetTimeZoneChecks(): void { + timeZoneProbes.clear(); + refusedTimeZones.clear(); +} + /** * Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock * in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and @@ -333,8 +423,14 @@ export async function getCalendars(): Promise { const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey); const methodCalls: [string, Record, string][] = [['Calendar/get', { accountId }, '0']]; if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId)); + // The user's time zone is checked on the way too (calendarTimeZone). + const timeZone = getUserTimeZone(); + const zoneKey = timeZone && timeZone !== 'UTC' ? timeZoneKey(timeZone) : null; + const checksTimeZone = !!zoneKey && !timeZoneProbes.has(zoneKey); + if (checksTimeZone) methodCalls.push(timeZoneProbeCall(accountId, timeZone!)); const pending = jmapClient.request(methodCalls, USING); if (carriesProbe) rememberSyntheticIdProbe(probeKey!, pending); + if (checksTimeZone) rememberTimeZoneProbe(zoneKey!, timeZone!, pending); const res = await pending; const own = methodResult<{ list: Calendar[] }>(res).list ?? []; @@ -395,36 +491,37 @@ export async function queryEvents( accountId?: string, ): Promise { const account = accountId || jmapClient.accountId; - const timeZone = getUserTimeZone(); - const conditions: Record[] = []; - if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); - const range: Record = {}; - if (after) range.after = toLocalDateTime(after, timeZone); - if (before) range.before = toLocalDateTime(before, timeZone); - if (Object.keys(range).length > 0) conditions.push(range); - const filter = conditions.length === 1 - ? conditions[0] - : conditions.length > 1 - ? { operator: 'AND', conditions } - : undefined; - const ids: string[] = []; - for (let page = 0; page < QUERY_MAX_PAGES; page++) { - const args: Record = { - accountId: account, - limit: QUERY_PAGE_SIZE, - position: ids.length, - }; - if (timeZone) args.timeZone = timeZone; - if (filter) args.filter = filter; - const res = await jmapClient.request( - [['CalendarEvent/query', args, '0']], - USING, - ); - const batch = methodResult<{ ids: string[] }>(res).ids ?? []; - ids.push(...batch); - if (batch.length < QUERY_PAGE_SIZE) break; - } - return ids; + return withCalendarTimeZone(async (timeZone) => { + const conditions: Record[] = []; + if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); + const range: Record = {}; + if (after) range.after = toLocalDateTime(after, timeZone); + if (before) range.before = toLocalDateTime(before, timeZone); + if (Object.keys(range).length > 0) conditions.push(range); + const filter = conditions.length === 1 + ? conditions[0] + : conditions.length > 1 + ? { operator: 'AND', conditions } + : undefined; + const ids: string[] = []; + for (let page = 0; page < QUERY_MAX_PAGES; page++) { + const args: Record = { + accountId: account, + limit: QUERY_PAGE_SIZE, + position: ids.length, + }; + if (timeZone) args.timeZone = timeZone; + if (filter) args.filter = filter; + const res = await jmapClient.request( + [['CalendarEvent/query', args, '0']], + USING, + ); + const batch = methodResult<{ ids: string[] }>(res).ids ?? []; + ids.push(...batch); + if (batch.length < QUERY_PAGE_SIZE) break; + } + return ids; + }); } // ─── Expanded ranges ───────────────────────────────────── @@ -566,7 +663,16 @@ export async function queryExpandedEvents( accountId?: string, ): Promise { const account = accountId || jmapClient.accountId; - const timeZone = getUserTimeZone(); + return withCalendarTimeZone((timeZone) => queryExpandedEventsIn(account, after, before, timeZone)); +} + +/** queryExpandedEvents with the bounds and the request in `timeZone`. */ +async function queryExpandedEventsIn( + account: string, + after: string, + before: string, + timeZone: string | undefined, +): Promise { const bounds = { after: toLocalDateTime(after, timeZone), before: toLocalDateTime(before, timeZone) }; const baseArgs: Record = { accountId: account, expandRecurrences: true }; if (timeZone) baseArgs.timeZone = timeZone; @@ -670,9 +776,8 @@ export async function hydrateExpandedOccurrences( if (baseIds.length === 0) return events; const account = accountId || jmapClient.accountId; const bases = new Map>(); - const list = await fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, { - timeZone: getUserTimeZone(), - }); + const list = await withCalendarTimeZone((timeZone) => + fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone })); for (const base of list as Partial[]) { if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base)); } @@ -768,12 +873,12 @@ export async function getEvents( ): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; - const list = await fetchEventsById( + const list = await withCalendarTimeZone((timeZone) => fetchEventsById( account, ids, options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, - { withOverrides: !options?.expanded, timeZone: getUserTimeZone() }, - ); + { withOverrides: !options?.expanded, timeZone }, + )); return (list as unknown as CalendarEvent[]).map((e) => normalizeTaskProgress(normalizeRecurrenceProperties(e))); } diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index c9acce60..0249ec72 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -48,7 +48,7 @@ import { canCreateEventsIn } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { alertsToReminders, - remindersToAlerts, + reminderFieldsToSave, preservedAlerts, formatReminder, REMINDER_PRESETS, @@ -314,6 +314,11 @@ export function EventModal({ ? [customRule] : undefined : recurrenceFromOption(recurrence); + // The calendars' default alerts: a change of the reminders turns them + // off for the event, and its alerts must not take their ids. + const defaultAlertIds = calendars + .filter((cal) => cal.id === calendarId || !!event?.calendarIds?.[cal.id]) + .flatMap((cal) => [...Object.keys(cal.defaultAlertsWithTime ?? {}), ...Object.keys(cal.defaultAlertsWithoutTime ?? {})]); const data: Partial = { title: title.trim(), description: description.trim() || (isEdit && event?.description ? '' : undefined), @@ -321,8 +326,9 @@ export function EventModal({ // into the event's own zone and keeps untouched ones as stored. ...eventTimeFieldsToSave({ allDay, start, end }, event), recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), - alerts: clearedOr(remindersToAlerts(reminders, preserved), 'alerts'), - useDefaultAlerts: reminders.length > 0 ? false : undefined, + // Untouched reminders are not sent: an event on its calendar's + // default alerts keeps them. + ...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds), locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts index fba9b6d3..3d13c640 100644 --- a/src/lib/__tests__/calendar-alerts.test.ts +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -5,8 +5,12 @@ import { alertsToReminders, remindersToAlerts, formatReminder, + preservedAlerts, + reminderFieldsToSave, + withOwnAlertIds, } from '../calendar-alerts'; import { translate, type MessageParams } from '../../i18n'; +import type { Alert } from '../../api/types'; describe('calendar-alerts', () => { describe('offsetToMinutesBefore', () => { @@ -88,3 +92,75 @@ describe('calendar-alerts', () => { }); }); }); + +// While an event uses its calendar's default alerts, Stalwart 1.0 fills the +// defaults into `alerts` under the default alerts' ids, and on save drops +// every alert under one of those ids. The editor wrote the alerts back with +// useDefaultAlerts: false on every save, freezing today's defaults into the +// event. +describe('reminderFieldsToSave', () => { + const offsetAlert = (offset: string) => ({ + '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' }, action: 'display', + }) as Alert; + // What 1.0 returns for an event on its calendar's defaults. + const onDefaults = { + useDefaultAlerts: true, + alerts: { 'default-15': offsetAlert('-PT15M'), 'default-abs': { '@type': 'Alert', trigger: { '@type': 'AbsoluteTrigger', when: '2026-09-01T08:00:00Z' } } as Alert }, + }; + + it('writes nothing while the reminders are the ones the event had, so the defaults stay on', () => { + const reminders = alertsToReminders(onDefaults.alerts); + expect(reminderFieldsToSave(onDefaults, reminders, preservedAlerts(onDefaults.alerts))).toEqual({}); + // Order and repeats are no change. + expect(reminderFieldsToSave( + { alerts: { a: offsetAlert('-PT15M'), b: offsetAlert('-PT1H') } }, + [{ minutesBefore: 60 }, { minutesBefore: 15 }, { minutesBefore: 15 }], + {}, + )).toEqual({}); + }); + + it('turns the defaults off for a changed event and gives its alerts ids of their own on Stalwart 1.0', () => { + const fields = reminderFieldsToSave( + onDefaults, + [{ minutesBefore: 15 }, { minutesBefore: 60 }], + preservedAlerts(onDefaults.alerts), + ['reminder-1'], + ); + + expect(fields.useDefaultAlerts).toBe(false); + const ids = Object.keys(fields.alerts ?? {}); + expect(ids).toHaveLength(3); + for (const id of ids) expect(['default-15', 'default-abs', 'reminder-1']).not.toContain(id); + // The alert the editor cannot show stays, under its new id. + expect(Object.values(fields.alerts ?? {})).toContainEqual(onDefaults.alerts['default-abs']); + }); + + it('removes the alerts and the defaults when every reminder is removed', () => { + expect(reminderFieldsToSave(onDefaults, [], {})).toEqual({ alerts: null, useDefaultAlerts: false }); + }); + + it('replaces the own alerts of an event without defaults, keeping what the editor cannot show', () => { + const own = { alerts: { a: offsetAlert('-PT15M'), abs: onDefaults.alerts['default-abs'] } }; + const fields = reminderFieldsToSave(own, [{ minutesBefore: 30 }], preservedAlerts(own.alerts)); + + expect(fields).toEqual({ + alerts: { abs: own.alerts.abs, 'reminder-1': offsetAlert('-PT30M') }, + useDefaultAlerts: false, + }); + }); + + it('gives a new event its reminders, and writes nothing without any', () => { + expect(reminderFieldsToSave(null, [{ minutesBefore: 10 }], {})).toEqual({ + alerts: { 'reminder-1': offsetAlert('-PT10M') }, + useDefaultAlerts: false, + }); + expect(reminderFieldsToSave(null, [], {})).toEqual({}); + }); +}); + +describe('withOwnAlertIds', () => { + it('numbers the alerts past every id taken', () => { + const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M' } } as Alert; + expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']); + }); +}); diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index eb52c049..b499d778 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -6,6 +6,7 @@ import { eventsOnDay, normalizeAllDayDuration, buildAllDayDuration, + changedCalendarFields, getCalendarColor, getEventStartDate, getEventEndDate, @@ -278,3 +279,27 @@ describe('layoutOverlappingEvents', () => { expect(layoutOverlappingEvents(events, day)).toHaveLength(0); }); }); + +// Stalwart 1.0 accepts a calendar colour only as #rgb, #rrggbb or a CSS name +// (groupware/src/calendar/color.rs). The edit sheet sent the colour with +// every rename, so a calendar another client coloured with Apple's 8-digit +// hex could not be renamed. +describe('changedCalendarFields', () => { + const apple = { id: 'c1', name: 'Home', color: '#FF2968FF', description: 'Family' }; + + it('sends only the new name of a calendar whose colour came from another client', () => { + expect(changedCalendarFields(apple, { name: 'House', color: '#FF2968FF', description: 'Family' })).toEqual({ name: 'House' }); + }); + + it('sends a colour the user picked, and clears an emptied description', () => { + expect(changedCalendarFields(apple, { name: 'Home', color: '#3b82f6', description: '' })).toEqual({ + color: '#3b82f6', + description: null, + }); + }); + + it('compares with the colour the editor showed for a calendar without one', () => { + const plain = { id: 'c2', name: 'Work' }; + expect(changedCalendarFields(plain, { name: 'Work', color: getCalendarColor(plain).toUpperCase(), description: '' })).toEqual({}); + }); +}); diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index c61df179..70ec88e1 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -433,3 +433,50 @@ describe('overrides returned as PatchObjects', () => { } }); }); + +// A reply to one occurrence, as 0.16 stored it: an override with little more +// than the attendee - no title, place or description. 1.0 builds that +// occurrence from the override alone, and returns the override as a +// PatchObject that removes what the series has and the stub lacks. +describe('hydrateRecurrenceInstances with a reply stub', () => { + const series: Partial = { + id: 'b', duration: 'PT1H', recurrenceRules: [{ frequency: 'daily' } as any], + title: 'Team sync', description: 'Agenda in the doc', + locations: { l1: { '@type': 'Location', name: 'Room 4' } } as any, + }; + const stub = (): CalendarEvent => { + const occurrence: Partial = ev({ + id: 's1', baseEventId: 'b', recurrenceId: '2026-09-08T10:00:00', start: '2026-09-08T10:00:00', duration: 'PT1H', + }); + delete occurrence.title; + return occurrence as CalendarEvent; + }; + const withOverride = (override: Record) => new Map>([['b', { + ...series, recurrenceOverrides: { '2026-09-08T10:00:00': override as Partial }, + }]]); + + it('takes the title, place and description from the series on Stalwart 1.0', () => { + const [out] = hydrateRecurrenceInstances([stub()], withOverride({ + title: null, description: null, locations: null, 'participants/att/participationStatus': 'declined', + })); + expect(out.title).toBe('Team sync'); + expect(out.locations).toEqual(series.locations); + expect(out.description).toBe('Agenda in the doc'); + }); + + it('takes only the title from the series on 0.16, whose override is the stub itself', () => { + const [out] = hydrateRecurrenceInstances([stub()], withOverride({ + participants: { att: { '@type': 'Participant', participationStatus: 'declined' } }, + })); + expect(out.title).toBe('Team sync'); + expect(out.locations).toBeUndefined(); + expect(out.description).toBeUndefined(); + }); + + it('keeps a title the override sets, even an empty one', () => { + const renamed = { ...stub(), title: 'Team sync (moved)' }; + expect(hydrateRecurrenceInstances([renamed], withOverride({ title: 'Team sync (moved)' }))[0].title) + .toBe('Team sync (moved)'); + expect(hydrateRecurrenceInstances([{ ...stub(), title: '' }], withOverride({ title: '' }))[0].title).toBe(''); + }); +}); diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index 87c47c21..2090a8c7 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -107,6 +107,64 @@ export function remindersToAlerts( return alerts; } +/** + * `alerts` under new ids (reminder-1, reminder-2, ...), none of them in + * `taken`. While `useDefaultAlerts` is on, Stalwart 1.0 shows the calendar's + * defaults in `alerts` under the default alerts' ids, and on save it drops + * every alert whose id is one of those: alerts that stay on after the + * defaults are turned off need ids of their own. + */ +export function withOwnAlertIds(alerts: Alert[], taken: Iterable): Record { + const used = new Set(taken); + const result: Record = {}; + let n = 0; + for (const alert of alerts) { + let id: string; + do id = `reminder-${++n}`; while (used.has(id)); + result[id] = alert; + } + return result; +} + +function reminderMinutes(reminders: Reminder[]): number[] { + return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a); +} + +/** + * What an editor save writes for the reminders: nothing while they are the + * ones the event had, so an event that uses its calendar's default alerts + * keeps using them. Stalwart 1.0 fills those defaults into `alerts`, and + * writing them back with `useDefaultAlerts: false`, as every save did, + * froze today's defaults into the event. + * + * Changed reminders replace the event's alerts and turn the defaults off + * (a change would not stick while they are on). The alerts of an event that + * used the defaults then get ids of their own (withOwnAlertIds), away from + * the event's and the calendar's default ids (`reservedIds`). 0.16 stores + * the alerts either way. + */ +export function reminderFieldsToSave( + event: Pick | null | undefined, + reminders: Reminder[], + preserved: Record, + reservedIds: Iterable = [], +): Partial> { + const before = reminderMinutes(alertsToReminders(event?.alerts)); + const after = reminderMinutes(reminders); + if (before.length === after.length && before.every((minutes, i) => minutes === after[i])) return {}; + + const usesDefaults = !!event?.useDefaultAlerts; + let alerts = remindersToAlerts(reminders, preserved); + if (alerts && usesDefaults) { + alerts = withOwnAlertIds(Object.values(alerts), [...Object.keys(event?.alerts ?? {}), ...reservedIds]); + } + const fields: Partial> = {}; + if (alerts) fields.alerts = alerts; + else if (event?.alerts && Object.keys(event.alerts).length > 0) fields.alerts = null; + if (reminders.length > 0 || usesDefaults) fields.useDefaultAlerts = false; + return fields; +} + type Translate = (key: string, fallback?: string, params?: MessageParams) => string; // Without a translator: the English text, plurals resolved. const plain: Translate = (key, fallback, params) => formatMessage(fallback ?? key, params, 'en'); diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index e03b8874..b977c2d3 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -536,6 +536,24 @@ export function getCalendarColor(calendar: Pick | unde return CALENDAR_PALETTE[hashString(calendar.id) % CALENDAR_PALETTE.length]; } +/** + * The fields of a calendar edit that differ from the calendar, and only + * those. Stalwart 1.0 accepts a colour only as #rgb, #rrggbb or a CSS name, + * so re-sending one another client wrote - Apple Calendar's 8-digit hex - + * failed every rename. The colour is compared with the one the editor + * started from (getCalendarColor). + */ +export function changedCalendarFields( + calendar: Pick, + values: { name: string; color: string; description: string }, +): { name?: string; color?: string; description?: string | null } { + const changes: { name?: string; color?: string; description?: string | null } = {}; + if (values.name !== calendar.name) changes.name = values.name; + if (values.color.toLowerCase() !== getCalendarColor(calendar).toLowerCase()) changes.color = values.color; + if (values.description !== (calendar.description ?? '')) changes.description = values.description || null; + return changes; +} + export function getEventColor( event: Pick, calendars: Calendar[], diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index dafd7bd0..1aeafe31 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -43,6 +43,9 @@ export const RECURRENCE_BASE_PROPERTIES = [ 'showWithoutTime', 'timeZone', 'duration', + 'title', + 'locations', + 'description', ] as const; type EventIdentity = Pick & Partial>; @@ -300,6 +303,28 @@ export function parseDurationSeconds(duration: string | null | undefined): numbe + Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0); } +/** + * Complete an occurrence built from a reply stub. 0.16 stored an attendee's + * answer to one occurrence as an override holding little more than that + * attendee - no SUMMARY, LOCATION or DESCRIPTION - and Stalwart 1.0 builds + * such an occurrence from the override alone, so it shows untitled. The + * title comes from the series unless the override sets one itself; the + * place and description only when the override removes the title, which is + * what a stub looks like as 1.0 returns it (`title: null` in the PatchObject). + */ +function fillReplyStub( + occurrence: CalendarEvent, + base: Partial, + override: Partial | undefined, +): void { + if (!occurrence.title && base.title && typeof override?.title !== 'string') { + occurrence.title = base.title; + } + if (override?.title !== null) return; + if (occurrence.locations == null && base.locations) occurrence.locations = base.locations; + if (!occurrence.description && base.description) occurrence.description = base.description; +} + function shiftIso(from: string | null | undefined, seconds: number): string | null { if (!from) return null; const ms = Date.parse(from); @@ -314,8 +339,9 @@ function shiftIso(from: string | null | undefined, seconds: number): string | nu * and - for an override that does not set its own duration - the inherited * duration (RFC 8984 §4.3.4: an override is a patch on the base event; the * server's own computed duration for such overrides has been seen to be - * wrong). Occurrences whose base event is not in `bases` are returned - * unchanged. + * wrong). A title the occurrence lacks comes from the series as well (see + * fillReplyStub). Occurrences whose base event is not in `bases` are + * returned unchanged. */ export function hydrateRecurrenceInstances( instances: CalendarEvent[], @@ -340,6 +366,7 @@ export function hydrateRecurrenceInstances( const overrideKey = resolveOverrideKey(instance, base.recurrenceOverrides); if (overrideKey) hydrated.recurrenceId = overrideKey; const override = overrideKey ? base.recurrenceOverrides?.[overrideKey] : undefined; + fillReplyStub(hydrated, base, override); if (override && override.duration == null && base.duration && hydrated.duration !== base.duration) { hydrated.duration = base.duration; const seconds = parseDurationSeconds(base.duration); diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index aca6e297..32c77c9e 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -79,6 +79,7 @@ import { addDaysToLocalDateTime, applySharedCalendarColors, buildEventDayIndex, + changedCalendarFields, dayKey, eventsOnDayFromIndex, getEventStartDate, @@ -858,11 +859,10 @@ export default function CalendarScreen() { if (calendarEditTarget.mode === 'create') { await createCalendar(values.name, values.color, values.description); } else { - await updateCalendar(calendarEditTarget.calendar.id, { - name: values.name, - color: values.color, - description: values.description || null, - }); + // Only what the user changed: an untouched colour another client + // wrote may be one Stalwart 1.0 refuses. + const changes = changedCalendarFields(calendarEditTarget.calendar, values); + if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes); } }, [calendarEditTarget, createCalendar, updateCalendar], diff --git a/src/stores/__tests__/calendar-store-extend-range.test.ts b/src/stores/__tests__/calendar-store-extend-range.test.ts index ba780ed5..5eaa6c98 100644 --- a/src/stores/__tests__/calendar-store-extend-range.test.ts +++ b/src/stores/__tests__/calendar-store-extend-range.test.ts @@ -20,6 +20,7 @@ vi.mock('../../api/calendar', () => ({ queryExpandedEvents: vi.fn(), hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), resetSyntheticIdSupport: vi.fn(), + resetTimeZoneChecks: vi.fn(), })); vi.mock('../../api/jmap-client', () => ({ diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 49f98407..2d9a8b68 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -19,6 +19,7 @@ vi.mock('../../api/calendar', () => ({ queryExpandedEvents: vi.fn(), hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), resetSyntheticIdSupport: vi.fn(), + resetTimeZoneChecks: vi.fn(), })); vi.mock('@react-native-async-storage/async-storage', () => ({ diff --git a/src/stores/__tests__/calendar-subscriptions-store.test.ts b/src/stores/__tests__/calendar-subscriptions-store.test.ts index 96fd409a..2a5b73af 100644 --- a/src/stores/__tests__/calendar-subscriptions-store.test.ts +++ b/src/stores/__tests__/calendar-subscriptions-store.test.ts @@ -25,9 +25,11 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ }, })); +import * as calendarApi from '../../api/calendar'; import { normalizeFeedUrl, selectAccountSubscriptions, + useCalendarSubscriptionsStore, type CalendarSubscription, } from '../calendar-subscriptions-store'; @@ -57,3 +59,18 @@ describe('selectAccountSubscriptions', () => { expect(selectAccountSubscriptions(subs, 'acc-2').map((s) => s.id)).toEqual(['b', 'legacy']); }); }); + +describe('updateSubscription', () => { + it('renames the mirror calendar without re-sending a colour Stalwart 1.0 refuses', async () => { + const update = calendarApi.updateCalendar as ReturnType; + update.mockResolvedValue(undefined); + useCalendarSubscriptionsStore.setState({ + subscriptions: [{ id: 's', name: 'Feed', url: 'https://example.com/f.ics', color: '#FF2968FF', calendarId: 'c9', lastSyncAt: null, lastError: null }], + }); + + await useCalendarSubscriptionsStore.getState().updateSubscription('s', { name: 'Holidays', color: '#FF2968FF' }); + + expect(update).toHaveBeenCalledWith('c9', { name: 'Holidays' }); + expect(useCalendarSubscriptionsStore.getState().subscriptions[0].name).toBe('Holidays'); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 2a2c4b8f..920296e2 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -31,6 +31,7 @@ import { queryExpandedEvents, hydrateExpandedOccurrences, resetSyntheticIdSupport, + resetTimeZoneChecks, } from '../api/calendar'; import { jmapClient } from '../api/jmap-client'; import { expandRecurringEvents } from '../lib/recurrence-expansion'; @@ -1187,6 +1188,7 @@ export const useCalendarStore = create()( // upgraded server. resetCalendarAccessDenied(); resetSyntheticIdSupport(); + resetTimeZoneChecks(); syntheticIdRejected = false; overrideSeriesKeysRejected = false; knownMasters.clear(); diff --git a/src/stores/calendar-subscriptions-store.ts b/src/stores/calendar-subscriptions-store.ts index 78b933a5..64050241 100644 --- a/src/stores/calendar-subscriptions-store.ts +++ b/src/stores/calendar-subscriptions-store.ts @@ -201,7 +201,12 @@ export const useCalendarSubscriptionsStore = create()( }; if (next.name !== sub.name || next.color !== sub.color) { try { - await updateCalendar(sub.calendarId, { name: next.name, color: next.color }); + // Only what changed: Stalwart 1.0 refuses a colour that is not + // #rgb, #rrggbb or a CSS name, which another client may have set. + await updateCalendar(sub.calendarId, { + ...(next.name !== sub.name ? { name: next.name } : {}), + ...(next.color !== sub.color ? { color: next.color } : {}), + }); await useCalendarStore.getState().fetchCalendars(); } catch { // The mirror calendar keeps its old name/colour; the sub still updates.