diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 82e97086..0abc1041 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -42,10 +42,12 @@ describe('calendar operations', () => { }); describe('queryEvents', () => { - it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => { - // Stalwart rejects the draft's plural `inCalendars` filter (and - // after/before); calendars are restricted via singular `inCalendar` - // conditions and date filtering stays client-side. + it('should AND the date window with the singular inCalendar condition', async () => { + // Stalwart rejects the draft's plural `inCalendars` filter, so calendars + // are restricted via singular `inCalendar` conditions. The date window + // must reach the server: without it the query asks for every event in + // the account and the response is capped at the page size, so the + // calendar renders empty for anyone with more than one page of history. mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], }); @@ -55,10 +57,38 @@ describe('calendar operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[0]).toBe('CalendarEvent/query'); - expect(call[1].filter).toEqual({ inCalendar: 'cal-1' }); + expect(call[1].filter).toEqual({ + operator: 'AND', + conditions: [ + { inCalendar: 'cal-1' }, + { after: '2026-03-01T00:00:00Z', before: '2026-03-31T23:59:59Z' }, + ], + }); expect(call[1].accountId).toBe('acc-1'); }); + it('should page through a result set larger than one page', async () => { + // Regression: a full page used to be returned as the complete answer, so + // an account with more events than the page size silently lost every + // event past it - the calendar rendered empty. + const first = Array.from({ length: 1000 }, (_, i) => `a${i}`); + mockRequest + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: first }, '0']], + }) + .mockResolvedValueOnce({ + methodResponses: [['CalendarEvent/query', { ids: ['tail-1', 'tail-2'] }, '0']], + }); + + const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + + expect(result).toHaveLength(1002); + expect(result[1001]).toBe('tail-2'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockRequest.mock.calls[0][0][0][1].position).toBe(0); + expect(mockRequest.mock.calls[1][0][0][1].position).toBe(1000); + }); + it('should OR multiple calendars and respect the target account', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], @@ -74,7 +104,7 @@ describe('calendar operations', () => { expect(call[1].accountId).toBe('acc-shared'); }); - it('should omit the filter when no calendars are given', async () => { + it('should omit the filter when no calendars are given and window is empty', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], }); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 544ad177..72d863e2 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -185,26 +185,70 @@ export async function getCalendars(): Promise { return [...own, ...shared.flat()]; } +const QUERY_PAGE_SIZE = 1000; +// Backstop against a server that keeps returning full pages; 20 pages is far +// beyond any real calendar window and keeps a bug from looping forever. +const QUERY_MAX_PAGES = 20; + +/** + * Combine the calendar restriction and the date window into a single filter. + * Stalwart matches an event when its whole time range - including every + * recurrence - overlaps the window, which is exactly the set the client-side + * expander needs (a recurring master whose base start predates the window + * still comes back if any occurrence falls inside). Either part may be absent + * (callers that want every event in an account pass empty bounds). + */ +function buildEventFilter( + calendarIds: string[], + after: string, + before: string, +): Record | undefined { + const conditions: Record[] = []; + if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); + const window: Record = {}; + if (after) window.after = after; + if (before) window.before = before; + if (Object.keys(window).length > 0) conditions.push(window); + if (conditions.length === 0) return undefined; + if (conditions.length === 1) return conditions[0]; + return { operator: 'AND', conditions }; +} + export async function queryEvents( calendarIds: string[], - _after: string, - _before: string, + after: string, + before: string, accountId?: string, ): Promise { - // Stalwart rejects after/before filters on CalendarEvent/query; date - // filtering is done client-side. Calendars are restricted via the singular - // `inCalendar` condition (the plural `inCalendars` fails the whole query - // with unsupportedFilter on Stalwart). + // The date window is applied server-side. Without it the query asks for + // every event in the account and the response is silently capped at the + // page size, so any account holding more events than one page loses + // everything past it - including the month being displayed. Calendars are + // restricted via the singular `inCalendar` condition (the plural + // `inCalendars` fails the whole query with unsupportedFilter on Stalwart). const account = accountId || jmapClient.accountId; - const args: Record = { accountId: account, limit: 1000 }; const timeZone = getUserTimeZone(); - if (timeZone) args.timeZone = timeZone; - if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds); - const res = await jmapClient.request( - [['CalendarEvent/query', args, '0']], - USING, - ); - return methodResult<{ ids: string[] }>(res).ids ?? []; + const filter = buildEventFilter(calendarIds, after, before); + const ids: string[] = []; + // Page through the result set: a window can still hold more events than one + // page, and a truncated page must never pass for a complete answer. + 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; } export async function getEvents(ids: string[], accountId?: string): Promise {