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.