@open-webapp/calendar 0.1.0 → 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
@@ -2,6 +2,8 @@
2
2
 
3
3
  Multi-account Google Calendar read-only sync (controller) plus React UI (`CalendarPane`, `CalendarSelector`). Storage and encryption are host-supplied adapters.
4
4
 
5
+ > **0.2.0 is BREAKING.** See [Breaking changes in 0.2.0](#breaking-changes-in-020).
6
+
5
7
  ## Install
6
8
 
7
9
  ```bash
@@ -30,13 +32,18 @@ const controller = createCalendarController({
30
32
  storage, // StorageAdapter
31
33
  crypto, // CryptoAdapter
32
34
  });
33
- await controller.refresh();
35
+ await controller.hydrate(); // publishes cached events immediately (no network)
36
+ void controller.refresh(); // then syncs from Google
34
37
 
35
38
  <CalendarPane
36
39
  sources={sources}
37
40
  controller={controller}
38
41
  selectedRsvpStatuses={statuses} // Set<RsvpStatus>
39
42
  onRsvpStatusesChange={setStatuses}
43
+ connected={connected} // optional, see CalendarPane props
44
+ needsReauth={needsReauth}
45
+ onConnect={connect}
46
+ onReconnect={reconnect}
40
47
  />
41
48
  ```
42
49
 
@@ -44,21 +51,58 @@ await controller.refresh();
44
51
 
45
52
  `createCalendarController({ sources, getToken, storage, crypto, now? })`
46
53
 
54
+ - `hydrate(): Promise<void>` loads the stored rows and publishes cached events before any network call. Never throws. Offline, the cache stays visible.
47
55
  - `refresh(): Promise<void>` syncs every source in parallel; never throws.
48
- - `getEvents(): CalendarEvent[]` stable snapshot (updated on `refresh`/`setSelectedIds`), sources concatenated in `sources` order.
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`.
49
63
  - `getSourceStatus()` / `sourceStatus`: `Record<sourceId, 'ok' | 'needs-scope' | 'error'>`.
50
64
  - `getCalendars(sourceId)`, `getSelectedIds(sourceId)`.
51
- - `setSelectedIds(sourceId, ids)`: primary calendar is always kept; drops events/tokens of deselected calendars, persists, notifies.
52
- - `clearSelectedIds(sourceId)`: same as `setSelectedIds(sourceId, [])` (leaves primary only).
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.
53
67
  - `subscribe(listener): () => void`.
54
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
+
55
78
  ### sourceStatus
56
79
 
57
- - `ok`: last refresh succeeded (also the initial value).
80
+ - `ok`: last refresh succeeded, or only some calendars failed (also the initial value).
58
81
  - `needs-scope`: Google returned 403 `insufficientPermissions`; the host should prompt re-consent.
59
82
  - `error`: any other failure (401, 5xx, non-403 HTTP, unexpected exception).
60
83
  - Network failures leave the previous status unchanged and keep cached events.
61
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
+
62
106
  ## Adapters
63
107
 
64
108
  ```ts
@@ -69,12 +113,18 @@ interface StorageAdapter {
69
113
  clear(sourceId): Promise<void>;
70
114
  }
71
115
  interface StorageRow { events: CalendarEvent[] | string | unknown[]; syncState: SyncState | string | null | unknown; selectedIds: string[] }
72
- interface SyncState { tokens: Record<calendarId, 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
+ }
73
121
  interface CryptoAdapter { encrypt(s: string): Promise<string>; decrypt(s: string): Promise<string> }
74
122
  ```
75
123
 
76
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.
77
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
+
78
128
  ### Contract harness
79
129
 
80
130
  ```ts
@@ -84,12 +134,9 @@ runStorageAdapterContract(() => ({ storage, crypto, seedRaw?, wrongKeyCrypto?, e
84
134
 
85
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).
86
136
 
87
- ## Sync behavior
137
+ ### Event key
88
138
 
89
- - First sync per calendar: full fetch, `singleEvents=true`, window now -30d .. now +30d (RFC3339, no milliseconds), all pages (`maxResults=250`); stores `nextSyncToken`.
90
- - Later syncs: incremental with `syncToken` (no time bounds); cancelled items remove events.
91
- - HTTP 410: token dropped, full windowed resync.
92
- - Several sources are fetched independently; one failing source does not affect others.
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.
93
140
 
94
141
  ## UI
95
142
 
@@ -102,14 +149,26 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
102
149
  | `selectedRsvpStatuses` | `Set<RsvpStatus>` | filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction |
103
150
  | `onRsvpStatusesChange` | `(next) => void` | |
104
151
  | `renderEventAction?` | `(event) => ReactNode` | per-event slot |
105
- | `emptyState?` | `ReactNode` | shown when no sources are connected |
106
- | `sourceNotice?` | `(source) => ReactNode` | per-source slot (e.g. re-consent button when `needs-scope`) |
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 |
107
158
  | `onClose?` | `() => void` | |
108
159
  | `popupName?` | `string` | `window.open` name for the event popup (default `notesdiary-cal-event`) |
109
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
+
110
169
  ### CalendarSelector props
111
170
 
112
- `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; "Clear" resets to the primary calendar.
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".
113
172
 
114
173
  ### Host CSS
115
174
 
@@ -118,3 +177,12 @@ Call at top level of a vitest file; it registers a `describe('StorageAdapter con
118
177
  - `--calendar-footer-height` (default `64px`, set on `.calendar-pane`; override to match host footer)
119
178
  - `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
120
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.
@@ -8,8 +8,19 @@ export interface CalendarControllerOptions {
8
8
  crypto: CryptoAdapter;
9
9
  now?: () => Date;
10
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
+ }
11
18
  export interface CalendarController {
19
+ hydrate(): Promise<void>;
12
20
  refresh(): Promise<void>;
21
+ /** Re-runs a refresh after a deferred pass. */
22
+ retryDeferred(): Promise<void>;
23
+ getState(): CalendarControllerState;
13
24
  getEvents(): CalendarEvent[];
14
25
  getSourceStatus(): Record<string, SourceStatus>;
15
26
  readonly sourceStatus: Record<string, SourceStatus>;
@@ -1,4 +1,4 @@
1
- import { CalendarFetchError, fetchCalendarList } from './fetch.js';
1
+ import { CalendarFetchError, fetchCalendarList, isRefreshDeferredError } from './fetch.js';
2
2
  import { reconcileSelection } from './reconcile.js';
3
3
  import { syncCalendar } from './sync.js';
4
4
  export function createCalendarController(opts) {
@@ -7,19 +7,35 @@ export function createCalendarController(opts) {
7
7
  const state = new Map();
8
8
  const listeners = new Set();
9
9
  let snapshot = [];
10
+ let loadingCount = 0;
11
+ let deferred = false;
10
12
  let statusSnapshot = {};
11
13
  for (const s of sources) {
12
14
  state.set(s.id, {
13
15
  loaded: false,
14
16
  events: [],
15
17
  syncState: { tokens: {} },
16
- selectedIds: [],
18
+ selectedIds: ['primary'],
17
19
  calendars: [],
18
20
  status: 'ok',
21
+ errored: new Set(),
22
+ gen: 0,
19
23
  });
20
24
  }
21
25
  function publish() {
22
- snapshot = sources.flatMap((s) => state.get(s.id).events);
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
+ });
23
39
  statusSnapshot = Object.fromEntries(sources.map((s) => [s.id, state.get(s.id).status]));
24
40
  listeners.forEach((l) => l());
25
41
  }
@@ -46,7 +62,8 @@ export function createCalendarController(opts) {
46
62
  st.events = await decode(row.events, []);
47
63
  const ss = await decode(row.syncState, null);
48
64
  st.syncState = ss && ss.tokens ? ss : { tokens: {} };
49
- st.selectedIds = Array.isArray(row.selectedIds) ? row.selectedIds : [];
65
+ if (Array.isArray(row.selectedIds))
66
+ st.selectedIds = row.selectedIds;
50
67
  }
51
68
  catch {
52
69
  /* unreadable cache: start empty */
@@ -65,37 +82,87 @@ export function createCalendarController(opts) {
65
82
  /* persistence is best-effort */
66
83
  }
67
84
  }
68
- async function refreshSource(sourceId) {
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) {
69
97
  const st = state.get(sourceId);
70
98
  await load(sourceId);
99
+ const gen = st.gen;
100
+ let wasDeferred = false;
71
101
  try {
72
102
  const token = await getToken(sourceId);
73
103
  const calendars = await fetchCalendarList(token);
74
104
  st.calendars = calendars;
75
105
  const selected = reconcileSelection(st.selectedIds, calendars);
76
- const next = [];
77
- const tokens = {};
78
- for (const calendarId of selected) {
79
- const res = await syncCalendar({
80
- token,
81
- sourceId,
82
- calendarId,
83
- existing: st.events.filter((e) => e.calendarId === calendarId),
84
- syncToken: st.syncState.tokens[calendarId],
85
- now: now(),
86
- });
87
- next.push(...res.events);
88
- if (res.syncToken)
89
- tokens[calendarId] = res.syncToken;
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
+ }
90
139
  }
91
- st.events = next;
92
- st.syncState = { tokens };
93
- st.selectedIds = selected;
94
- st.status = 'ok';
95
- await persist(sourceId);
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';
96
158
  }
97
159
  catch (e) {
98
- if (e instanceof CalendarFetchError) {
160
+ if (st.selectedIds.length === 0)
161
+ st.selectedIds = ['primary'];
162
+ if (isRefreshDeferredError(e)) {
163
+ wasDeferred = true;
164
+ }
165
+ else if (e instanceof CalendarFetchError) {
99
166
  if (e.kind === 'scope')
100
167
  st.status = 'needs-scope';
101
168
  else if (e.kind !== 'network')
@@ -105,34 +172,66 @@ export function createCalendarController(opts) {
105
172
  st.status = 'error';
106
173
  }
107
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();
108
193
  }
109
194
  async function setSelected(sourceId, ids) {
110
195
  const st = state.get(sourceId);
111
196
  if (!st)
112
197
  return;
113
198
  await load(sourceId);
114
- const primary = st.calendars.find((c) => c.primary)?.id ?? (st.selectedIds.includes('primary') ? 'primary' : undefined);
115
- const next = [...new Set(primary && !ids.includes(primary) ? [primary, ...ids] : ids)];
199
+ const next = [...new Set(ids)];
200
+ if (next.length === 0)
201
+ return;
202
+ const added = next.filter((id) => !st.selectedIds.includes(id));
116
203
  st.selectedIds = next;
117
- st.events = st.events.filter((e) => e.calendarId && next.includes(e.calendarId));
118
- const tokens = {};
119
- for (const id of next)
120
- if (st.syncState.tokens[id])
121
- tokens[id] = st.syncState.tokens[id];
122
- st.syncState = { tokens };
204
+ st.gen++;
123
205
  await persist(sourceId);
124
206
  publish();
207
+ if (added.length > 0)
208
+ await runPass({ [sourceId]: added });
125
209
  }
126
210
  const controller = {
127
- async refresh() {
211
+ async hydrate() {
128
212
  try {
129
- await Promise.all(sources.map((s) => refreshSource(s.id)));
213
+ await Promise.all(sources.map((s) => load(s.id)));
130
214
  }
131
215
  catch {
132
216
  /* never throws */
133
217
  }
134
218
  publish();
135
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
+ },
136
235
  getEvents: () => snapshot,
137
236
  getSourceStatus: () => statusSnapshot,
138
237
  get sourceStatus() {
@@ -141,7 +240,7 @@ export function createCalendarController(opts) {
141
240
  getCalendars: (id) => state.get(id)?.calendars ?? [],
142
241
  getSelectedIds: (id) => state.get(id)?.selectedIds ?? [],
143
242
  setSelectedIds: (id, ids) => setSelected(id, ids),
144
- clearSelectedIds: (id) => setSelected(id, []),
243
+ clearSelectedIds: (id) => setSelected(id, [state.get(id)?.calendars.find((c) => c.primary)?.id ?? 'primary']),
145
244
  subscribe(l) {
146
245
  listeners.add(l);
147
246
  return () => listeners.delete(l);
@@ -4,6 +4,12 @@ export declare class CalendarFetchError extends Error {
4
4
  status: number;
5
5
  constructor(kind: 'network' | 'auth' | 'scope' | 'gone' | 'http', status?: number);
6
6
  }
7
+ /** Thrown by a host's getToken when a token refresh was deferred (e.g. app inactive). Aborts a pass quietly. */
8
+ export declare class RefreshDeferredError extends Error {
9
+ readonly deferred = true;
10
+ constructor(message?: string);
11
+ }
12
+ export declare function isRefreshDeferredError(e: unknown): boolean;
7
13
  export declare function fetchCalendarList(token: string): Promise<CalendarInfo[]>;
8
14
  export interface EventsPage {
9
15
  items: any[];
@@ -8,6 +8,20 @@ export class CalendarFetchError extends Error {
8
8
  this.status = status;
9
9
  }
10
10
  }
11
+ /** Thrown by a host's getToken when a token refresh was deferred (e.g. app inactive). Aborts a pass quietly. */
12
+ export class RefreshDeferredError extends Error {
13
+ deferred = true;
14
+ constructor(message = 'token refresh deferred') {
15
+ super(message);
16
+ this.name = 'RefreshDeferredError';
17
+ }
18
+ }
19
+ export function isRefreshDeferredError(e) {
20
+ if (e instanceof RefreshDeferredError)
21
+ return true;
22
+ const x = e;
23
+ return !!x && (x.deferred === true || x.name === 'RefreshDeferredError');
24
+ }
11
25
  async function request(url, token) {
12
26
  let res;
13
27
  try {
@@ -1,5 +1,5 @@
1
1
  import type { CalendarEvent, CalendarInfo } from './types.js';
2
- /** Stored selection filtered to existing calendars; the primary calendar is always included. */
2
+ /** Stored selection filtered to existing calendars; falls back to primary (else first). Primary is not forced. */
3
3
  export declare function reconcileSelection(stored: string[] | null, calendars: CalendarInfo[]): string[];
4
4
  /** Applies an incremental page of raw items to one calendar's events (cancelled items remove). */
5
5
  export declare function applyChanges(existing: CalendarEvent[], items: any[], ctx: {
@@ -1,12 +1,8 @@
1
1
  import { reconcileSelectedCalendarIds } from './events.js';
2
2
  import { parseEvent } from './events.js';
3
- /** Stored selection filtered to existing calendars; the primary calendar is always included. */
3
+ /** Stored selection filtered to existing calendars; falls back to primary (else first). Primary is not forced. */
4
4
  export function reconcileSelection(stored, calendars) {
5
- const ids = reconcileSelectedCalendarIds(stored, calendars);
6
- const primary = calendars.find((c) => c.primary);
7
- if (primary && !ids.includes(primary.id))
8
- ids.unshift(primary.id);
9
- return ids;
5
+ return reconcileSelectedCalendarIds(stored, calendars);
10
6
  }
11
7
  /** Applies an incremental page of raw items to one calendar's events (cancelled items remove). */
12
8
  export function applyChanges(existing, items, ctx) {
@@ -2,6 +2,10 @@ import type { CalendarEvent } from './types.js';
2
2
  export interface SyncState {
3
3
  /** Per-calendar incremental sync tokens. */
4
4
  tokens: Record<string, string>;
5
+ /** Per-calendar ms epoch of last full sync (optional; absent on 0.1.0 rows). */
6
+ lastFullSyncAt?: Record<string, number>;
7
+ /** ms epoch of last successful sync pass (optional; absent on 0.1.0 rows). */
8
+ lastSyncAt?: number;
5
9
  }
6
10
  /** Persisted per-source row. `events` is an encrypted JSON string when written by the controller. */
7
11
  export interface StorageRow {
@@ -2,7 +2,10 @@ import type { CalendarEvent } from './types.js';
2
2
  export interface CalendarSyncResult {
3
3
  events: CalendarEvent[];
4
4
  syncToken?: string;
5
+ /** True when a full (windowed) sync was performed. */
6
+ full: boolean;
5
7
  }
8
+ export declare const FULL_SYNC_MAX_AGE_MS: number;
6
9
  /** Syncs one calendar: incremental when a token exists, full (windowed) otherwise or on 410. */
7
10
  export declare function syncCalendar(args: {
8
11
  token: string;
@@ -10,5 +13,6 @@ export declare function syncCalendar(args: {
10
13
  calendarId: string;
11
14
  existing: CalendarEvent[];
12
15
  syncToken?: string;
16
+ lastFullSyncAt?: number;
13
17
  now: Date;
14
18
  }): Promise<CalendarSyncResult>;
package/dist/core/sync.js CHANGED
@@ -1,22 +1,25 @@
1
1
  import { CalendarFetchError, fetchEvents } from './fetch.js';
2
2
  import { getRollingWindow, parseEvents } from './events.js';
3
3
  import { applyChanges } from './reconcile.js';
4
+ export const FULL_SYNC_MAX_AGE_MS = 24 * 3600_000;
4
5
  /** Syncs one calendar: incremental when a token exists, full (windowed) otherwise or on 410. */
5
6
  export async function syncCalendar(args) {
6
- const { token, sourceId, calendarId, existing, syncToken, now } = args;
7
+ const { token, sourceId, calendarId, existing, syncToken, lastFullSyncAt, now } = args;
7
8
  const ctx = { sourceId, calendarId };
8
9
  const full = async () => {
9
10
  const w = getRollingWindow(now);
10
11
  const page = await fetchEvents(token, calendarId, { timeMin: w.start, timeMax: w.end });
11
- return { events: parseEvents(page.items, ctx), syncToken: page.nextSyncToken };
12
+ return { events: parseEvents(page.items, ctx), syncToken: page.nextSyncToken, full: true };
12
13
  };
13
- if (!syncToken)
14
+ const stale = lastFullSyncAt === undefined || now.getTime() - lastFullSyncAt > FULL_SYNC_MAX_AGE_MS;
15
+ if (!syncToken || stale)
14
16
  return full();
15
17
  try {
16
18
  const page = await fetchEvents(token, calendarId, { syncToken });
17
19
  return {
18
20
  events: applyChanges(existing, page.items, ctx),
19
21
  syncToken: page.nextSyncToken ?? syncToken,
22
+ full: false,
20
23
  };
21
24
  }
22
25
  catch (e) {
@@ -11,6 +11,12 @@ export interface CalendarPaneProps {
11
11
  emptyState?: ReactNode;
12
12
  sourceNotice?: (source: Source) => ReactNode;
13
13
  onClose?: () => void;
14
+ /** Whether Google Calendar is connected. Defaults to `sources.length > 0`. */
15
+ connected?: boolean;
16
+ /** Whether the connection needs reauthorization. Defaults to all sources being 'needs-scope'. */
17
+ needsReauth?: boolean;
18
+ onConnect?: () => void;
19
+ onReconnect?: () => void;
14
20
  /** Window name used for the event popup. */
15
21
  popupName?: string;
16
22
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { filterByRsvp, groupByDay, getOrganizerLabel, formatDayHeader, isEventCurrent, findNextUpcomingEvent, getCalendarEventKey, DEFAULT_RSVP_STATUSES, } from '../core/events.js';
4
4
  import { CalendarSelector } from './CalendarSelector.js';
@@ -98,13 +98,18 @@ export function CalendarPane(props) {
98
98
  setVersion((v) => v + 1);
99
99
  return unsubscribe;
100
100
  }, [controller]);
101
+ useEffect(() => {
102
+ if (typeof controller.hydrate === 'function')
103
+ void Promise.resolve(controller.hydrate()).catch(() => { });
104
+ }, [controller]);
101
105
  // eslint-disable-next-line react-hooks/exhaustive-deps
102
106
  const events = useMemo(() => controller.getEvents(), [controller, version]);
103
107
  const statuses = controller.getSourceStatus();
104
- const connected = sources.length > 0;
108
+ const connected = props.connected ?? sources.length > 0;
109
+ const ctrlState = controller.getState();
105
110
  const multi = sources.length >= 2;
106
111
  const needsScopeSources = sources.filter((s) => statuses[s.id] === 'needs-scope');
107
- const needsReauth = connected && needsScopeSources.length === sources.length;
112
+ const needsReauth = connected && (props.needsReauth ?? (sources.length > 0 && needsScopeSources.length === sources.length));
108
113
  const sourceColor = (id) => SOURCE_COLORS[Math.max(0, sources.findIndex((s) => s.id === id)) % SOURCE_COLORS.length];
109
114
  const sourceEmail = (id) => sources.find((s) => s.id === id)?.email ?? '';
110
115
  const todayIso = toIsoDate(new Date());
@@ -260,19 +265,19 @@ export function CalendarPane(props) {
260
265
  return () => observer.disconnect();
261
266
  }, [connected, needsReauth]);
262
267
  if (!connected) {
263
- return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.emptyState }) }));
268
+ return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.emptyState ?? (_jsxs(_Fragment, { children: [_jsx("p", { children: "Not connected to Google Calendar." }), _jsx("button", { className: "calendar-connect-button", onClick: props.onConnect, children: "Connect Google Calendar" })] })) }) }));
264
269
  }
265
270
  if (needsReauth) {
266
- return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: needsScopeSources.map((s) => (_jsx("div", { children: props.sourceNotice?.(s) }, s.id))) }) }));
271
+ return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.sourceNotice && needsScopeSources.length > 0 ? (needsScopeSources.map((s) => _jsx("div", { children: props.sourceNotice?.(s) }, s.id))) : (_jsxs(_Fragment, { children: [_jsx("p", { children: "Calendar connection needs reauthorization." }), _jsx("button", { className: "calendar-connect-button", onClick: props.onReconnect, children: "Reconnect to restore sync" })] })) }) }));
267
272
  }
268
273
  const sourcesWithEvents = new Set(filteredEvents.map((e) => e.sourceId));
269
274
  const hasErrorWithoutEvents = sources.some((s) => statuses[s.id] === 'error' && !sourcesWithEvents.has(s.id));
270
275
  const calendarSources = sources.filter((s) => controller.getCalendars(s.id).length > 0);
271
- const error = hasErrorWithoutEvents;
272
- const loading = false;
276
+ const error = hasErrorWithoutEvents || (ctrlState.error && filteredEvents.length === 0);
277
+ const loading = ctrlState.loading && filteredEvents.length === 0;
273
278
  const emptyStateText = 'No matching events in the next 30 days';
274
279
  const showCalendarSelector = calendarSources.length > 0;
275
- return (_jsxs("div", { className: "calendar-pane", ref: paneRef, children: [_jsxs("div", { className: "calendar-pane-scroll", children: [_jsxs("div", { className: "calendar-rsvp-chips", ref: chipsRef, children: [_jsx("button", { type: "button", className: "calendar-rsvp-chip calendar-scroll-today-btn", "aria-label": "Scroll to today", title: "Scroll to today", onClick: () => recenter('smooth'), children: _jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("rect", { x: "2", y: "3", width: "11", height: "10", rx: "1.5", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M2 6H13", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M5 2V4M10 2V4", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" }), _jsx("circle", { cx: "7.5", cy: "9.5", r: "1.3", fill: "currentColor" })] }) }), RSVP_CHIPS.map((chip) => (_jsx("button", { type: "button", className: `calendar-rsvp-chip${selectedRsvpStatuses.has(chip.status) ? ' selected' : ''}`, "aria-pressed": selectedRsvpStatuses.has(chip.status), "aria-label": chip.label, title: chip.label, onClick: () => handleToggle(chip.status), children: chip.icon }, chip.status)))] }), needsScopeSources.map((s) => (_jsx("div", { children: props.sourceNotice?.(s) }, s.id))), !loading && error && (_jsx("div", { className: "empty-state calendar-empty-state", role: "alert", children: _jsx("p", { children: "Couldn't load calendar events. Try again shortly." }) })), !(error && filteredEvents.length === 0) && (_jsxs("div", { className: "calendar-day-groups", children: [dayGroups.map((group) => (_jsxs("div", { className: "calendar-day-group", children: [_jsx("div", { className: "calendar-day-header", ref: (el) => {
280
+ return (_jsxs("div", { className: "calendar-pane", ref: paneRef, children: [_jsxs("div", { className: "calendar-pane-scroll", children: [_jsxs("div", { className: "calendar-rsvp-chips", ref: chipsRef, children: [_jsx("button", { type: "button", className: "calendar-rsvp-chip calendar-scroll-today-btn", "aria-label": "Scroll to today", title: "Scroll to today", onClick: () => recenter('smooth'), children: _jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("rect", { x: "2", y: "3", width: "11", height: "10", rx: "1.5", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M2 6H13", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("path", { d: "M5 2V4M10 2V4", stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round" }), _jsx("circle", { cx: "7.5", cy: "9.5", r: "1.3", fill: "currentColor" })] }) }), RSVP_CHIPS.map((chip) => (_jsx("button", { type: "button", className: `calendar-rsvp-chip${selectedRsvpStatuses.has(chip.status) ? ' selected' : ''}`, "aria-pressed": selectedRsvpStatuses.has(chip.status), "aria-label": chip.label, title: chip.label, onClick: () => handleToggle(chip.status), children: chip.icon }, chip.status)))] }), needsScopeSources.map((s) => (_jsx("div", { children: props.sourceNotice?.(s) }, s.id))), error && (_jsx("div", { className: "empty-state calendar-empty-state", role: "alert", children: _jsx("p", { children: "Couldn't load calendar events. Try again shortly." }) })), !(error && filteredEvents.length === 0) && (_jsxs("div", { className: "calendar-day-groups", children: [dayGroups.map((group) => (_jsxs("div", { className: "calendar-day-group", children: [_jsx("div", { className: "calendar-day-header", ref: (el) => {
276
281
  if (el) {
277
282
  dayHeaderRefs.current.set(group.dateKey, el);
278
283
  }
@@ -317,7 +322,7 @@ export function CalendarPane(props) {
317
322
  openEventPopup(event.htmlLink, popupRef, popupName);
318
323
  }
319
324
  }, children: _jsx("span", { className: "calendar-event-title", children: event.summary }) })) : (_jsx("span", { className: "calendar-event-title", children: event.summary })), props.renderEventAction?.(event)] }), showCalendarLabel && (_jsxs("span", { className: "calendar-event-calendar", children: [_jsx("span", { className: "calendar-event-calendar-dot", style: { backgroundColor: calendar.backgroundColor } }), calendar.summary] })), showOrganizerLabel && (_jsxs("span", { className: "calendar-event-organizer", children: [multi && (_jsx("span", { className: "calendar-event-source-dot", style: { backgroundColor: sourceColor(event.sourceId) } })), organizerText] }))] }, eventKey));
320
- })] }, group.dateKey))), filteredEvents.length === 0 && !error && needsScopeSources.length === 0 && _jsx("p", { className: "calendar-empty-state-text", children: emptyStateText })] }))] }), showCalendarSelector && (_jsx("footer", { className: "calendar-pane-footer", children: calendarSources.map((s) => (_jsx(CalendarSelector, { calendars: controller.getCalendars(s.id), selectedIds: controller.getSelectedIds(s.id), onChange: (ids) => {
325
+ })] }, group.dateKey))), loading && _jsx("p", { className: "calendar-empty-state-text", children: "Loading calendar\u2026" }), filteredEvents.length === 0 && !loading && !error && needsScopeSources.length === 0 && _jsx("p", { className: "calendar-empty-state-text", children: emptyStateText })] }))] }), showCalendarSelector && (_jsx("footer", { className: "calendar-pane-footer", children: calendarSources.map((s) => (_jsx(CalendarSelector, { calendars: controller.getCalendars(s.id), selectedIds: controller.getSelectedIds(s.id), onChange: (ids) => {
321
326
  void controller.setSelectedIds(s.id, ids);
322
327
  }, heading: multi ? s.email : undefined }, s.id))) }))] }));
323
328
  }
@@ -72,7 +72,7 @@ export function runStorageAdapterContract(factory) {
72
72
  });
73
73
  it('missing source reads back as empty', async () => {
74
74
  const h = await factory();
75
- expect(await readBack(h)).toEqual({ events: [], selected: [] });
75
+ expect(await readBack(h)).toEqual({ events: [], selected: ['primary'] });
76
76
  });
77
77
  it('reads a legacy plain (unencrypted) row', async () => {
78
78
  const h = await factory();
@@ -90,10 +90,17 @@ export function runStorageAdapterContract(factory) {
90
90
  expect(out.events).toEqual([LEGACY_EVENT]);
91
91
  expect(out.selected).toEqual(['cal']);
92
92
  });
93
+ it('reads a 0.1.0 row that has no lastFullSyncAt/lastSyncAt fields', async () => {
94
+ const h = await factory();
95
+ await seed(h, 'src', { events: [LEGACY_EVENT], syncState: { tokens: { cal: 't' } }, selectedIds: ['cal'] });
96
+ const out = await readBack(h);
97
+ expect(out.events).toEqual([LEGACY_EVENT]);
98
+ expect(out.selected).toEqual(['cal']);
99
+ });
93
100
  it('a corrupt row reads back empty without throwing', async () => {
94
101
  const h = await factory();
95
102
  await seed(h, 'src', h.corruptRow ?? { events: '%%not-json%%', syncState: '{{', selectedIds: 'bad' });
96
- await expect(readBack(h)).resolves.toEqual({ events: [], selected: [] });
103
+ await expect(readBack(h)).resolves.toEqual({ events: [], selected: ['primary'] });
97
104
  });
98
105
  it('a wrong key reads back empty without throwing', async () => {
99
106
  const h = await factory();
@@ -104,7 +111,7 @@ export function runStorageAdapterContract(factory) {
104
111
  syncState: await h.crypto.encrypt(JSON.stringify({ tokens: {} })),
105
112
  selectedIds: [],
106
113
  });
107
- await expect(readBack(h, h.wrongKeyCrypto)).resolves.toEqual({ events: [], selected: [] });
114
+ await expect(readBack(h, h.wrongKeyCrypto)).resolves.toEqual({ events: [], selected: ['primary'] });
108
115
  });
109
116
  });
110
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-webapp/calendar",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",