mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: query events by date window, scan tasks separately and stop re-probing denied shared accounts (#907)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user