diff --git a/src/api/__tests__/calendar-dst-bounds.test.ts b/src/api/__tests__/calendar-dst-bounds.test.ts new file mode 100644 index 00000000..640c0fd2 --- /dev/null +++ b/src/api/__tests__/calendar-dst-bounds.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const h = vi.hoisted(() => ({ session: null as unknown })); + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + get currentSession() { + return h.session; + }, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { queryEvents, queryExpandedEvents, resetTimeZoneChecks } from '../calendar'; +import { useSettingsStore } from '../../stores/settings-store'; + +// Stalwart 0.16 cannot place a range bound whose wall clock names no instant +// in the request's zone (the hour the clocks skip) or two (the hour they +// repeat): an expanded query fails with "Both 'after' and 'before' filters +// are required when expanding recurrences", a plain one drops the bound +// (live on 0.16.27). Such a bound goes out moved out of the range. + +const mockRequest = jmapClient.request as ReturnType; + +function connect(maxExpandedQueryDuration = 'P52W1D'): void { + h.session = { + apiUrl: 'https://mail.example.org/jmap/', + accounts: { 'acc-1': { accountCapabilities: { 'urn:ietf:params:jmap:calendars': { maxExpandedQueryDuration } } } }, + }; + mockRequest.mockImplementation(async (calls: [string, unknown, string][]) => ({ + methodResponses: calls.map(([name, , id]) => [name, { ids: [] }, id]), + })); +} + +const bounds = () => mockRequest.mock.calls.flatMap(([calls]) => + (calls as [string, { filter?: Record }, string][]) + .filter(([name]) => name === 'CalendarEvent/query') + .map(([, args]) => { + const filter = args.filter as { after?: string; before?: string; conditions?: { after?: string; before?: string }[] }; + const range = filter.conditions?.find((c) => 'after' in c) ?? filter; + return [range.after, range.before]; + })); + +beforeEach(() => { + vi.clearAllMocks(); + resetTimeZoneChecks(); +}); + +afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + h.session = null; +}); + +describe('range bounds next to a change of the clocks', () => { + // Havana turns the clocks back from 01:00 to 00:00 on 7 November 2027: + // the week from that Sunday starts at a midnight that happens twice. + const HAVANA_WEEK = { after: '2027-11-07T04:00:00.000Z', before: '2027-11-14T05:00:00.000Z' }; + + it('moves an expanded query\'s start out of the hour that repeats', async () => { + useSettingsStore.setState({ calendarTimeZone: 'America/Havana' }); + connect(); + + await queryExpandedEvents(HAVANA_WEEK.after, HAVANA_WEEK.before); + + expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]); + }); + + it('moves a plain query\'s start out of the hour that repeats', async () => { + useSettingsStore.setState({ calendarTimeZone: 'America/Havana' }); + connect(); + + await queryEvents(['cal-1'], HAVANA_WEEK.after, HAVANA_WEEK.before); + + expect(bounds()).toEqual([['2027-11-06T23:45:00', '2027-11-14T00:00:00']]); + }); + + it('keeps chunk borders out of the hour the clocks skip, still overlapping', async () => { + // Santiago skips from 00:00 to 01:00 on 5 September 2027. With a limit + // of two days the chunks are a day long and their borders fall at 00:30. + useSettingsStore.setState({ calendarTimeZone: 'America/Santiago' }); + connect('P2D'); + + await queryExpandedEvents('2027-09-04T04:30:00.000Z', '2027-09-06T04:30:00.000Z'); + + expect(bounds()).toEqual([ + ['2027-09-04T00:30:00', '2027-09-05T01:00:00'], + ['2027-09-04T23:59:59', '2027-09-06T00:29:59'], + ['2027-09-06T00:29:58', '2027-09-06T01:30:00'], + ]); + }); + + it('leaves bounds that name one instant as they are', async () => { + useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }); + connect(); + + // Berlin turns the clocks back at 03:00; its midnights are unique. + await queryExpandedEvents('2027-10-30T22:00:00.000Z', '2027-11-06T23:00:00.000Z'); + + expect(bounds()).toEqual([['2027-10-31T00:00:00', '2027-11-07T00:00:00']]); + }); +}); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index f1b46df8..2b174a14 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -2,7 +2,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; -import { getEffectiveTimeZone } from '../lib/calendar-timezone'; +import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { RECURRENCE_BASE_PROPERTIES, @@ -509,8 +509,8 @@ export async function queryEvents( 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 (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1); + if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1); if (Object.keys(range).length > 0) conditions.push(range); const filter = conditions.length === 1 ? conditions[0] @@ -589,6 +589,52 @@ function localDateTimeOf(seconds: number): string { + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; } +// Stalwart 0.16 cannot place a range bound whose wall clock names no instant +// in the request's time zone (in the hour the clocks skip) or two (in the +// hour they repeat): an expanded query fails with "Both 'after' and 'before' +// filters are required when expanding recurrences", and a plain one leaves +// the bound out (live on 0.16.27; 1.0 places both). Where the clocks change +// at midnight - Havana, the Azores, Santiago - the start of a day is such a +// time, and a chunk border can be one anywhere. Such a bound moves out of +// the range until it names one instant: `after` earlier, `before` later. +const BOUND_STEP = 15 * 60; +const MAX_BOUND_SHIFT = 3 * 3600; + +/** How many instants the wall clock `seconds` (wallClockSeconds) names in `timeZone`. */ +function instantsAt(seconds: number, timeZone: string): number { + const wall = localDateTimeOf(seconds); + // The offsets a day either side: a change of the clocks in between is + // the only one near. + const offsets = new Set([seconds - DAY_SECONDS, seconds + DAY_SECONDS].map( + (at) => getTimeZoneOffsetMs(new Date(at * 1000), timeZone) / 1000, + )); + let count = 0; + for (const offset of offsets) { + if (formatWallClock(new Date((seconds - offset) * 1000), timeZone) === wall) count++; + } + return count; +} + +/** The wall clock `seconds`, or the nearest one in `direction` that names one instant in `timeZone`. */ +function placeableBound(seconds: number, timeZone: string | undefined, direction: -1 | 1): number { + if (!timeZone || timeZone === 'UTC') return seconds; + try { + for (let shift = 0; shift <= MAX_BOUND_SHIFT; shift += BOUND_STEP) { + const candidate = seconds + direction * shift; + if (instantsAt(candidate, timeZone) === 1) return candidate; + } + } catch { + // A zone Intl does not know either: the server has the last word. + } + return seconds; +} + +/** placeableBound of a LocalDateTime; anything else as it is. */ +function placeableLocalBound(value: string, timeZone: string | undefined, direction: -1 | 1): string { + const seconds = wallClockSeconds(value); + return seconds === null ? value : localDateTimeOf(placeableBound(seconds, timeZone, direction)); +} + /** * [start, end] cut into chunks of at most `span` seconds, or null when that * takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server @@ -735,7 +781,10 @@ async function queryExpandedEventsIn( 'CalendarEvent/query', { ...baseArgs, - filter: { after: localDateTimeOf(chunk.start), before: localDateTimeOf(chunk.end) }, + filter: { + after: localDateTimeOf(placeableBound(chunk.start, timeZone, -1)), + before: localDateTimeOf(placeableBound(chunk.end, timeZone, 1)), + }, position: chunk.position, limit: QUERY_PAGE_SIZE, },