fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907)

This commit is contained in:
Linus Rath
2026-08-29 18:05:56 +02:00
parent 4bebf70482
commit 58e3ef6827
7 changed files with 534 additions and 92 deletions
+4 -4
View File
@@ -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.
+44 -4
View File
@@ -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<typeof vi.fn>;
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']],
+122 -11
View File
@@ -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<string>();
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<T = any>(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<Calendar[]> {
// 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<Calendar[]> {
accountId: sharedAccountId,
isShared: true,
}));
} catch {
} catch (err) {
noteCalendarAccessError(sharedAccountId, err);
return [];
}
}),
@@ -185,21 +248,33 @@ export async function getCalendars(): Promise<Calendar[]> {
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<string[]> {
// 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<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone;
if (calendarIds.length > 0) args.filter = buildInCalendarFilter(calendarIds);
const conditions: Record<string, unknown>[] = [];
if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds));
const range: Record<string, string> = {};
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<ScannedCalendarObject[]> {
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<CalendarEvent[]> {
if (ids.length === 0) return [];
const account = accountId || jmapClient.accountId;
@@ -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']);
});
});
+71
View File
@@ -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<string, boolean> | 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<string> {
const withAny = new Set<string>();
const withEvent = new Set<string>();
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<string>();
for (const id of calendarIds) {
if (withAny.has(id) && !withEvent.has(id)) tasksOnly.add(id);
}
return tasksOnly;
}
+120 -21
View File
@@ -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<typeof vi.fn>;
const mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
const mockScan = calendarApi.scanCalendarObjects as ReturnType<typeof vi.fn>;
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', () => {
+135 -52
View File
@@ -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<void>;
fetchCalendars: () => Promise<void>;
fetchEvents: (calendarIds: string[], after: string, before: string) => Promise<void>;
// Scan every calendar object (all accounts) to list tasks and find the
// tasks-only calendars; independent of the visible date range.
fetchTasks: () => Promise<void>;
ensureRange: (after: string, before: string) => Promise<void>;
refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
@@ -112,6 +120,10 @@ export interface CalendarState {
reset: () => void;
}
// In-flight dedupe for the two whole-account loads (see fetchCalendars).
let calendarsInFlight: Promise<void> | null = null;
let tasksInFlight: Promise<void> | 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<CalendarState>()(
// 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<CalendarState>()(
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<string>();
const taskCalendarIds = new Set<string>();
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<string | undefined>([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<CalendarState>()(
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<CalendarState>()(
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<CalendarState>()(
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