@open-webapp/calendar 0.1.0 → 0.2.1
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 +86 -14
- package/dist/core/controller.d.ts +11 -0
- package/dist/core/controller.js +140 -36
- package/dist/core/fetch.d.ts +6 -0
- package/dist/core/fetch.js +14 -0
- package/dist/core/reconcile.d.ts +1 -1
- package/dist/core/reconcile.js +2 -6
- package/dist/core/storage.d.ts +4 -0
- package/dist/core/sync.d.ts +4 -0
- package/dist/core/sync.js +6 -3
- package/dist/react/CalendarPane.d.ts +6 -0
- package/dist/react/CalendarPane.js +16 -9
- package/dist/testing/storageContract.js +10 -3
- package/package.json +1 -1
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.
|
|
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
|
-
- `
|
|
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)`:
|
|
52
|
-
- `clearSelectedIds(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.
|
|
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 {
|
|
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
|
-
|
|
137
|
+
### Event key
|
|
88
138
|
|
|
89
|
-
|
|
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
|
-
| `
|
|
106
|
-
| `
|
|
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
|
|
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,16 @@ 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
|
+
## 0.2.1 (patch)
|
|
182
|
+
|
|
183
|
+
Fixes only, no behavior contract change: `getState().loading` is now published while a sync pass is in flight; a selection-change (partial) pass no longer clears `deferred` set by an earlier full pass; `CalendarPane` tolerates controllers without `getState`/`hydrate`.
|
|
184
|
+
|
|
185
|
+
## Breaking changes in 0.2.0
|
|
186
|
+
|
|
187
|
+
BREAKING (0.1.0 to 0.2.0):
|
|
188
|
+
|
|
189
|
+
- **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.
|
|
190
|
+
- **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).
|
|
191
|
+
- **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()`.
|
|
192
|
+
- 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>;
|
package/dist/core/controller.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 (
|
|
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,71 @@ 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
|
+
publish();
|
|
182
|
+
try {
|
|
183
|
+
const ids = sources.map((s) => s.id).filter((id) => !only || only[id]);
|
|
184
|
+
const results = await Promise.all(ids.map((id) => refreshSource(id, only?.[id])));
|
|
185
|
+
const any = results.some(Boolean);
|
|
186
|
+
if (!only)
|
|
187
|
+
deferred = any;
|
|
188
|
+
else if (any)
|
|
189
|
+
deferred = true;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* never throws */
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
loadingCount--;
|
|
196
|
+
}
|
|
197
|
+
publish();
|
|
108
198
|
}
|
|
109
199
|
async function setSelected(sourceId, ids) {
|
|
110
200
|
const st = state.get(sourceId);
|
|
111
201
|
if (!st)
|
|
112
202
|
return;
|
|
113
203
|
await load(sourceId);
|
|
114
|
-
const
|
|
115
|
-
|
|
204
|
+
const next = [...new Set(ids)];
|
|
205
|
+
if (next.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
const added = next.filter((id) => !st.selectedIds.includes(id));
|
|
116
208
|
st.selectedIds = next;
|
|
117
|
-
st.
|
|
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 };
|
|
209
|
+
st.gen++;
|
|
123
210
|
await persist(sourceId);
|
|
124
211
|
publish();
|
|
212
|
+
if (added.length > 0)
|
|
213
|
+
await runPass({ [sourceId]: added });
|
|
125
214
|
}
|
|
126
215
|
const controller = {
|
|
127
|
-
async
|
|
216
|
+
async hydrate() {
|
|
128
217
|
try {
|
|
129
|
-
await Promise.all(sources.map((s) =>
|
|
218
|
+
await Promise.all(sources.map((s) => load(s.id)));
|
|
130
219
|
}
|
|
131
220
|
catch {
|
|
132
221
|
/* never throws */
|
|
133
222
|
}
|
|
134
223
|
publish();
|
|
135
224
|
},
|
|
225
|
+
refresh: () => runPass(),
|
|
226
|
+
retryDeferred: () => runPass(),
|
|
227
|
+
getState() {
|
|
228
|
+
const sel = sources.flatMap((s) => {
|
|
229
|
+
const st = state.get(s.id);
|
|
230
|
+
return st.selectedIds.map((c) => st.errored.has(c));
|
|
231
|
+
});
|
|
232
|
+
const lasts = sources.map((s) => state.get(s.id).syncState.lastSyncAt).filter((v) => typeof v === 'number');
|
|
233
|
+
return {
|
|
234
|
+
loading: loadingCount > 0,
|
|
235
|
+
error: sel.length > 0 && sel.every(Boolean) && snapshot.length === 0,
|
|
236
|
+
deferred,
|
|
237
|
+
lastSyncAt: lasts.length ? Math.max(...lasts) : null,
|
|
238
|
+
};
|
|
239
|
+
},
|
|
136
240
|
getEvents: () => snapshot,
|
|
137
241
|
getSourceStatus: () => statusSnapshot,
|
|
138
242
|
get sourceStatus() {
|
|
@@ -141,7 +245,7 @@ export function createCalendarController(opts) {
|
|
|
141
245
|
getCalendars: (id) => state.get(id)?.calendars ?? [],
|
|
142
246
|
getSelectedIds: (id) => state.get(id)?.selectedIds ?? [],
|
|
143
247
|
setSelectedIds: (id, ids) => setSelected(id, ids),
|
|
144
|
-
clearSelectedIds: (id) => setSelected(id, []),
|
|
248
|
+
clearSelectedIds: (id) => setSelected(id, [state.get(id)?.calendars.find((c) => c.primary)?.id ?? 'primary']),
|
|
145
249
|
subscribe(l) {
|
|
146
250
|
listeners.add(l);
|
|
147
251
|
return () => listeners.delete(l);
|
package/dist/core/fetch.d.ts
CHANGED
|
@@ -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[];
|
package/dist/core/fetch.js
CHANGED
|
@@ -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 {
|
package/dist/core/reconcile.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CalendarEvent, CalendarInfo } from './types.js';
|
|
2
|
-
/** Stored selection filtered to existing calendars;
|
|
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: {
|
package/dist/core/reconcile.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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) {
|
package/dist/core/storage.d.ts
CHANGED
|
@@ -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 {
|
package/dist/core/sync.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,20 @@ 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 = typeof controller.getState === 'function'
|
|
110
|
+
? controller.getState()
|
|
111
|
+
: { loading: false, error: false, deferred: false, lastSyncAt: null };
|
|
105
112
|
const multi = sources.length >= 2;
|
|
106
113
|
const needsScopeSources = sources.filter((s) => statuses[s.id] === 'needs-scope');
|
|
107
|
-
const needsReauth = connected && needsScopeSources.length === sources.length;
|
|
114
|
+
const needsReauth = connected && (props.needsReauth ?? (sources.length > 0 && needsScopeSources.length === sources.length));
|
|
108
115
|
const sourceColor = (id) => SOURCE_COLORS[Math.max(0, sources.findIndex((s) => s.id === id)) % SOURCE_COLORS.length];
|
|
109
116
|
const sourceEmail = (id) => sources.find((s) => s.id === id)?.email ?? '';
|
|
110
117
|
const todayIso = toIsoDate(new Date());
|
|
@@ -260,19 +267,19 @@ export function CalendarPane(props) {
|
|
|
260
267
|
return () => observer.disconnect();
|
|
261
268
|
}, [connected, needsReauth]);
|
|
262
269
|
if (!connected) {
|
|
263
|
-
return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.emptyState }) }));
|
|
270
|
+
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
271
|
}
|
|
265
272
|
if (needsReauth) {
|
|
266
|
-
return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: needsScopeSources.map((s) =>
|
|
273
|
+
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
274
|
}
|
|
268
275
|
const sourcesWithEvents = new Set(filteredEvents.map((e) => e.sourceId));
|
|
269
276
|
const hasErrorWithoutEvents = sources.some((s) => statuses[s.id] === 'error' && !sourcesWithEvents.has(s.id));
|
|
270
277
|
const calendarSources = sources.filter((s) => controller.getCalendars(s.id).length > 0);
|
|
271
|
-
const error = hasErrorWithoutEvents;
|
|
272
|
-
const loading =
|
|
278
|
+
const error = hasErrorWithoutEvents || (ctrlState.error && filteredEvents.length === 0);
|
|
279
|
+
const loading = ctrlState.loading && filteredEvents.length === 0;
|
|
273
280
|
const emptyStateText = 'No matching events in the next 30 days';
|
|
274
281
|
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))),
|
|
282
|
+
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
283
|
if (el) {
|
|
277
284
|
dayHeaderRefs.current.set(group.dateKey, el);
|
|
278
285
|
}
|
|
@@ -317,7 +324,7 @@ export function CalendarPane(props) {
|
|
|
317
324
|
openEventPopup(event.htmlLink, popupRef, popupName);
|
|
318
325
|
}
|
|
319
326
|
}, 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) => {
|
|
327
|
+
})] }, 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
328
|
void controller.setSelectedIds(s.id, ids);
|
|
322
329
|
}, heading: multi ? s.email : undefined }, s.id))) }))] }));
|
|
323
330
|
}
|
|
@@ -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
|
}
|