@open-webapp/calendar 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ # @open-webapp/calendar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Multi-account Google Calendar read-only sync (controller) plus React UI (`CalendarPane`, `CalendarSelector`). Storage and encryption are host-supplied adapters.
4
+
5
+ > **0.2.0 is BREAKING.** See [Breaking changes in 0.2.0](#breaking-changes-in-020).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @open-webapp/calendar
11
+ ```
12
+
13
+ Peer: `react ^19`. `vitest` is an optional peer, needed only for `@open-webapp/calendar/testing`'s `runStorageAdapterContract`.
14
+
15
+ ## Entry points
16
+
17
+ | Import | Contents |
18
+ | --- | --- |
19
+ | `@open-webapp/calendar` | core types, event helpers, fetch/sync/reconcile helpers, `createCalendarController`, `CalendarPane`, `CalendarSelector` |
20
+ | `@open-webapp/calendar/testing` | `runStorageAdapterContract`, `LEGACY_EVENT`, `StorageContractHarness`, `createMemoryStorage`, `identityCrypto`, `installFakeFetch` |
21
+ | `@open-webapp/calendar/styles.css` | stylesheet for the React components (import once) |
22
+
23
+ ## Usage
24
+
25
+ ```tsx
26
+ import { createCalendarController, CalendarPane } from '@open-webapp/calendar';
27
+ import '@open-webapp/calendar/styles.css';
28
+
29
+ const controller = createCalendarController({
30
+ sources: [{ id: 'a', email: 'me@example.com' }],
31
+ getToken: async (sourceId) => accessTokenFor(sourceId),
32
+ storage, // StorageAdapter
33
+ crypto, // CryptoAdapter
34
+ });
35
+ await controller.hydrate(); // publishes cached events immediately (no network)
36
+ void controller.refresh(); // then syncs from Google
37
+
38
+ <CalendarPane
39
+ sources={sources}
40
+ controller={controller}
41
+ selectedRsvpStatuses={statuses} // Set<RsvpStatus>
42
+ onRsvpStatusesChange={setStatuses}
43
+ connected={connected} // optional, see CalendarPane props
44
+ needsReauth={needsReauth}
45
+ onConnect={connect}
46
+ onReconnect={reconnect}
47
+ />
48
+ ```
49
+
50
+ ## Controller
51
+
52
+ `createCalendarController({ sources, getToken, storage, crypto, now? })`
53
+
54
+ - `hydrate(): Promise<void>` loads the stored rows and publishes cached events before any network call. Never throws. Offline, the cache stays visible.
55
+ - `refresh(): Promise<void>` syncs every source in parallel; never throws.
56
+ - `retryDeferred(): Promise<void>` re-runs a refresh after a deferred pass (see [Deferred refresh](#deferred-refresh)).
57
+ - `getEvents(): CalendarEvent[]` stable snapshot (updated on `hydrate`/`refresh`/`setSelectedIds`/`clearSelectedIds`), sources concatenated in `sources` order. Only events that pass the read-time filter are returned (see [Window](#window)).
58
+ - `getState(): { loading, error, deferred, lastSyncAt }`:
59
+ - `loading`: a refresh pass is in flight.
60
+ - `error`: true only when every selected calendar (across sources) errored and nothing is cached.
61
+ - `deferred`: the last pass was deferred.
62
+ - `lastSyncAt`: ms epoch of the most recent refresh pass across sources, or `null`.
63
+ - `getSourceStatus()` / `sourceStatus`: `Record<sourceId, 'ok' | 'needs-scope' | 'error'>`.
64
+ - `getCalendars(sourceId)`, `getSelectedIds(sourceId)`.
65
+ - `setSelectedIds(sourceId, ids)`: see [Selection](#selection). Fetches calendars newly added to the selection; drops nothing it has cached for deselected calendars.
66
+ - `clearSelectedIds(sourceId)`: resets the selection to the primary calendar.
67
+ - `subscribe(listener): () => void`.
68
+
69
+ ### Selection
70
+
71
+ - Default selection is the primary calendar.
72
+ - On refresh, the stored selection is filtered to calendars that still exist. If none remain, the selection falls back to the primary calendar (else the first calendar).
73
+ - Primary is not forced: a stored selection that omits primary is kept.
74
+ - `setSelectedIds(id, [])` is ignored; at least one calendar always remains selected. Use `clearSelectedIds` to reset to primary.
75
+ - `setSelectedIds` persists the selection and fetches any calendars newly added to it.
76
+ - Deselecting a calendar keeps its cached events and sync tokens (retained, hidden by the read-time filter). Re-selecting it shows the cache immediately and syncs it.
77
+
78
+ ### sourceStatus
79
+
80
+ - `ok`: last refresh succeeded, or only some calendars failed (also the initial value).
81
+ - `needs-scope`: Google returned 403 `insufficientPermissions`; the host should prompt re-consent.
82
+ - `error`: any other failure (401, 5xx, non-403 HTTP, unexpected exception).
83
+ - Network failures leave the previous status unchanged and keep cached events.
84
+
85
+ ### Deferred refresh
86
+
87
+ - A host `getToken` (or fetch path) may throw `RefreshDeferredError`, or any error with `deferred: true`. `isRefreshDeferredError(e)` recognises both.
88
+ - A deferred pass is quiet: it does not change `sourceStatus`, does not set `getState().error`, and is not retried immediately. `getState().deferred` becomes `true`; the host calls `retryDeferred()` when it is ready.
89
+
90
+ ## Sync behavior
91
+
92
+ Per calendar:
93
+
94
+ - Full (windowed) sync when there is no sync token, no `lastFullSyncAt` for that calendar, or the last full sync is more than 24h old. Full sync uses `singleEvents=true`, window now -30d .. now +30d (RFC3339, no milliseconds), all pages (`maxResults=250`), and stores `nextSyncToken` and `lastFullSyncAt`.
95
+ - Otherwise incremental with `syncToken` (no time bounds); cancelled items remove events.
96
+ - HTTP 410 (`gone`): token dropped, full windowed resync.
97
+ - A failing calendar is retried once immediately (deferred errors are not retried).
98
+ - Calendars are isolated: one failing calendar keeps its cached events and token; other calendars and sources are unaffected.
99
+ - Per-source status: if some targeted calendars succeed and none reports a scope error, the source is `ok` (failed calendars keep their cached events). A scope error gives `needs-scope`. If every targeted calendar fails, the status follows the `sourceStatus` rules above (`error` for non-network failures, unchanged on network-only failures).
100
+
101
+ ### Window
102
+
103
+ - Stored events whose end is strictly before now -30d are pruned on refresh.
104
+ - Read-time filter (`getEvents()`): events on deselected calendars are hidden; events are kept when they end after now -30d and start before now +30d (edges excluded, overlaps kept).
105
+
106
+ ## Adapters
107
+
108
+ ```ts
109
+ interface StorageAdapter {
110
+ get(sourceId): Promise<StorageRow | null>;
111
+ put(sourceId, row: StorageRow): Promise<void>;
112
+ delete(sourceId): Promise<void>;
113
+ clear(sourceId): Promise<void>;
114
+ }
115
+ interface StorageRow { events: CalendarEvent[] | string | unknown[]; syncState: SyncState | string | null | unknown; selectedIds: string[] }
116
+ interface SyncState {
117
+ tokens: Record<calendarId, string>;
118
+ lastFullSyncAt?: Record<calendarId, number>; // ms epoch; absent on 0.1.0 rows
119
+ lastSyncAt?: number; // ms epoch; absent on 0.1.0 rows
120
+ }
121
+ interface CryptoAdapter { encrypt(s: string): Promise<string>; decrypt(s: string): Promise<string> }
122
+ ```
123
+
124
+ The controller writes `events` and `syncState` as `crypto.encrypt(JSON.stringify(...))` strings and `selectedIds` as a plain array. On read, string values are decrypted and parsed; non-string (legacy plain) values are used as-is. Corrupt rows or decrypt failures read back as empty.
125
+
126
+ Rows written by 0.1.0 (no `lastFullSyncAt`/`lastSyncAt`, no retained-calendar data) still read. A missing `lastFullSyncAt` triggers one full windowed sync on the next refresh.
127
+
128
+ ### Contract harness
129
+
130
+ ```ts
131
+ import { runStorageAdapterContract } from '@open-webapp/calendar/testing';
132
+ runStorageAdapterContract(() => ({ storage, crypto, seedRaw?, wrongKeyCrypto?, encryptedFixture?, corruptRow? }));
133
+ ```
134
+
135
+ Call at top level of a vitest file; it registers a `describe('StorageAdapter contract')` suite (round trip, source isolation, delete/clear, missing source, legacy plain row, legacy encrypted fixture, corrupt row, wrong key).
136
+
137
+ ### Event key
138
+
139
+ `getCalendarEventKey(event)` returns `${sourceId}:${calendarId}:${eventId}` (e.g. `src1:calA:ev1`). This multi-source form is the one intentional divergence from notesdiary (`calA:ev1`), kept by decision for multi-source.
140
+
141
+ ## UI
142
+
143
+ ### CalendarPane props
144
+
145
+ | Prop | Type | Notes |
146
+ | --- | --- | --- |
147
+ | `sources` | `Source[]` | shown accounts; 2+ enables multi-source UI (per-source color and email) |
148
+ | `controller` | `CalendarController` | |
149
+ | `selectedRsvpStatuses` | `Set<RsvpStatus>` | filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction |
150
+ | `onRsvpStatusesChange` | `(next) => void` | |
151
+ | `renderEventAction?` | `(event) => ReactNode` | per-event slot |
152
+ | `connected?` | `boolean` | defaults to `sources.length > 0` (back-compat). `false` shows the not-connected block |
153
+ | `needsReauth?` | `boolean` | defaults to every source being `needs-scope`. Shows the reauth block |
154
+ | `onConnect?` | `() => void` | wired to the built-in "Connect Google Calendar" button |
155
+ | `onReconnect?` | `() => void` | wired to the built-in "Reconnect to restore sync" button |
156
+ | `emptyState?` | `ReactNode` | replaces the built-in not-connected block |
157
+ | `sourceNotice?` | `(source) => ReactNode` | per-source slot, rendered for `needs-scope` sources (e.g. re-consent button); without it the built-in reauth block is shown |
158
+ | `onClose?` | `() => void` | |
159
+ | `popupName?` | `string` | `window.open` name for the event popup (default `notesdiary-cal-event`) |
160
+
161
+ Built-in copy (overridable only through the slots above):
162
+
163
+ - Not connected: "Not connected to Google Calendar." with "Connect Google Calendar".
164
+ - Reauth: "Calendar connection needs reauthorization." with "Reconnect to restore sync".
165
+ - Error with nothing cached: "Couldn't load calendar events. Try again shortly."
166
+ - Loading: "Loading calendar…".
167
+ - Empty: "No matching events in the next 30 days".
168
+
169
+ ### CalendarSelector props
170
+
171
+ `calendars: CalendarInfo[]`, `selectedIds: string[]`, `onChange(ids)`, `heading?: string` (account heading in multi-source use). It has no slot props or `popupName`. At least one calendar stays selected (deselecting the last one is ignored). "Clear" resets to the primary calendar. Built-in copy: "Search calendars", "Select all", "Clear", "Done", "No calendars found", "No calendars available".
172
+
173
+ ### Host CSS
174
+
175
+ `styles.css` is self-contained (plain colors, dark mode via `prefers-color-scheme`). It requires no host CSS variables. Variables it reads, all with defaults:
176
+
177
+ - `--calendar-footer-height` (default `64px`, set on `.calendar-pane`; override to match host footer)
178
+ - `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
179
+ - `--rsvp-bar` (set internally per RSVP status; do not override)
180
+
181
+ ## Breaking changes in 0.2.0
182
+
183
+ BREAKING (0.1.0 to 0.2.0):
184
+
185
+ - **Selection semantics.** Primary is no longer forced into the selection. Stored selections are honored (filtered to existing calendars). `setSelectedIds` no longer always adds primary, and an empty list is ignored. `clearSelectedIds` resets to primary.
186
+ - **Cache and storage rows.** `setSelectedIds` no longer drops events and sync tokens of deselected calendars; they are retained. `syncState` gains optional `lastFullSyncAt` and `lastSyncAt`. Window pruning and the read-time window filter are new. 0.1.0 rows still read (missing fields take their defaults).
187
+ - **Pane behavior.** `CalendarPane` gains `connected`, `needsReauth`, `onConnect`, `onReconnect`. Omitting `connected` keeps the old behavior (`sources.length > 0`). Deferred refreshes are quiet. `getState()` and `hydrate()` are new; hosts that want cache-first display should call `hydrate()` before `refresh()`.
188
+ - Unchanged: event key form (`src1:calA:ev1`), `sourceStatus` values, `StorageAdapter`/`CryptoAdapter` signatures.
@@ -0,0 +1,33 @@
1
+ import type { CryptoAdapter, StorageAdapter } from './storage.js';
2
+ import type { CalendarEvent, CalendarInfo, Source } from './types.js';
3
+ export type SourceStatus = 'ok' | 'needs-scope' | 'error';
4
+ export interface CalendarControllerOptions {
5
+ sources: Source[];
6
+ getToken: (sourceId: string) => Promise<string>;
7
+ storage: StorageAdapter;
8
+ crypto: CryptoAdapter;
9
+ now?: () => Date;
10
+ }
11
+ export interface CalendarControllerState {
12
+ loading: boolean;
13
+ /** True only when every selected calendar failed and nothing is cached. */
14
+ error: boolean;
15
+ deferred: boolean;
16
+ lastSyncAt: number | null;
17
+ }
18
+ export interface CalendarController {
19
+ hydrate(): Promise<void>;
20
+ refresh(): Promise<void>;
21
+ /** Re-runs a refresh after a deferred pass. */
22
+ retryDeferred(): Promise<void>;
23
+ getState(): CalendarControllerState;
24
+ getEvents(): CalendarEvent[];
25
+ getSourceStatus(): Record<string, SourceStatus>;
26
+ readonly sourceStatus: Record<string, SourceStatus>;
27
+ getCalendars(sourceId: string): CalendarInfo[];
28
+ getSelectedIds(sourceId: string): string[];
29
+ setSelectedIds(sourceId: string, ids: string[]): Promise<void>;
30
+ clearSelectedIds(sourceId: string): Promise<void>;
31
+ subscribe(listener: () => void): () => void;
32
+ }
33
+ export declare function createCalendarController(opts: CalendarControllerOptions): CalendarController;
@@ -0,0 +1,251 @@
1
+ import { CalendarFetchError, fetchCalendarList, isRefreshDeferredError } from './fetch.js';
2
+ import { reconcileSelection } from './reconcile.js';
3
+ import { syncCalendar } from './sync.js';
4
+ export function createCalendarController(opts) {
5
+ const { sources, getToken, storage, crypto } = opts;
6
+ const now = opts.now ?? (() => new Date());
7
+ const state = new Map();
8
+ const listeners = new Set();
9
+ let snapshot = [];
10
+ let loadingCount = 0;
11
+ let deferred = false;
12
+ let statusSnapshot = {};
13
+ for (const s of sources) {
14
+ state.set(s.id, {
15
+ loaded: false,
16
+ events: [],
17
+ syncState: { tokens: {} },
18
+ selectedIds: ['primary'],
19
+ calendars: [],
20
+ status: 'ok',
21
+ errored: new Set(),
22
+ gen: 0,
23
+ });
24
+ }
25
+ function publish() {
26
+ snapshot = sources.flatMap((s) => {
27
+ const st = state.get(s.id);
28
+ const ws = now().getTime() - 30 * 86400000;
29
+ const we = now().getTime() + 30 * 86400000;
30
+ const ms = (v, d) => {
31
+ const t = v ? new Date(v).getTime() : NaN;
32
+ return Number.isNaN(t) ? d : t;
33
+ };
34
+ // read-time window: exact edges excluded, overlaps kept (notesdiary loadCached)
35
+ return st.events.filter((e) => (!e.calendarId || st.selectedIds.includes(e.calendarId)) &&
36
+ ms(e.end.dateTime ?? e.end.date, Infinity) > ws &&
37
+ ms(e.start.dateTime ?? e.start.date, -Infinity) < we);
38
+ });
39
+ statusSnapshot = Object.fromEntries(sources.map((s) => [s.id, state.get(s.id).status]));
40
+ listeners.forEach((l) => l());
41
+ }
42
+ async function decode(v, fallback) {
43
+ if (typeof v === 'string') {
44
+ try {
45
+ return JSON.parse(await crypto.decrypt(v));
46
+ }
47
+ catch {
48
+ return fallback;
49
+ }
50
+ }
51
+ return (v ?? fallback);
52
+ }
53
+ async function load(sourceId) {
54
+ const st = state.get(sourceId);
55
+ if (st.loaded)
56
+ return;
57
+ st.loaded = true;
58
+ try {
59
+ const row = await storage.get(sourceId);
60
+ if (!row)
61
+ return;
62
+ st.events = await decode(row.events, []);
63
+ const ss = await decode(row.syncState, null);
64
+ st.syncState = ss && ss.tokens ? ss : { tokens: {} };
65
+ if (Array.isArray(row.selectedIds))
66
+ st.selectedIds = row.selectedIds;
67
+ }
68
+ catch {
69
+ /* unreadable cache: start empty */
70
+ }
71
+ }
72
+ async function persist(sourceId) {
73
+ const st = state.get(sourceId);
74
+ try {
75
+ await storage.put(sourceId, {
76
+ events: await crypto.encrypt(JSON.stringify(st.events)),
77
+ syncState: await crypto.encrypt(JSON.stringify(st.syncState)),
78
+ selectedIds: st.selectedIds,
79
+ });
80
+ }
81
+ catch {
82
+ /* persistence is best-effort */
83
+ }
84
+ }
85
+ async function syncWithRetry(run) {
86
+ try {
87
+ return await run();
88
+ }
89
+ catch (e) {
90
+ if (isRefreshDeferredError(e))
91
+ throw e;
92
+ return run();
93
+ }
94
+ }
95
+ /** Returns true when the pass was deferred. `only` limits syncing to those calendars. */
96
+ async function refreshSource(sourceId, only) {
97
+ const st = state.get(sourceId);
98
+ await load(sourceId);
99
+ const gen = st.gen;
100
+ let wasDeferred = false;
101
+ try {
102
+ const token = await getToken(sourceId);
103
+ const calendars = await fetchCalendarList(token);
104
+ st.calendars = calendars;
105
+ const selected = reconcileSelection(st.selectedIds, calendars);
106
+ const targets = only ? selected.filter((c) => only.includes(c)) : selected;
107
+ const fresh = [];
108
+ const synced = [];
109
+ const tokens = { ...st.syncState.tokens };
110
+ const lastFull = { ...st.syncState.lastFullSyncAt };
111
+ const failures = [];
112
+ for (const calendarId of targets) {
113
+ try {
114
+ const res = await syncWithRetry(() => syncCalendar({
115
+ token,
116
+ sourceId,
117
+ calendarId,
118
+ existing: st.events.filter((e) => e.calendarId === calendarId),
119
+ syncToken: st.syncState.tokens[calendarId],
120
+ lastFullSyncAt: st.syncState.lastFullSyncAt?.[calendarId],
121
+ now: now(),
122
+ }));
123
+ fresh.push(...res.events);
124
+ synced.push(calendarId);
125
+ st.errored.delete(calendarId);
126
+ if (res.syncToken)
127
+ tokens[calendarId] = res.syncToken;
128
+ else
129
+ delete tokens[calendarId];
130
+ if (res.full)
131
+ lastFull[calendarId] = now().getTime();
132
+ }
133
+ catch (e) {
134
+ if (isRefreshDeferredError(e))
135
+ throw e;
136
+ failures.push(e);
137
+ st.errored.add(calendarId);
138
+ }
139
+ }
140
+ const winStart = new Date(now().getTime() - 30 * 86400000).getTime();
141
+ const endMs = (e) => {
142
+ const v = e.end.dateTime ?? e.end.date;
143
+ const t = v ? new Date(v).getTime() : NaN;
144
+ return Number.isNaN(t) ? Infinity : t;
145
+ };
146
+ st.events = [...st.events.filter((e) => !(e.calendarId && synced.includes(e.calendarId))), ...fresh].filter((e) => endMs(e) >= winStart);
147
+ st.syncState = { ...st.syncState, tokens, lastFullSyncAt: lastFull };
148
+ if (st.gen === gen)
149
+ st.selectedIds = selected;
150
+ if (failures.length === 0)
151
+ st.status = 'ok';
152
+ else if (failures.some((e) => e instanceof CalendarFetchError && e.kind === 'scope'))
153
+ st.status = 'needs-scope';
154
+ else if (failures.length < targets.length)
155
+ st.status = 'ok';
156
+ else if (failures.some((e) => !(e instanceof CalendarFetchError) || e.kind !== 'network'))
157
+ st.status = 'error';
158
+ }
159
+ catch (e) {
160
+ if (st.selectedIds.length === 0)
161
+ st.selectedIds = ['primary'];
162
+ if (isRefreshDeferredError(e)) {
163
+ wasDeferred = true;
164
+ }
165
+ else if (e instanceof CalendarFetchError) {
166
+ if (e.kind === 'scope')
167
+ st.status = 'needs-scope';
168
+ else if (e.kind !== 'network')
169
+ st.status = 'error';
170
+ }
171
+ else {
172
+ st.status = 'error';
173
+ }
174
+ }
175
+ st.syncState = { ...st.syncState, lastSyncAt: now().getTime() };
176
+ await persist(sourceId);
177
+ return wasDeferred;
178
+ }
179
+ async function runPass(only) {
180
+ loadingCount++;
181
+ try {
182
+ const ids = sources.map((s) => s.id).filter((id) => !only || only[id]);
183
+ const results = await Promise.all(ids.map((id) => refreshSource(id, only?.[id])));
184
+ deferred = results.some(Boolean);
185
+ }
186
+ catch {
187
+ /* never throws */
188
+ }
189
+ finally {
190
+ loadingCount--;
191
+ }
192
+ publish();
193
+ }
194
+ async function setSelected(sourceId, ids) {
195
+ const st = state.get(sourceId);
196
+ if (!st)
197
+ return;
198
+ await load(sourceId);
199
+ const next = [...new Set(ids)];
200
+ if (next.length === 0)
201
+ return;
202
+ const added = next.filter((id) => !st.selectedIds.includes(id));
203
+ st.selectedIds = next;
204
+ st.gen++;
205
+ await persist(sourceId);
206
+ publish();
207
+ if (added.length > 0)
208
+ await runPass({ [sourceId]: added });
209
+ }
210
+ const controller = {
211
+ async hydrate() {
212
+ try {
213
+ await Promise.all(sources.map((s) => load(s.id)));
214
+ }
215
+ catch {
216
+ /* never throws */
217
+ }
218
+ publish();
219
+ },
220
+ refresh: () => runPass(),
221
+ retryDeferred: () => runPass(),
222
+ getState() {
223
+ const sel = sources.flatMap((s) => {
224
+ const st = state.get(s.id);
225
+ return st.selectedIds.map((c) => st.errored.has(c));
226
+ });
227
+ const lasts = sources.map((s) => state.get(s.id).syncState.lastSyncAt).filter((v) => typeof v === 'number');
228
+ return {
229
+ loading: loadingCount > 0,
230
+ error: sel.length > 0 && sel.every(Boolean) && snapshot.length === 0,
231
+ deferred,
232
+ lastSyncAt: lasts.length ? Math.max(...lasts) : null,
233
+ };
234
+ },
235
+ getEvents: () => snapshot,
236
+ getSourceStatus: () => statusSnapshot,
237
+ get sourceStatus() {
238
+ return statusSnapshot;
239
+ },
240
+ getCalendars: (id) => state.get(id)?.calendars ?? [],
241
+ getSelectedIds: (id) => state.get(id)?.selectedIds ?? [],
242
+ setSelectedIds: (id, ids) => setSelected(id, ids),
243
+ clearSelectedIds: (id) => setSelected(id, [state.get(id)?.calendars.find((c) => c.primary)?.id ?? 'primary']),
244
+ subscribe(l) {
245
+ listeners.add(l);
246
+ return () => listeners.delete(l);
247
+ },
248
+ };
249
+ statusSnapshot = Object.fromEntries(sources.map((s) => [s.id, 'ok']));
250
+ return controller;
251
+ }
@@ -0,0 +1,91 @@
1
+ import type { CalendarEvent, CalendarInfo, ParseContext, RsvpStatus } from './types.js';
2
+ export declare const DEFAULT_RSVP_STATUSES: RsvpStatus[];
3
+ /** Keeps stored calendar selections that still exist, defaulting to the primary calendar. */
4
+ export declare function reconcileSelectedCalendarIds(storedIds: string[] | null, calendars: CalendarInfo[]): string[];
5
+ /** Filters calendars by a case-insensitive substring of their display name. */
6
+ export declare function filterCalendarsByName(calendars: CalendarInfo[], query: string): CalendarInfo[];
7
+ /** Event IDs are unique only within their calendar and source. */
8
+ export declare function getCalendarEventKey(event: CalendarEvent): string;
9
+ /** Produces the compact calendar-selection label shown in the filter control. */
10
+ export declare function summarizeCalendarSelection(selectedIds: string[], calendars: CalendarInfo[]): string;
11
+ /** True iff the event is an all-day event. Passthrough of the package-computed flag. */
12
+ export declare function isAllDay(event: CalendarEvent): boolean;
13
+ /**
14
+ * Rolling fetch window: now-30d to now+30d, as full RFC3339 timestamps.
15
+ * Google Calendar's `timeMin`/`timeMax` reject bare `YYYY-MM-DD` dates.
16
+ */
17
+ export declare function getRollingWindow(now: Date): {
18
+ start: string;
19
+ end: string;
20
+ };
21
+ /**
22
+ * Returns events whose `selfResponseStatus` is a member of `selected` —
23
+ * declined is a normal, independently-selectable status now, not hard-excluded.
24
+ */
25
+ export declare function filterByRsvp(events: CalendarEvent[], selected: ReadonlySet<RsvpStatus>): CalendarEvent[];
26
+ /** displayName -> email -> "You" (only when organizer.self is true and both above are absent). */
27
+ export declare function getOrganizerLabel(event: CalendarEvent): string;
28
+ /**
29
+ * True iff `event` is a timed (non-all-day) event currently in progress at `now`:
30
+ * both `start.dateTime` and `end.dateTime` exist and parse to valid instants,
31
+ * start <= now < end.
32
+ */
33
+ export declare function isEventCurrent(event: CalendarEvent, now: Date): boolean;
34
+ /**
35
+ * Finds the soonest strictly-future timed event: `!isAllDay(event)`,
36
+ * `start.dateTime` exists and parses valid, and `startMs > now.getTime()`.
37
+ * Returns the smallest `startMs`; ties keep first-encountered array order.
38
+ * Returns `null` if none qualify.
39
+ */
40
+ export declare function findNextUpcomingEvent(events: CalendarEvent[], now: Date): CalendarEvent | null;
41
+ export interface DayGroup {
42
+ dateKey: string;
43
+ events: CalendarEvent[];
44
+ }
45
+ /**
46
+ * Group by local calendar date, sorted ascending by day. Within a day,
47
+ * all-day events come first (in list order), then timed events sorted by
48
+ * start time ascending.
49
+ *
50
+ * When `todayIso` is given and has no events, an empty group for it is
51
+ * still included (so a "Today" header can always be rendered / scrolled
52
+ * to), inserted in correct sorted position.
53
+ */
54
+ export declare function groupByDay(events: CalendarEvent[], todayIso?: string): DayGroup[];
55
+ /**
56
+ * "Today · Sept 26" / "Tomorrow · Sept 27" / else "Sept 28"-style.
57
+ * Includes year only when the event's year differs from today's year.
58
+ */
59
+ export declare function formatDayHeader(dateKey: string, today: string): string;
60
+ interface RawEvent {
61
+ id?: unknown;
62
+ status?: string;
63
+ summary?: string;
64
+ start?: CalendarEvent['start'];
65
+ end?: CalendarEvent['end'];
66
+ attendees?: {
67
+ self?: boolean;
68
+ responseStatus?: string;
69
+ }[];
70
+ organizer?: {
71
+ displayName?: string;
72
+ email?: string;
73
+ self?: boolean;
74
+ };
75
+ htmlLink?: string;
76
+ hangoutLink?: string;
77
+ conferenceData?: {
78
+ entryPoints?: {
79
+ entryPointType?: string;
80
+ uri?: string;
81
+ }[];
82
+ };
83
+ location?: string;
84
+ description?: string;
85
+ }
86
+ export declare function deriveSelfResponseStatus(raw: RawEvent): RsvpStatus;
87
+ export declare function extractJoinUrl(raw: RawEvent): string | null;
88
+ /** Parses a raw Google Calendar event. Returns null for cancelled/malformed input; never throws. */
89
+ export declare function parseEvent(raw: RawEvent, ctx: ParseContext): CalendarEvent | null;
90
+ export declare function parseEvents(items: RawEvent[], ctx: ParseContext): CalendarEvent[];
91
+ export {};