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. - 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. - 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 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. - 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. - 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 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`). - 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`. - 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 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. - 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`. - 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 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. - 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. - 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, createEvent,
updateEvent, updateEvent,
deleteEvents, deleteEvents,
toLocalDateTime,
} from '../calendar'; } 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>; const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
@@ -43,14 +56,13 @@ describe('calendar operations', () => {
describe('queryEvents', () => { describe('queryEvents', () => {
it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => { it('should filter via singular inCalendar conditions (Stalwart-compatible)', async () => {
// Stalwart rejects the draft's plural `inCalendars` filter (and // Stalwart rejects the draft's plural `inCalendars` filter; calendars
// after/before); calendars are restricted via singular `inCalendar` // are restricted via singular `inCalendar` conditions.
// conditions and date filtering stays client-side.
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], 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']); expect(result).toEqual(['ev1', 'ev2']);
const call = mockRequest.mock.calls[0][0][0]; const call = mockRequest.mock.calls[0][0][0];
@@ -59,6 +71,34 @@ describe('calendar operations', () => {
expect(call[1].accountId).toBe('acc-1'); 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 () => { it('should OR multiple calendars and respect the target account', async () => {
mockRequest.mockResolvedValue({ mockRequest.mockResolvedValue({
methodResponses: [['CalendarEvent/query', { ids: [] }, '0']], methodResponses: [['CalendarEvent/query', { ids: [] }, '0']],
+122 -11
View File
@@ -1,11 +1,14 @@
import { jmapClient } from './jmap-client'; import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types'; import { CAPABILITIES } from './types';
import type { Calendar, CalendarEvent } 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]; 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 * CalendarEvent/query and CalendarEvent/get. Stalwart interprets LocalDateTime
* filter values and computes utcStart/utcEnd for floating events in this zone, * filter values and computes utcStart/utcEnd for floating events in this zone,
* defaulting to UTC when absent — which shifts range boundaries and * defaulting to UTC when absent — which shifts range boundaries and
@@ -14,12 +17,63 @@ const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
*/ */
function getUserTimeZone(): string | undefined { function getUserTimeZone(): string | undefined {
try { try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; return getEffectiveTimeZone() || undefined;
} catch { } catch {
return undefined; 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 * Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one * 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) throw new Error('JMAP: empty method response');
if (entry[0] === 'error') { if (entry[0] === 'error') {
const err = entry[1] || {}; 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; return entry[1] as T;
} }
@@ -159,6 +219,8 @@ export async function getCalendars(): Promise<Calendar[]> {
// calendars, so each shared account is fetched best-effort. // calendars, so each shared account is fetched best-effort.
const shared = await Promise.all( const shared = await Promise.all(
sharedCalendarAccountIds().map(async (sharedAccountId) => { sharedCalendarAccountIds().map(async (sharedAccountId) => {
// Don't re-probe an account the server already rejected this session.
if (calendarAccessDenied.has(sharedAccountId)) return [];
try { try {
const sharedRes = await jmapClient.request( const sharedRes = await jmapClient.request(
[['Calendar/get', { accountId: sharedAccountId }, '0']], [['Calendar/get', { accountId: sharedAccountId }, '0']],
@@ -177,7 +239,8 @@ export async function getCalendars(): Promise<Calendar[]> {
accountId: sharedAccountId, accountId: sharedAccountId,
isShared: true, isShared: true,
})); }));
} catch { } catch (err) {
noteCalendarAccessError(sharedAccountId, err);
return []; return [];
} }
}), }),
@@ -185,21 +248,33 @@ export async function getCalendars(): Promise<Calendar[]> {
return [...own, ...shared.flat()]; 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( export async function queryEvents(
calendarIds: string[], calendarIds: string[],
_after: string, after: string,
_before: string, before: string,
accountId?: string, accountId?: string,
): Promise<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 account = accountId || jmapClient.accountId;
const args: Record<string, unknown> = { accountId: account, limit: 1000 }; const args: Record<string, unknown> = { accountId: account, limit: 1000 };
const timeZone = getUserTimeZone(); const timeZone = getUserTimeZone();
if (timeZone) args.timeZone = timeZone; 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( const res = await jmapClient.request(
[['CalendarEvent/query', args, '0']], [['CalendarEvent/query', args, '0']],
USING, USING,
@@ -207,6 +282,42 @@ export async function queryEvents(
return methodResult<{ ids: string[] }>(res).ids ?? []; 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[]> { export async function getEvents(ids: string[], accountId?: string): Promise<CalendarEvent[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const account = accountId || jmapClient.accountId; 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(), getCalendars: vi.fn(),
queryEvents: vi.fn(), queryEvents: vi.fn(),
getEvents: vi.fn(), getEvents: vi.fn(),
scanCalendarObjects: vi.fn(),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
createEvent: vi.fn(), createEvent: vi.fn(),
updateEvent: vi.fn(), updateEvent: vi.fn(),
deleteEvents: 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 mockUpdateEvent = calendarApi.updateEvent as ReturnType<typeof vi.fn>;
const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>; const mockDeleteEvents = calendarApi.deleteEvents as ReturnType<typeof vi.fn>;
const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>; const mockSetDefaultCalendar = calendarApi.setDefaultCalendar as ReturnType<typeof vi.fn>;
const mockScan = calendarApi.scanCalendarObjects as ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockScan.mockResolvedValue([]);
useCalendarStore.setState({ useCalendarStore.setState({
calendars: [], calendars: [],
events: [], events: [],
@@ -132,39 +138,83 @@ describe('calendar-store', () => {
expect(stored[0].accountId).toBe('acc-2'); 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 // VTODO-only CalDAV collections (Todoist imports, Thunderbird task
// lists) must be flagged so the drawer can keep them out of the // lists) must be flagged so the drawer can keep them out of the
// calendar list. Mixed calendars, calendars with start-less Events, // calendar list. Mixed calendars, calendars with start-less Events,
// and empty calendars all stay ordinary calendars. // and empty calendars all stay ordinary calendars. Tasks created by
mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2', 'startless']); // external CalDAV clients may lack `@type` and are detected by their
mockGetEvents.mockResolvedValue([ // task-only keys.
{ id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-tasks': true } }, useCalendarStore.setState({
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-mixed': true } }, calendars: [
{ id: 't2', '@type': 'Task', title: 'Ship it', calendarIds: { 'cal-mixed': true } }, { 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 } }, { id: 'startless', '@type': 'Event', calendarIds: { 'cal-startless': true } },
]); ]);
mockGetEvents.mockImplementation(async (ids: string[]) => ids.map((id) => ({ id, title: id })));
await useCalendarStore.getState().fetchEvents( await useCalendarStore.getState().fetchTasks();
['cal-tasks', 'cal-mixed', 'cal-startless', 'cal-empty'],
'2026-03-01',
'2026-03-31',
);
expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['cal-tasks']); 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 () => { it('reclassifies task lists on rescan instead of accumulating stale ids', async () => {
useCalendarStore.setState({ taskOnlyCalendarIds: ['cal-old'] }); useCalendarStore.setState({
mockQueryEvents.mockResolvedValue(['e1']); calendars: [{ id: 'cal-old' }] as any,
mockGetEvents.mockResolvedValue([ taskOnlyCalendarIds: ['cal-old'],
{ id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-old': true } }, });
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().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 () => { 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(); 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({ useCalendarStore.setState({
calendars: [{ id: 'cal-1' } as any], calendars: [{ id: 'cal-1' } as any],
loadedRange: { after: '2026-03-01', before: '2026-03-31' }, 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'); 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 () => { it('should fetch calendars first when none are loaded', async () => {
@@ -351,6 +420,36 @@ describe('calendar-store', () => {
expect(result).toEqual(created); expect(result).toEqual(created);
expect(useCalendarStore.getState().events).toHaveLength(2); 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', () => { describe('updateEvent', () => {
+135 -52
View File
@@ -6,6 +6,10 @@ import {
getCalendars as fetchCalendars, getCalendars as fetchCalendars,
queryEvents, queryEvents,
getEvents as fetchEvents, getEvents as fetchEvents,
scanCalendarObjects,
isCalendarAccessDenied,
noteCalendarAccessError,
resetCalendarAccessDenied,
createEvent as apiCreateEvent, createEvent as apiCreateEvent,
updateEvent as apiUpdateEvent, updateEvent as apiUpdateEvent,
deleteEvents as apiDeleteEvents, deleteEvents as apiDeleteEvents,
@@ -17,6 +21,7 @@ import {
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { expandRecurringEvents } from '../lib/recurrence-expansion'; import { expandRecurringEvents } from '../lib/recurrence-expansion';
import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; 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 // Does the event carry attendees the server should notify over iMIP? Used to
// decide whether to set sendSchedulingMessages on create/update/delete. // decide whether to set sendSchedulingMessages on create/update/delete.
@@ -81,6 +86,9 @@ export interface CalendarState {
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
fetchCalendars: () => Promise<void>; fetchCalendars: () => Promise<void>;
fetchEvents: (calendarIds: string[], after: string, before: string) => 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>; ensureRange: (after: string, before: string) => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>; handleStateChange: (change: StateChange) => Promise<void>;
@@ -112,6 +120,10 @@ export interface CalendarState {
reset: () => void; 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 { function persistHidden(ids: string[]): void {
void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch(
(err) => console.warn('[calendar-store] persist hidden failed', err), (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 // a "Not authenticated" error - the refetch driven by the auth-store
// will run this again once the session is live. // will run this again once the session is live.
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
try { // First-touch gate (#907): the screen mount, ensureRange and the
const calendars = (await fetchCalendars()) ?? []; // auth-store all kick this off at once, and on a clustered Stalwart
set({ calendars }); // concurrent first Calendar/* requests can each lazily create a default
} catch (err) { // calendar. Share one in-flight request instead.
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); 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) => { fetchEvents: async (calendarIds, after, before) => {
@@ -198,62 +220,99 @@ export const useCalendarStore = create<CalendarState>()(
const raw: CalendarEvent[] = []; const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) { for (const [accountId, ids] of groups) {
if (accountId && isCalendarAccessDenied(accountId)) continue;
try { 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)) ?? []; const eventIds = (await queryEvents(ids, after, before, accountId)) ?? [];
if (eventIds.length === 0) continue; if (eventIds.length === 0) continue;
const fetched = (await fetchEvents(eventIds, accountId)) ?? []; const fetched = (await fetchEvents(eventIds, accountId)) ?? [];
raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId))); raw.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) { } 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; if (!accountId) throw err;
noteCalendarAccessError(accountId, err);
} }
} }
// Stalwart returns both Events and Tasks from CalendarEvent/query. Events // Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks
// have a `start`; tasks are surfaced separately so they don't pollute the // (also CalDAV ones without an `@type`) are surfaced by fetchTasks so
// grid (they're shown in the task list with their due date instead). // they don't pollute the grid.
const onlyEvents = raw.filter((e) => { const onlyEvents = raw.filter((e) => !isTaskLikeObject(e) && !!e.start);
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),
);
const events = expandRecurringEvents(onlyEvents, after, before); const events = expandRecurringEvents(onlyEvents, after, before);
set({ events, tasks, taskOnlyCalendarIds, loadedRange: { after, before }, loading: false }); set({ events, loadedRange: { after, before }, loading: false });
} catch (err) { } catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); 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) => { ensureRange: async (after, before) => {
const { loadedRange, calendars } = get(); const { loadedRange } = get();
if (loadedRange && rangeCovers(loadedRange, after, before)) return; if (loadedRange && rangeCovers(loadedRange, after, before)) return;
const target = loadedRange ? unionRange(loadedRange, after, before) : { after, before }; // Queries are windowed now, so load exactly the requested range instead
const ids = calendars.map((c) => c.id); // of an ever-growing union (which re-downloaded everything each time).
if (ids.length === 0) { if (get().calendars.length === 0) {
// Calendars haven't loaded yet - fetch them, then events. // Calendars haven't loaded yet - fetch them (deduped), then events.
await get().fetchCalendars(); await get().fetchCalendars();
} }
const calendarIdsAfter = get().calendars.map((c) => c.id); const calendarIds = get().calendars.map((c) => c.id);
if (calendarIdsAfter.length === 0) { if (calendarIds.length === 0) {
set({ loadedRange: target }); set({ loadedRange: { after, before } });
return; 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 () => { refresh: async () => {
@@ -261,7 +320,10 @@ export const useCalendarStore = create<CalendarState>()(
if (!loadedRange) return; if (!loadedRange) return;
const ids = calendars.map((c) => c.id); const ids = calendars.map((c) => c.id);
if (ids.length === 0) return; 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) => { handleStateChange: async (change) => {
@@ -302,11 +364,28 @@ export const useCalendarStore = create<CalendarState>()(
hasSchedulingParticipants(event) ? true : undefined, hasSchedulingParticipants(event) ? true : undefined,
accountId, 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 // 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 // collide with the user's own events and stays visible under the right
// calendar filter. // calendar filter.
set({ events: [...get().events, mapServerEventToStoreEvent(created, calendars, accountId)] }); const mapped = mapServerEventToStoreEvent(full, calendars, accountId);
return created; 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) => { updateEvent: async (id, changes) => {
@@ -506,15 +585,19 @@ export const useCalendarStore = create<CalendarState>()(
persistHidden(next); persistHidden(next);
}, },
reset: () => set({ reset: () => {
calendars: [], // A new session may grant access the old one lacked.
events: [], resetCalendarAccessDenied();
tasks: [], set({
taskOnlyCalendarIds: [], calendars: [],
loadedRange: null, events: [],
loading: false, tasks: [],
error: null, taskOnlyCalendarIds: [],
}), loadedRange: null,
loading: false,
error: null,
});
},
}), }),
{ {
// Persist calendars + expanded events + the range they cover so the // Persist calendars + expanded events + the range they cover so the