From 58e3ef6827eb889669606404a5b419b4e63534f7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:56 +0200 Subject: [PATCH] fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907) --- docs/parity/05-calendar.md | 8 +- src/api/__tests__/calendar.test.ts | 48 ++++- src/api/calendar.ts | 133 +++++++++++-- .../calendar-component-detection.test.ts | 38 ++++ src/lib/calendar-component-detection.ts | 71 +++++++ src/stores/__tests__/calendar-store.test.ts | 141 +++++++++++-- src/stores/calendar-store.ts | 187 +++++++++++++----- 7 files changed, 534 insertions(+), 92 deletions(-) create mode 100644 src/lib/__tests__/calendar-component-detection.test.ts create mode 100644 src/lib/calendar-component-detection.ts diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index adb90685..dac38885 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -53,22 +53,22 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi - What RN does: `handleSave` never sets `timeZone` (`src/components/calendar/EventModal.tsx:262-281`) and `createEvent` does not add one (`src/api/calendar.ts:248-275`). Stalwart stores a floating DTSTART; attendees in other zones, CalDAV clients, and the user after travelling see wrong times; iMIP invites sent for such events are ambiguous. - Fix hint: set `timeZone: allDay ? null : Intl.DateTimeFormat().resolvedOptions().timeZone` (reuse `getUserTimeZone` from `src/api/calendar.ts:15`); once a custom time-zone setting exists (see below) use that. -- [ ] **Changing the calendar in the edit modal is ignored (cannot move an event between calendars)** — `P2` — `rn-only-bug` +- [x] **Changing the calendar in the edit modal is ignored (cannot move an event between calendars)** — `P2` — `rn-only-bug` — fixed in 161baf1 + 3fd5f10 (cross-account moves are not offered) - What WEB does: `calendarIds: { [calendarId]: true }` is part of the saved patch and the store remaps store ids to server ids (ref `components/calendar/event-modal.tsx:503`, `stores/calendar-store.ts:706-715`). - What RN does: `EventModal.handleSave` passes `calendarId` as the second argument, but `CalendarScreen.handleSave` only uses it on create (`src/screens/CalendarScreen.tsx:365-374`); the picker in edit mode is a no-op. - Fix hint: on update include `calendarIds: { [cal.originalId || calendarId]: true }` in the patch; for shared calendars the owning account differs, so either block cross-account moves or delete + recreate. -- [ ] **Edit-modal calendar picker offers the Birthdays calendar, iCal-subscription calendars and read-only shared calendars (#762)** — `P2` — `bugfix-parity` +- [x] **Edit-modal calendar picker offers the Birthdays calendar, iCal-subscription calendars and read-only shared calendars (#762)** — `P2` — `bugfix-parity` — fixed in 3fd5f10 - What WEB does: `canCreateEventsIn` excludes subscription calendars and calendars without `mayWriteAll`/`mayWriteOwn` (ref `lib/calendar-editability.ts:20-24`, changelog 1.9.0 #762). - What RN does: `EventModal` receives `eventCalendars`, which still contains the virtual birthday calendar (`src/screens/CalendarScreen.tsx:213-216, 227-231, 544-552`) and all shared calendars regardless of rights; picking Birthdays yields a server error, picking a subscription calendar lets the next feed sync delete the event (`src/stores/calendar-subscriptions-store.ts:58-74`). - Fix hint: filter with a RN `canCreateEventsIn(cal, isSubscriptionCalendar)` (subscriptions from `useCalendarSubscriptionsStore`), exclude `BIRTHDAY_CALENDAR_ID`. -- [ ] **No rights-first editability (edit/delete offered on read-only and RSVP-only events)** — `P2` — `missing` +- [x] **No rights-first editability (edit/delete offered on read-only and RSVP-only events)** — `P2` — `missing` — fixed in 3fd5f10 - What WEB does: `getEventEditability` gates on calendar `myRights` first (`mayWriteAll`, `mayWriteOwn` + owner, `mayRSVP` + participant), subscription calendars are read-only, and the popover shows edit/delete vs. RSVP-only accordingly (ref `lib/calendar-editability.ts:47-76`, `components/calendar/event-detail-popover.tsx:161-171, 540-575`, changelog 1.9.0 "Rights-first event editability, including alias organizers"). - What RN does: `EventDetailSheet` always renders Edit/Delete (`src/components/calendar/EventDetailSheet.tsx:328-351`); the only read-only guard is the birthday calendar (`src/screens/CalendarScreen.tsx:313-316`). The server rejects the write, but the user sees a generic error. - Fix hint: port `calendar-editability.ts`; pass `calendarsById`, the user's addresses (identities + aliases) and `isSubscriptionCalendar` into the sheet; hide Edit/Delete for `read-only`, show only RSVP for `rsvp-only`. -- [ ] **`myRights.mayWrite` (non-existent JMAP property) used for writability everywhere** — `P2` — `rn-only-bug` +- [x] **`myRights.mayWrite` (non-existent JMAP property) used for writability everywhere** — `P2` — `rn-only-bug` — fixed in 3fd5f10 - What WEB does: uses `mayWriteAll`/`mayWriteOwn`/`mayDelete` from RFC-style `CalendarRights` (ref `lib/calendar-editability.ts:20-24`, `components/calendar/calendar-sidebar-panel.tsx:328-334`). - What RN does: `CalendarRights.mayWrite` is documented as a "legacy short flag" (`src/api/types.ts:~507`) and is what `CalendarSidebarDrawer` (`:92-98`), `TasksSheet` (`:84-87`), `ICalImportSheet` (`:51-54`) and `CalendarInvitationBanner` (`:96`) test. Stalwart never sends it, so every calendar counts as writable and the "Subscribed" section can never populate. - Fix hint: replace with `!r || r.mayWriteAll || r.mayWriteOwn` and drop `mayWrite` from the type. diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 82e97086..4d97a61e 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -16,8 +16,21 @@ import { createEvent, updateEvent, deleteEvents, + toLocalDateTime, } from '../calendar'; +describe('toLocalDateTime', () => { + it('renders an instant as wall-clock in the given zone', () => { + expect(toLocalDateTime('2026-03-01T00:30:00Z', 'Europe/Berlin')).toBe('2026-03-01T01:30:00'); + expect(toLocalDateTime('2026-03-01T00:30:00Z', 'America/New_York')).toBe('2026-02-28T19:30:00'); + expect(toLocalDateTime('2026-07-01T23:15:00.000Z', 'UTC')).toBe('2026-07-01T23:15:00'); + }); + + it('passes LocalDateTime strings through', () => { + expect(toLocalDateTime('2026-03-01T00:00:00', 'UTC')).toMatch(/^2026-0[23]-\d{2}T\d{2}:00:00$/); + }); +}); + const mockRequest = jmapClient.request as ReturnType; beforeEach(() => { @@ -43,14 +56,13 @@ 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. + // Stalwart rejects the draft's plural `inCalendars` filter; calendars + // are restricted via singular `inCalendar` conditions. mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], }); - const result = await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + const result = await queryEvents(['cal-1'], '', ''); expect(result).toEqual(['ev1', 'ev2']); const call = mockRequest.mock.calls[0][0][0]; @@ -59,6 +71,34 @@ describe('calendar operations', () => { expect(call[1].accountId).toBe('acc-1'); }); + it('should send the date window as LocalDateTime after/before combined with the calendar filter', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], + }); + + await queryEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].filter.operator).toBe('AND'); + expect(call[1].filter.conditions[0]).toEqual({ inCalendar: 'cal-1' }); + const range = call[1].filter.conditions[1]; + expect(range.after).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(range.before).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(call[1].timeZone).toBeTruthy(); + }); + + it('should send only the range when no calendars are given', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], + }); + + await queryEvents([], '2026-03-01T00:00:00Z', '2026-03-31T23:59:59Z'); + + const call = mockRequest.mock.calls[0][0][0]; + expect(call[1].filter.operator).toBeUndefined(); + expect(Object.keys(call[1].filter).sort()).toEqual(['after', 'before']); + }); + it('should OR multiple calendars and respect the target account', async () => { mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 22ec11d6..02b19417 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,11 +1,14 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Calendar, CalendarEvent } from './types'; +import { getEffectiveTimeZone } from '../lib/calendar-timezone'; +import { SCAN_PROPERTIES, type ScannedCalendarObject } from '../lib/calendar-component-detection'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; /** - * IANA time zone of the device, sent as the `timeZone` argument on + * 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 @@ -14,12 +17,63 @@ const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; */ function getUserTimeZone(): string | undefined { try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + return getEffectiveTimeZone() || undefined; } catch { return undefined; } } +/** + * Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock + * in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and + * which Stalwart interprets in the query's `timeZone`. Accepts ISO strings + * (with or without a zone) so callers can pass `Date.toISOString()` output. + */ +export function toLocalDateTime(value: string | Date, timeZone?: string): string { + const date = typeof value === 'string' ? new Date(value) : value; + if (isNaN(date.getTime())) return typeof value === 'string' ? value.replace(/(\.\d+)?Z$/, '') : ''; + try { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(date); + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'; + const hour = get('hour') === '24' ? '00' : get('hour'); + return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`; + } catch { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } +} + +// Shared accounts the server rejected calendar access for — probed once, then +// skipped for the rest of the session (the session lists every account with +// the calendars capability, including ones that only share mail with us). +const calendarAccessDenied = new Set(); + +export function isCalendarAccessDenied(accountId: string): boolean { + return calendarAccessDenied.has(accountId); +} + +export function resetCalendarAccessDenied(): void { + calendarAccessDenied.clear(); +} + +function isAccessDeniedError(err: unknown): boolean { + const type = (err as { jmapErrorType?: string } | null)?.jmapErrorType; + return type === 'forbidden' || type === 'accountNotFound'; +} + +/** Remember an access rejection for a shared account; returns true when it was one. */ +export function noteCalendarAccessError(accountId: string, err: unknown): boolean { + if (!isAccessDeniedError(err)) return false; + calendarAccessDenied.add(accountId); + return true; +} + /** * Build a CalendarEvent/query filter restricting results to the given * calendars. Stalwart implements the singular `inCalendar` condition (one @@ -130,7 +184,13 @@ function methodResult(res: any, index = 0): T { if (!entry) throw new Error('JMAP: empty method response'); if (entry[0] === 'error') { const err = entry[1] || {}; - throw new Error(err.description || err.type || 'JMAP method error'); + // Keep the JMAP error type so callers can tell an expected access + // rejection (forbidden / accountNotFound on a shared account) apart from + // a genuine failure. + throw Object.assign( + new Error(err.description || err.type || 'JMAP method error'), + { jmapErrorType: err.type as string | undefined }, + ); } return entry[1] as T; } @@ -159,6 +219,8 @@ export async function getCalendars(): Promise { // calendars, so each shared account is fetched best-effort. const shared = await Promise.all( sharedCalendarAccountIds().map(async (sharedAccountId) => { + // Don't re-probe an account the server already rejected this session. + if (calendarAccessDenied.has(sharedAccountId)) return []; try { const sharedRes = await jmapClient.request( [['Calendar/get', { accountId: sharedAccountId }, '0']], @@ -177,7 +239,8 @@ export async function getCalendars(): Promise { accountId: sharedAccountId, isShared: true, })); - } catch { + } catch (err) { + noteCalendarAccessError(sharedAccountId, err); return []; } }), @@ -185,21 +248,33 @@ export async function getCalendars(): Promise { return [...own, ...shared.flat()]; } +/** + * Query event ids in a date window. `after`/`before` are instants (ISO, as + * from `Date.toISOString()`) or LocalDateTime strings; they're sent as + * LocalDateTime in the query's `timeZone` so Stalwart interprets the bounds + * in the user's zone. Calendars are restricted via the singular `inCalendar` + * condition (the plural `inCalendars` fails the whole query with + * unsupportedFilter on Stalwart); the range and calendar conditions are + * combined with an AND operator. Without bounds every object is returned. + */ 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). 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 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); + if (conditions.length === 1) args.filter = conditions[0]; + else if (conditions.length > 1) args.filter = { operator: 'AND', conditions }; const res = await jmapClient.request( [['CalendarEvent/query', args, '0']], USING, @@ -207,6 +282,42 @@ export async function queryEvents( return methodResult<{ ids: string[] }>(res).ids ?? []; } +/** + * Every calendar object of an account with just the properties needed to tell + * tasks from events (see lib/calendar-component-detection). Used to find + * tasks-only calendars and the task ids; pages through the query so accounts + * with more than 1000 objects are covered. + */ +export async function scanCalendarObjects(accountId?: string): Promise { + const account = accountId || jmapClient.accountId; + const pageSize = 1000; + const ids: string[] = []; + for (let position = 0; ; position += pageSize) { + const res = await jmapClient.request( + [['CalendarEvent/query', { accountId: account, position, limit: pageSize }, '0']], + USING, + ); + const page = methodResult<{ ids: string[] }>(res).ids ?? []; + ids.push(...page); + if (page.length < pageSize) break; + } + if (ids.length === 0) return []; + const batchSize = jmapClient.getMaxObjectsInGet(); + const all: ScannedCalendarObject[] = []; + for (let i = 0; i < ids.length; i += batchSize) { + const res = await jmapClient.request( + [['CalendarEvent/get', { + accountId: account, + ids: ids.slice(i, i + batchSize), + properties: SCAN_PROPERTIES, + }, '0']], + USING, + ); + all.push(...(methodResult<{ list: ScannedCalendarObject[] }>(res).list ?? [])); + } + return all; +} + export async function getEvents(ids: string[], accountId?: string): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; diff --git a/src/lib/__tests__/calendar-component-detection.test.ts b/src/lib/__tests__/calendar-component-detection.test.ts new file mode 100644 index 00000000..a7ae6e65 --- /dev/null +++ b/src/lib/__tests__/calendar-component-detection.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { + findTasksOnlyCalendarIds, + isTaskLikeObject, + type ScannedCalendarObject, +} from '../calendar-component-detection'; + +describe('isTaskLikeObject', () => { + it('matches explicit Task objects', () => { + expect(isTaskLikeObject({ '@type': 'Task' })).toBe(true); + expect(isTaskLikeObject({ '@type': 'task' })).toBe(true); + }); + + it('matches CalDAV tasks without @type by task-only keys', () => { + expect(isTaskLikeObject({ due: '2026-03-01T00:00:00' })).toBe(true); + expect(isTaskLikeObject({ progress: 'needs-action' })).toBe(true); + expect(isTaskLikeObject({ percentComplete: 0 })).toBe(true); + }); + + it('never treats an explicit Event as a task', () => { + expect(isTaskLikeObject({ '@type': 'Event', due: 'x' })).toBe(false); + expect(isTaskLikeObject({ '@type': 'Event' })).toBe(false); + expect(isTaskLikeObject({ start: '2026-03-01T09:00:00' } as ScannedCalendarObject)).toBe(false); + }); +}); + +describe('findTasksOnlyCalendarIds', () => { + it('returns calendars that only hold tasks, ignoring empty ones', () => { + const objects: ScannedCalendarObject[] = [ + { '@type': 'Task', calendarIds: { tasks: true } }, + { due: '2026-03-01T00:00:00', calendarIds: { tasks: true } }, + { '@type': 'Event', calendarIds: { mixed: true } }, + { '@type': 'Task', calendarIds: { mixed: true } }, + ]; + const result = findTasksOnlyCalendarIds(objects, ['tasks', 'mixed', 'empty']); + expect([...result]).toEqual(['tasks']); + }); +}); diff --git a/src/lib/calendar-component-detection.ts b/src/lib/calendar-component-detection.ts new file mode 100644 index 00000000..3dc28cb8 --- /dev/null +++ b/src/lib/calendar-component-detection.ts @@ -0,0 +1,71 @@ +/** + * Helpers for deciding whether a calendar holds only tasks (VTODO) and so + * should be hidden from the *event* calendar UI (#761 / #28). + * + * JMAP's Calendar/get on Stalwart exposes no per-calendar supported-component + * set, so "tasks-only" has to be derived from the objects a calendar contains, + * mirroring how task objects are already told apart from events elsewhere. + * Port of the webmail's lib/calendar-component-detection.ts. + */ + +/** + * A calendar object as seen with the minimal properties we scan. The index + * signature keeps it tolerant of the other fields the server returns (id, etc.). + */ +export interface ScannedCalendarObject { + id?: string; + '@type'?: string; + due?: unknown; + progress?: unknown; + percentComplete?: unknown; + calendarIds?: Record | null; +} + +/** The properties a tasks-only scan needs to request. */ +export const SCAN_PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds']; + +/** + * Is this calendar object a task rather than an event? Matches an explicit + * `@type: "Task"`, and CalDAV-created tasks (which may lack the type) by the + * presence of RFC 8984 §5.2 Task-only keys (`due` / `progress` / + * `percentComplete`) - a VEVENT never carries those. Thunderbird/Todoist + * VTODOs that Stalwart returns without `@type` are otherwise neither shown + * as tasks nor counted for tasks-only calendars. + */ +export function isTaskLikeObject(obj: ScannedCalendarObject): boolean { + const type = obj['@type']; + if (typeof type === 'string' && type.toLowerCase() === 'task') return true; + if (type !== 'Event' && ( + ('progress' in obj && typeof obj.progress === 'string') || + ('due' in obj && obj.due != null) || + ('percentComplete' in obj) + )) return true; + return false; +} + +/** + * Given every object in an account and the calendar ids under consideration, + * return the ids of calendars that hold at least one object and whose objects + * are ALL tasks (no event). Empty calendars are deliberately NOT included - + * a brand-new event calendar with nothing in it must stay visible. + */ +export function findTasksOnlyCalendarIds( + objects: ScannedCalendarObject[], + calendarIds: string[], +): Set { + const withAny = new Set(); + const withEvent = new Set(); + for (const obj of objects) { + const task = isTaskLikeObject(obj); + const ids = obj.calendarIds ? Object.keys(obj.calendarIds) : []; + for (const id of ids) { + withAny.add(id); + if (!task) withEvent.add(id); + } + } + const tasksOnly = new Set(); + for (const id of calendarIds) { + if (withAny.has(id) && !withEvent.has(id)) tasksOnly.add(id); + } + return tasksOnly; +} diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index bec13c4c..ae5f9ba2 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -4,6 +4,10 @@ vi.mock('../../api/calendar', () => ({ getCalendars: vi.fn(), queryEvents: vi.fn(), getEvents: vi.fn(), + scanCalendarObjects: vi.fn(), + isCalendarAccessDenied: vi.fn(() => false), + noteCalendarAccessError: vi.fn(() => false), + resetCalendarAccessDenied: vi.fn(), createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvents: vi.fn(), @@ -39,9 +43,11 @@ const mockCreateEvent = calendarApi.createEvent as ReturnType; const mockUpdateEvent = calendarApi.updateEvent as ReturnType; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType; const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType; +const mockScan = calendarApi.scanCalendarObjects as ReturnType; beforeEach(() => { vi.clearAllMocks(); + mockScan.mockResolvedValue([]); useCalendarStore.setState({ calendars: [], events: [], @@ -132,39 +138,83 @@ describe('calendar-store', () => { expect(stored[0].accountId).toBe('acc-2'); }); - it('classifies calendars whose contents are all Tasks as task lists (#28)', async () => { + it('keeps tasks (also CalDAV ones without @type) out of the event grid', async () => { + mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2']); + mockGetEvents.mockResolvedValue([ + { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true } }, + { id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-1': true } }, + { id: 't2', title: 'Thunderbird todo', due: '2026-03-16T00:00:00', start: '2026-03-15T10:00:00', calendarIds: { 'cal-1': true } }, + ]); + + await useCalendarStore.getState().fetchEvents(['cal-1'], '2026-03-01', '2026-03-31'); + + expect(useCalendarStore.getState().events.map((e) => e.id)).toEqual(['e1']); + }); + }); + + describe('fetchTasks', () => { + it('classifies calendars whose contents are all tasks as task lists (#28)', async () => { // VTODO-only CalDAV collections (Todoist imports, Thunderbird task // lists) must be flagged so the drawer can keep them out of the // calendar list. Mixed calendars, calendars with start-less Events, - // and empty calendars all stay ordinary calendars. - mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2', 'startless']); - mockGetEvents.mockResolvedValue([ - { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-tasks': true } }, - { id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-mixed': true } }, - { id: 't2', '@type': 'Task', title: 'Ship it', calendarIds: { 'cal-mixed': true } }, + // and empty calendars all stay ordinary calendars. Tasks created by + // external CalDAV clients may lack `@type` and are detected by their + // task-only keys. + useCalendarStore.setState({ + calendars: [ + { id: 'cal-tasks' }, { id: 'cal-mixed' }, { id: 'cal-startless' }, { id: 'cal-empty' }, + ] as any, + }); + mockScan.mockResolvedValue([ + { id: 't1', '@type': 'Task', calendarIds: { 'cal-tasks': true } }, + { id: 't3', due: '2026-03-16T00:00:00', calendarIds: { 'cal-tasks': true } }, + { id: 'e1', '@type': 'Event', calendarIds: { 'cal-mixed': true } }, + { id: 't2', '@type': 'Task', calendarIds: { 'cal-mixed': true } }, { id: 'startless', '@type': 'Event', calendarIds: { 'cal-startless': true } }, ]); + mockGetEvents.mockImplementation(async (ids: string[]) => ids.map((id) => ({ id, title: id }))); - await useCalendarStore.getState().fetchEvents( - ['cal-tasks', 'cal-mixed', 'cal-startless', 'cal-empty'], - '2026-03-01', - '2026-03-31', - ); + await useCalendarStore.getState().fetchTasks(); expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['cal-tasks']); - expect(useCalendarStore.getState().tasks.map((t) => t.id)).toEqual(['t1', 't2']); + expect(mockGetEvents).toHaveBeenCalledWith(['t1', 't3', 't2'], undefined); + expect(useCalendarStore.getState().tasks.map((t) => t.id)).toEqual(['t1', 't3', 't2']); }); - it('reclassifies task lists on refetch instead of accumulating stale ids', async () => { - useCalendarStore.setState({ taskOnlyCalendarIds: ['cal-old'] }); - mockQueryEvents.mockResolvedValue(['e1']); - mockGetEvents.mockResolvedValue([ - { id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-old': true } }, + it('reclassifies task lists on rescan instead of accumulating stale ids', async () => { + useCalendarStore.setState({ + calendars: [{ id: 'cal-old' }] as any, + taskOnlyCalendarIds: ['cal-old'], + }); + mockScan.mockResolvedValue([ + { id: 'e1', '@type': 'Event', calendarIds: { 'cal-old': true } }, ]); - await useCalendarStore.getState().fetchEvents(['cal-old'], '2026-03-01', '2026-03-31'); + await useCalendarStore.getState().fetchTasks(); expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual([]); + expect(useCalendarStore.getState().tasks).toEqual([]); + }); + + it('maps shared-account task lists onto namespaced store ids', async () => { + useCalendarStore.setState({ + calendars: [ + { id: 'cal-1' }, + { id: 'acc-2:todo', originalId: 'todo', accountId: 'acc-2', isShared: true }, + ] as any, + }); + mockScan.mockImplementation(async (accountId?: string) => + accountId === 'acc-2' ? [{ id: 't1', '@type': 'Task', calendarIds: { todo: true } }] : [], + ); + mockGetEvents.mockImplementation(async (ids: string[], accountId?: string) => + accountId === 'acc-2' ? [{ id: 't1', title: 'Shared todo', calendarIds: { todo: true } }] : [], + ); + + await useCalendarStore.getState().fetchTasks(); + + expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['acc-2:todo']); + expect(useCalendarStore.getState().tasks[0].id).toBe('acc-2:t1'); + expect(useCalendarStore.getState().tasks[0].calendarIds).toEqual({ 'acc-2:todo': true }); }); it('routes queries by account when own and shared calendars share a raw id', async () => { @@ -229,7 +279,9 @@ describe('calendar-store', () => { expect(mockQueryEvents).not.toHaveBeenCalled(); }); - it('should expand the loaded range when requested window extends it', async () => { + it('should load exactly the requested window when it is not covered', async () => { + // Queries are date-windowed, so moving past the loaded range fetches + // the new window instead of an ever-growing union of everything seen. useCalendarStore.setState({ calendars: [{ id: 'cal-1' } as any], loadedRange: { after: '2026-03-01', before: '2026-03-31' }, @@ -238,7 +290,24 @@ describe('calendar-store', () => { await useCalendarStore.getState().ensureRange('2026-04-01', '2026-04-30'); - expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-04-30', undefined); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-04-01', '2026-04-30', undefined); + expect(useCalendarStore.getState().loadedRange).toEqual({ after: '2026-04-01', before: '2026-04-30' }); + }); + + it('dedupes concurrent first-touch calendar fetches (#907)', async () => { + let resolveCalendars: (v: unknown) => void = () => {}; + mockGetCalendars.mockImplementation( + () => new Promise((resolve) => { resolveCalendars = resolve; }), + ); + mockQueryEvents.mockResolvedValue([]); + + const a = useCalendarStore.getState().fetchCalendars(); + const b = useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + resolveCalendars([{ id: 'cal-1', name: 'Personal' }]); + await Promise.all([a, b]); + + expect(mockGetCalendars).toHaveBeenCalledTimes(1); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); }); it('should fetch calendars first when none are loaded', async () => { @@ -351,6 +420,36 @@ describe('calendar-store', () => { expect(result).toEqual(created); expect(useCalendarStore.getState().events).toHaveLength(2); }); + + it('re-reads the created event and expands a recurring series across the loaded range', async () => { + useCalendarStore.setState({ + events: [], + loadedRange: { after: '2026-03-01T00:00:00Z', before: '2026-03-10T00:00:00Z' }, + }); + mockCreateEvent.mockResolvedValue({ id: 'ev-new', title: 'Standup', start: '2026-03-02T09:00:00' }); + mockGetEvents.mockResolvedValue([{ + id: 'ev-new', + uid: 'u', + title: 'Standup', + start: '2026-03-02T09:00:00', + utcStart: '2026-03-02T08:00:00Z', + utcEnd: '2026-03-02T08:30:00Z', + duration: 'PT30M', + calendarIds: { 'cal-1': true }, + recurrenceRules: [{ frequency: 'daily', count: 3 }], + }]); + + const result = await useCalendarStore.getState().createEvent( + { title: 'Standup', start: '2026-03-02T09:00:00' }, + 'cal-1', + ); + + expect(mockGetEvents).toHaveBeenCalledWith(['ev-new'], undefined); + expect(result.utcStart).toBe('2026-03-02T08:00:00Z'); + const events = useCalendarStore.getState().events; + expect(events).toHaveLength(3); + expect(events.every((e) => e.originalId === 'ev-new' && !!e.recurrenceId)).toBe(true); + }); }); describe('updateEvent', () => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 738cb609..43bb63c8 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -6,6 +6,10 @@ import { getCalendars as fetchCalendars, queryEvents, getEvents as fetchEvents, + scanCalendarObjects, + isCalendarAccessDenied, + noteCalendarAccessError, + resetCalendarAccessDenied, createEvent as apiCreateEvent, updateEvent as apiUpdateEvent, deleteEvents as apiDeleteEvents, @@ -17,6 +21,7 @@ import { import { jmapClient } from '../api/jmap-client'; import { expandRecurringEvents } from '../lib/recurrence-expansion'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; +import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection'; // Does the event carry attendees the server should notify over iMIP? Used to // decide whether to set sendSchedulingMessages on create/update/delete. @@ -81,6 +86,9 @@ export interface CalendarState { hydrate: () => Promise; fetchCalendars: () => Promise; fetchEvents: (calendarIds: string[], after: string, before: string) => Promise; + // Scan every calendar object (all accounts) to list tasks and find the + // tasks-only calendars; independent of the visible date range. + fetchTasks: () => Promise; ensureRange: (after: string, before: string) => Promise; refresh: () => Promise; handleStateChange: (change: StateChange) => Promise; @@ -112,6 +120,10 @@ export interface CalendarState { reset: () => void; } +// In-flight dedupe for the two whole-account loads (see fetchCalendars). +let calendarsInFlight: Promise | null = null; +let tasksInFlight: Promise | null = null; + function persistHidden(ids: string[]): void { void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( (err) => console.warn('[calendar-store] persist hidden failed', err), @@ -165,12 +177,22 @@ export const useCalendarStore = create()( // a "Not authenticated" error - the refetch driven by the auth-store // will run this again once the session is live. if (!jmapClient.isConnected) return; - try { - const calendars = (await fetchCalendars()) ?? []; - set({ calendars }); - } catch (err) { - set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); - } + // First-touch gate (#907): the screen mount, ensureRange and the + // auth-store all kick this off at once, and on a clustered Stalwart + // concurrent first Calendar/* requests can each lazily create a default + // calendar. Share one in-flight request instead. + if (calendarsInFlight) return calendarsInFlight; + calendarsInFlight = (async () => { + try { + const calendars = (await fetchCalendars()) ?? []; + set({ calendars }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); + } finally { + calendarsInFlight = null; + } + })(); + return calendarsInFlight; }, fetchEvents: async (calendarIds, after, before) => { @@ -198,62 +220,99 @@ export const useCalendarStore = create()( const raw: CalendarEvent[] = []; for (const [accountId, ids] of groups) { + if (accountId && isCalendarAccessDenied(accountId)) continue; try { + // The window is sent as after/before so accounts with more than + // 1000 objects don't silently lose events and navigating past the + // loaded range doesn't re-download everything. const eventIds = (await queryEvents(ids, after, before, accountId)) ?? []; if (eventIds.length === 0) continue; const fetched = (await fetchEvents(eventIds, accountId)) ?? []; raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); } catch (err) { - // A failing shared account must not hide the user's own events. + // A failing shared account must not hide the user's own events; + // remember an access rejection so it isn't re-probed every fetch. if (!accountId) throw err; + noteCalendarAccessError(accountId, err); } } - // Stalwart returns both Events and Tasks from CalendarEvent/query. Events - // have a `start`; tasks are surfaced separately so they don't pollute the - // grid (they're shown in the task list with their due date instead). - const onlyEvents = raw.filter((e) => { - const t = (e as { '@type'?: string })['@type']; - return (t === 'Event' || t === undefined) && !!e.start; - }); - const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task'); - // Classify VTODO-only task lists from the full (undated) object set: - // a calendar counts as a task list when every object in it is a Task. - // Anything that isn't a Task — including start-less Events — keeps the - // calendar in the ordinary event-calendar bucket. - const eventCalendarIds = new Set(); - const taskCalendarIds = new Set(); - for (const obj of raw) { - const isTask = (obj as { '@type'?: string })['@type'] === 'Task'; - for (const calId of Object.keys(obj.calendarIds || {})) { - (isTask ? taskCalendarIds : eventCalendarIds).add(calId); - } - } - const taskOnlyCalendarIds = [...taskCalendarIds].filter( - (calId) => !eventCalendarIds.has(calId), - ); + // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks + // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so + // they don't pollute the grid. + const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start); const events = expandRecurringEvents(onlyEvents, after, before); - set({ events, tasks, taskOnlyCalendarIds, loadedRange: { after, before }, loading: false }); + set({ events, loadedRange: { after, before }, loading: false }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); } }, + fetchTasks: async () => { + if (!jmapClient.isConnected) return; + if (tasksInFlight) return tasksInFlight; + tasksInFlight = (async () => { + try { + const calendars = get().calendars; + const accountIds = new Set([undefined]); + for (const cal of calendars) if (cal.accountId) accountIds.add(cal.accountId); + const tasks: CalendarEvent[] = []; + const taskOnly: string[] = []; + for (const accountId of accountIds) { + if (accountId && isCalendarAccessDenied(accountId)) continue; + try { + const scanned = await scanCalendarObjects(accountId); + // Classify VTODO-only task lists from the full (undated) object + // set: a calendar counts as a task list when every object in it + // is a task. Empty calendars stay ordinary event calendars. (#28) + const rawIds = calendars + .filter((c) => (c.accountId ?? undefined) === accountId) + .map((c) => c.originalId || c.id); + for (const rawId of findTasksOnlyCalendarIds(scanned, rawIds)) { + const cal = calendars.find( + (c) => (c.originalId || c.id) === rawId && (c.accountId ?? undefined) === accountId, + ); + taskOnly.push(cal?.id ?? rawId); + } + const taskIds = scanned + .filter((o) => isTaskLikeObject(o)) + .map((o) => o.id as string) + .filter(Boolean); + if (taskIds.length === 0) continue; + const fetched = (await fetchEvents(taskIds, accountId)) ?? []; + tasks.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); + } catch (err) { + if (!accountId) throw err; + noteCalendarAccessError(accountId, err); + } + } + set({ tasks, taskOnlyCalendarIds: taskOnly }); + } catch (err) { + set({ error: err instanceof Error ? err.message : 'Failed to load tasks' }); + } finally { + tasksInFlight = null; + } + })(); + return tasksInFlight; + }, + ensureRange: async (after, before) => { - const { loadedRange, calendars } = get(); + const { loadedRange } = get(); if (loadedRange && rangeCovers(loadedRange, after, before)) return; - const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before }; - const ids = calendars.map((c) => c.id); - if (ids.length === 0) { - // Calendars haven't loaded yet - fetch them, then events. + // Queries are windowed now, so load exactly the requested range instead + // of an ever-growing union (which re-downloaded everything each time). + if (get().calendars.length === 0) { + // Calendars haven't loaded yet - fetch them (deduped), then events. await get().fetchCalendars(); } - const calendarIdsAfter = get().calendars.map((c) => c.id); - if (calendarIdsAfter.length === 0) { - set({ loadedRange: target }); + const calendarIds = get().calendars.map((c) => c.id); + if (calendarIds.length === 0) { + set({ loadedRange: { after, before } }); return; } - await get().fetchEvents(calendarIdsAfter, target.after, target.before); + const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0; + await get().fetchEvents(calendarIds, after, before); + if (needTasks) void get().fetchTasks(); }, refresh: async () => { @@ -261,7 +320,10 @@ export const useCalendarStore = create()( if (!loadedRange) return; const ids = calendars.map((c) => c.id); if (ids.length === 0) return; - await get().fetchEvents(ids, loadedRange.after, loadedRange.before); + await Promise.all([ + get().fetchEvents(ids, loadedRange.after, loadedRange.before), + get().fetchTasks(), + ]); }, handleStateChange: async (change) => { @@ -302,11 +364,28 @@ export const useCalendarStore = create()( hasSchedulingParticipants(event) ? true : undefined, accountId, ); + // The /set echo lacks server-computed properties (utcStart/utcEnd, the + // normalised recurrence rule); re-read the event so it renders at the + // right instant, then expand a recurring series across the loaded range + // instead of showing a single instance until the next refresh. + let full = created; + try { + const fetched = (await fetchEvents([created.id], accountId)) ?? []; + if (fetched[0]) full = fetched[0]; + } catch { + // Keep the echo; the next refresh reconciles. + } // Map the same way fetchEvents does so the optimistic insert doesn't // collide with the user's own events and stays visible under the right // calendar filter. - set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] }); - return created; + const mapped = mapServerEventToStoreEvent(full, calendars, accountId); + const { loadedRange } = get(); + const inserted = + mapped.recurrenceRules?.length && loadedRange + ? expandRecurringEvents([mapped], loadedRange.after, loadedRange.before) + : [mapped]; + set({ events: [...get().events, ...inserted] }); + return full; }, updateEvent: async (id, changes) => { @@ -506,15 +585,19 @@ export const useCalendarStore = create()( persistHidden(next); }, - reset: () => set({ - calendars: [], - events: [], - tasks: [], - taskOnlyCalendarIds: [], - loadedRange: null, - loading: false, - error: null, - }), + reset: () => { + // A new session may grant access the old one lacked. + resetCalendarAccessDenied(); + set({ + calendars: [], + events: [], + tasks: [], + taskOnlyCalendarIds: [], + loadedRange: null, + loading: false, + error: null, + }); + }, }), { // Persist calendars + expanded events + the range they cover so the