fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change

This commit is contained in:
Linus Rath
2026-10-29 01:58:24 +01:00
parent f3e11d4c7c
commit 7c775571c9
2 changed files with 158 additions and 4 deletions
@@ -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<typeof vi.fn>;
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, unknown> }, 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']]);
});
});
+53 -4
View File
@@ -2,7 +2,7 @@ import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent, CalendarRights } from './types'; import type { Calendar, CalendarEvent, CalendarRights } from './types';
import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; 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 { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection';
import { import {
RECURRENCE_BASE_PROPERTIES, RECURRENCE_BASE_PROPERTIES,
@@ -509,8 +509,8 @@ export async function queryEvents(
const conditions: Record<string, unknown>[] = []; const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const range: Record<string, string> = {}; const range: Record<string, string> = {};
if (after) range.after = toLocalDateTime(after, timeZone); if (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1);
if (before) range.before = toLocalDateTime(before, timeZone); if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1);
if (Object.keys(range).length > 0) conditions.push(range); if (Object.keys(range).length > 0) conditions.push(range);
const filter = conditions.length === 1 const filter = conditions.length === 1
? conditions[0] ? conditions[0]
@@ -589,6 +589,52 @@ function localDateTimeOf(seconds: number): string {
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; + `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 * [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 * takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
@@ -735,7 +781,10 @@ async function queryExpandedEventsIn(
'CalendarEvent/query', 'CalendarEvent/query',
{ {
...baseArgs, ...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, position: chunk.position,
limit: QUERY_PAGE_SIZE, limit: QUERY_PAGE_SIZE,
}, },