@open-webapp/calendar 0.0.0-stage → 0.1.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 +119 -2
- package/dist/core/controller.d.ts +22 -0
- package/dist/core/controller.js +152 -0
- package/dist/core/events.d.ts +91 -0
- package/dist/core/events.js +257 -0
- package/dist/core/fetch.d.ts +17 -0
- package/dist/core/fetch.js +94 -0
- package/dist/core/reconcile.d.ts +8 -0
- package/dist/core/reconcile.js +24 -0
- package/dist/core/storage.d.ts +21 -0
- package/dist/core/storage.js +1 -0
- package/dist/core/sync.d.ts +14 -0
- package/dist/core/sync.js +27 -0
- package/dist/core/types.d.ts +45 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/react/CalendarPane.d.ts +17 -0
- package/dist/react/CalendarPane.js +323 -0
- package/dist/react/CalendarSelector.d.ts +10 -0
- package/dist/react/CalendarSelector.js +90 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +2 -0
- package/dist/styles.css +503 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +42 -0
- package/dist/testing/storageContract.d.ts +27 -0
- package/dist/testing/storageContract.js +110 -0
- package/package.json +53 -4
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @open-webapp/calendar
|
|
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
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @open-webapp/calendar
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer: `react ^19`. `vitest` is an optional peer, needed only for `@open-webapp/calendar/testing`'s `runStorageAdapterContract`.
|
|
12
|
+
|
|
13
|
+
## Entry points
|
|
14
|
+
|
|
15
|
+
| Import | Contents |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `@open-webapp/calendar` | core types, event helpers, fetch/sync/reconcile helpers, `createCalendarController`, `CalendarPane`, `CalendarSelector` |
|
|
18
|
+
| `@open-webapp/calendar/testing` | `runStorageAdapterContract`, `LEGACY_EVENT`, `StorageContractHarness`, `createMemoryStorage`, `identityCrypto`, `installFakeFetch` |
|
|
19
|
+
| `@open-webapp/calendar/styles.css` | stylesheet for the React components (import once) |
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { createCalendarController, CalendarPane } from '@open-webapp/calendar';
|
|
25
|
+
import '@open-webapp/calendar/styles.css';
|
|
26
|
+
|
|
27
|
+
const controller = createCalendarController({
|
|
28
|
+
sources: [{ id: 'a', email: 'me@example.com' }],
|
|
29
|
+
getToken: async (sourceId) => accessTokenFor(sourceId),
|
|
30
|
+
storage, // StorageAdapter
|
|
31
|
+
crypto, // CryptoAdapter
|
|
32
|
+
});
|
|
33
|
+
await controller.refresh();
|
|
34
|
+
|
|
35
|
+
<CalendarPane
|
|
36
|
+
sources={sources}
|
|
37
|
+
controller={controller}
|
|
38
|
+
selectedRsvpStatuses={statuses} // Set<RsvpStatus>
|
|
39
|
+
onRsvpStatusesChange={setStatuses}
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Controller
|
|
44
|
+
|
|
45
|
+
`createCalendarController({ sources, getToken, storage, crypto, now? })`
|
|
46
|
+
|
|
47
|
+
- `refresh(): Promise<void>` syncs every source in parallel; never throws.
|
|
48
|
+
- `getEvents(): CalendarEvent[]` stable snapshot (updated on `refresh`/`setSelectedIds`), sources concatenated in `sources` order.
|
|
49
|
+
- `getSourceStatus()` / `sourceStatus`: `Record<sourceId, 'ok' | 'needs-scope' | 'error'>`.
|
|
50
|
+
- `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).
|
|
53
|
+
- `subscribe(listener): () => void`.
|
|
54
|
+
|
|
55
|
+
### sourceStatus
|
|
56
|
+
|
|
57
|
+
- `ok`: last refresh succeeded (also the initial value).
|
|
58
|
+
- `needs-scope`: Google returned 403 `insufficientPermissions`; the host should prompt re-consent.
|
|
59
|
+
- `error`: any other failure (401, 5xx, non-403 HTTP, unexpected exception).
|
|
60
|
+
- Network failures leave the previous status unchanged and keep cached events.
|
|
61
|
+
|
|
62
|
+
## Adapters
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
interface StorageAdapter {
|
|
66
|
+
get(sourceId): Promise<StorageRow | null>;
|
|
67
|
+
put(sourceId, row: StorageRow): Promise<void>;
|
|
68
|
+
delete(sourceId): Promise<void>;
|
|
69
|
+
clear(sourceId): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
interface StorageRow { events: CalendarEvent[] | string | unknown[]; syncState: SyncState | string | null | unknown; selectedIds: string[] }
|
|
72
|
+
interface SyncState { tokens: Record<calendarId, string> }
|
|
73
|
+
interface CryptoAdapter { encrypt(s: string): Promise<string>; decrypt(s: string): Promise<string> }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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
|
+
|
|
78
|
+
### Contract harness
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { runStorageAdapterContract } from '@open-webapp/calendar/testing';
|
|
82
|
+
runStorageAdapterContract(() => ({ storage, crypto, seedRaw?, wrongKeyCrypto?, encryptedFixture?, corruptRow? }));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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
|
+
|
|
87
|
+
## Sync behavior
|
|
88
|
+
|
|
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.
|
|
93
|
+
|
|
94
|
+
## UI
|
|
95
|
+
|
|
96
|
+
### CalendarPane props
|
|
97
|
+
|
|
98
|
+
| Prop | Type | Notes |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `sources` | `Source[]` | shown accounts; 2+ enables multi-source UI (per-source color and email) |
|
|
101
|
+
| `controller` | `CalendarController` | |
|
|
102
|
+
| `selectedRsvpStatuses` | `Set<RsvpStatus>` | filter; `DEFAULT_RSVP_STATUSES` = accepted, tentative, needsAction |
|
|
103
|
+
| `onRsvpStatusesChange` | `(next) => void` | |
|
|
104
|
+
| `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`) |
|
|
107
|
+
| `onClose?` | `() => void` | |
|
|
108
|
+
| `popupName?` | `string` | `window.open` name for the event popup (default `notesdiary-cal-event`) |
|
|
109
|
+
|
|
110
|
+
### CalendarSelector props
|
|
111
|
+
|
|
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.
|
|
113
|
+
|
|
114
|
+
### Host CSS
|
|
115
|
+
|
|
116
|
+
`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:
|
|
117
|
+
|
|
118
|
+
- `--calendar-footer-height` (default `64px`, set on `.calendar-pane`; override to match host footer)
|
|
119
|
+
- `--chip-row-height` (fallback `44px`; host may set it to match its chip row for sticky headers)
|
|
120
|
+
- `--rsvp-bar` (set internally per RSVP status; do not override)
|
|
@@ -0,0 +1,22 @@
|
|
|
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 CalendarController {
|
|
12
|
+
refresh(): Promise<void>;
|
|
13
|
+
getEvents(): CalendarEvent[];
|
|
14
|
+
getSourceStatus(): Record<string, SourceStatus>;
|
|
15
|
+
readonly sourceStatus: Record<string, SourceStatus>;
|
|
16
|
+
getCalendars(sourceId: string): CalendarInfo[];
|
|
17
|
+
getSelectedIds(sourceId: string): string[];
|
|
18
|
+
setSelectedIds(sourceId: string, ids: string[]): Promise<void>;
|
|
19
|
+
clearSelectedIds(sourceId: string): Promise<void>;
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function createCalendarController(opts: CalendarControllerOptions): CalendarController;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { CalendarFetchError, fetchCalendarList } 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 statusSnapshot = {};
|
|
11
|
+
for (const s of sources) {
|
|
12
|
+
state.set(s.id, {
|
|
13
|
+
loaded: false,
|
|
14
|
+
events: [],
|
|
15
|
+
syncState: { tokens: {} },
|
|
16
|
+
selectedIds: [],
|
|
17
|
+
calendars: [],
|
|
18
|
+
status: 'ok',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function publish() {
|
|
22
|
+
snapshot = sources.flatMap((s) => state.get(s.id).events);
|
|
23
|
+
statusSnapshot = Object.fromEntries(sources.map((s) => [s.id, state.get(s.id).status]));
|
|
24
|
+
listeners.forEach((l) => l());
|
|
25
|
+
}
|
|
26
|
+
async function decode(v, fallback) {
|
|
27
|
+
if (typeof v === 'string') {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await crypto.decrypt(v));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return (v ?? fallback);
|
|
36
|
+
}
|
|
37
|
+
async function load(sourceId) {
|
|
38
|
+
const st = state.get(sourceId);
|
|
39
|
+
if (st.loaded)
|
|
40
|
+
return;
|
|
41
|
+
st.loaded = true;
|
|
42
|
+
try {
|
|
43
|
+
const row = await storage.get(sourceId);
|
|
44
|
+
if (!row)
|
|
45
|
+
return;
|
|
46
|
+
st.events = await decode(row.events, []);
|
|
47
|
+
const ss = await decode(row.syncState, null);
|
|
48
|
+
st.syncState = ss && ss.tokens ? ss : { tokens: {} };
|
|
49
|
+
st.selectedIds = Array.isArray(row.selectedIds) ? row.selectedIds : [];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* unreadable cache: start empty */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function persist(sourceId) {
|
|
56
|
+
const st = state.get(sourceId);
|
|
57
|
+
try {
|
|
58
|
+
await storage.put(sourceId, {
|
|
59
|
+
events: await crypto.encrypt(JSON.stringify(st.events)),
|
|
60
|
+
syncState: await crypto.encrypt(JSON.stringify(st.syncState)),
|
|
61
|
+
selectedIds: st.selectedIds,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* persistence is best-effort */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function refreshSource(sourceId) {
|
|
69
|
+
const st = state.get(sourceId);
|
|
70
|
+
await load(sourceId);
|
|
71
|
+
try {
|
|
72
|
+
const token = await getToken(sourceId);
|
|
73
|
+
const calendars = await fetchCalendarList(token);
|
|
74
|
+
st.calendars = calendars;
|
|
75
|
+
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;
|
|
90
|
+
}
|
|
91
|
+
st.events = next;
|
|
92
|
+
st.syncState = { tokens };
|
|
93
|
+
st.selectedIds = selected;
|
|
94
|
+
st.status = 'ok';
|
|
95
|
+
await persist(sourceId);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (e instanceof CalendarFetchError) {
|
|
99
|
+
if (e.kind === 'scope')
|
|
100
|
+
st.status = 'needs-scope';
|
|
101
|
+
else if (e.kind !== 'network')
|
|
102
|
+
st.status = 'error';
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
st.status = 'error';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function setSelected(sourceId, ids) {
|
|
110
|
+
const st = state.get(sourceId);
|
|
111
|
+
if (!st)
|
|
112
|
+
return;
|
|
113
|
+
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)];
|
|
116
|
+
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 };
|
|
123
|
+
await persist(sourceId);
|
|
124
|
+
publish();
|
|
125
|
+
}
|
|
126
|
+
const controller = {
|
|
127
|
+
async refresh() {
|
|
128
|
+
try {
|
|
129
|
+
await Promise.all(sources.map((s) => refreshSource(s.id)));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* never throws */
|
|
133
|
+
}
|
|
134
|
+
publish();
|
|
135
|
+
},
|
|
136
|
+
getEvents: () => snapshot,
|
|
137
|
+
getSourceStatus: () => statusSnapshot,
|
|
138
|
+
get sourceStatus() {
|
|
139
|
+
return statusSnapshot;
|
|
140
|
+
},
|
|
141
|
+
getCalendars: (id) => state.get(id)?.calendars ?? [],
|
|
142
|
+
getSelectedIds: (id) => state.get(id)?.selectedIds ?? [],
|
|
143
|
+
setSelectedIds: (id, ids) => setSelected(id, ids),
|
|
144
|
+
clearSelectedIds: (id) => setSelected(id, []),
|
|
145
|
+
subscribe(l) {
|
|
146
|
+
listeners.add(l);
|
|
147
|
+
return () => listeners.delete(l);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
statusSnapshot = Object.fromEntries(sources.map((s) => [s.id, 'ok']));
|
|
151
|
+
return controller;
|
|
152
|
+
}
|
|
@@ -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 {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
2
|
+
export const DEFAULT_RSVP_STATUSES = ['accepted', 'tentative', 'needsAction'];
|
|
3
|
+
/** Keeps stored calendar selections that still exist, defaulting to the primary calendar. */
|
|
4
|
+
export function reconcileSelectedCalendarIds(storedIds, calendars) {
|
|
5
|
+
const availableIds = new Set(calendars.map((calendar) => calendar.id));
|
|
6
|
+
const selectedIds = (storedIds ?? []).filter((id) => availableIds.has(id));
|
|
7
|
+
if (selectedIds.length > 0)
|
|
8
|
+
return selectedIds;
|
|
9
|
+
const fallback = calendars.find((calendar) => calendar.primary) ?? calendars[0];
|
|
10
|
+
return fallback ? [fallback.id] : [];
|
|
11
|
+
}
|
|
12
|
+
/** Filters calendars by a case-insensitive substring of their display name. */
|
|
13
|
+
export function filterCalendarsByName(calendars, query) {
|
|
14
|
+
const normalizedQuery = query.toLocaleLowerCase();
|
|
15
|
+
return normalizedQuery === ''
|
|
16
|
+
? calendars
|
|
17
|
+
: calendars.filter((calendar) => calendar.summary.toLocaleLowerCase().includes(normalizedQuery));
|
|
18
|
+
}
|
|
19
|
+
/** Event IDs are unique only within their calendar and source. */
|
|
20
|
+
export function getCalendarEventKey(event) {
|
|
21
|
+
return `${event.sourceId}:${event.calendarId ?? ""}:${event.id}`;
|
|
22
|
+
}
|
|
23
|
+
/** Produces the compact calendar-selection label shown in the filter control. */
|
|
24
|
+
export function summarizeCalendarSelection(selectedIds, calendars) {
|
|
25
|
+
if (selectedIds.length !== 1)
|
|
26
|
+
return `${selectedIds.length} calendars`;
|
|
27
|
+
return calendars.find((calendar) => calendar.id === selectedIds[0])?.summary ?? '1 calendar';
|
|
28
|
+
}
|
|
29
|
+
/** True iff the event is an all-day event. Passthrough of the package-computed flag. */
|
|
30
|
+
export function isAllDay(event) {
|
|
31
|
+
return event.isAllDay;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rolling fetch window: now-30d to now+30d, as full RFC3339 timestamps.
|
|
35
|
+
* Google Calendar's `timeMin`/`timeMax` reject bare `YYYY-MM-DD` dates.
|
|
36
|
+
*/
|
|
37
|
+
export function getRollingWindow(now) {
|
|
38
|
+
const start = new Date(now.getTime() - 30 * MS_PER_DAY);
|
|
39
|
+
const end = new Date(now.getTime() + 30 * MS_PER_DAY);
|
|
40
|
+
return { start: start.toISOString(), end: end.toISOString() };
|
|
41
|
+
}
|
|
42
|
+
function toIsoDate(d) {
|
|
43
|
+
return d.toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns events whose `selfResponseStatus` is a member of `selected` —
|
|
47
|
+
* declined is a normal, independently-selectable status now, not hard-excluded.
|
|
48
|
+
*/
|
|
49
|
+
export function filterByRsvp(events, selected) {
|
|
50
|
+
return events.filter((e) => selected.has(e.selfResponseStatus));
|
|
51
|
+
}
|
|
52
|
+
/** displayName -> email -> "You" (only when organizer.self is true and both above are absent). */
|
|
53
|
+
export function getOrganizerLabel(event) {
|
|
54
|
+
const { organizer } = event;
|
|
55
|
+
if (organizer.displayName)
|
|
56
|
+
return organizer.displayName;
|
|
57
|
+
if (organizer.email)
|
|
58
|
+
return organizer.email;
|
|
59
|
+
if (organizer.self === true)
|
|
60
|
+
return 'You';
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* True iff `event` is a timed (non-all-day) event currently in progress at `now`:
|
|
65
|
+
* both `start.dateTime` and `end.dateTime` exist and parse to valid instants,
|
|
66
|
+
* start <= now < end.
|
|
67
|
+
*/
|
|
68
|
+
export function isEventCurrent(event, now) {
|
|
69
|
+
if (isAllDay(event))
|
|
70
|
+
return false;
|
|
71
|
+
const { start, end } = event;
|
|
72
|
+
if (!start.dateTime || !end.dateTime)
|
|
73
|
+
return false;
|
|
74
|
+
const startMs = new Date(start.dateTime).getTime();
|
|
75
|
+
const endMs = new Date(end.dateTime).getTime();
|
|
76
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs))
|
|
77
|
+
return false;
|
|
78
|
+
const nowMs = now.getTime();
|
|
79
|
+
return startMs <= nowMs && nowMs < endMs;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Finds the soonest strictly-future timed event: `!isAllDay(event)`,
|
|
83
|
+
* `start.dateTime` exists and parses valid, and `startMs > now.getTime()`.
|
|
84
|
+
* Returns the smallest `startMs`; ties keep first-encountered array order.
|
|
85
|
+
* Returns `null` if none qualify.
|
|
86
|
+
*/
|
|
87
|
+
export function findNextUpcomingEvent(events, now) {
|
|
88
|
+
const nowMs = now.getTime();
|
|
89
|
+
let best = null;
|
|
90
|
+
let bestMs = Infinity;
|
|
91
|
+
for (const event of events) {
|
|
92
|
+
if (isAllDay(event))
|
|
93
|
+
continue;
|
|
94
|
+
const { dateTime } = event.start;
|
|
95
|
+
if (!dateTime)
|
|
96
|
+
continue;
|
|
97
|
+
const startMs = new Date(dateTime).getTime();
|
|
98
|
+
if (Number.isNaN(startMs))
|
|
99
|
+
continue;
|
|
100
|
+
if (startMs <= nowMs)
|
|
101
|
+
continue;
|
|
102
|
+
if (startMs < bestMs) {
|
|
103
|
+
bestMs = startMs;
|
|
104
|
+
best = event;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return best;
|
|
108
|
+
}
|
|
109
|
+
function getStartDateKey(event) {
|
|
110
|
+
const { start } = event;
|
|
111
|
+
if (start.date)
|
|
112
|
+
return start.date;
|
|
113
|
+
if (start.dateTime)
|
|
114
|
+
return start.dateTime.slice(0, 10);
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
function getStartTimeSortKey(event) {
|
|
118
|
+
const { start } = event;
|
|
119
|
+
if (start.dateTime)
|
|
120
|
+
return new Date(start.dateTime).getTime();
|
|
121
|
+
return -Infinity; // all-day events sort first
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Group by local calendar date, sorted ascending by day. Within a day,
|
|
125
|
+
* all-day events come first (in list order), then timed events sorted by
|
|
126
|
+
* start time ascending.
|
|
127
|
+
*
|
|
128
|
+
* When `todayIso` is given and has no events, an empty group for it is
|
|
129
|
+
* still included (so a "Today" header can always be rendered / scrolled
|
|
130
|
+
* to), inserted in correct sorted position.
|
|
131
|
+
*/
|
|
132
|
+
export function groupByDay(events, todayIso) {
|
|
133
|
+
const byDate = new Map();
|
|
134
|
+
for (const event of events) {
|
|
135
|
+
const key = getStartDateKey(event);
|
|
136
|
+
const list = byDate.get(key);
|
|
137
|
+
if (list) {
|
|
138
|
+
list.push(event);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
byDate.set(key, [event]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (todayIso !== undefined && !byDate.has(todayIso)) {
|
|
145
|
+
byDate.set(todayIso, []);
|
|
146
|
+
}
|
|
147
|
+
const dateKeys = Array.from(byDate.keys()).sort();
|
|
148
|
+
return dateKeys.map((dateKey) => {
|
|
149
|
+
const dayEvents = byDate.get(dateKey);
|
|
150
|
+
const allDayEvents = dayEvents.filter((e) => isAllDay(e));
|
|
151
|
+
const timedEvents = dayEvents
|
|
152
|
+
.filter((e) => !isAllDay(e))
|
|
153
|
+
.sort((a, b) => getStartTimeSortKey(a) - getStartTimeSortKey(b));
|
|
154
|
+
return { dateKey, events: [...allDayEvents, ...timedEvents] };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const MONTH_NAMES = [
|
|
158
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
159
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
160
|
+
];
|
|
161
|
+
function parseIsoDateParts(dateKey) {
|
|
162
|
+
const [year, month, day] = dateKey.split('-').map(Number);
|
|
163
|
+
return { year, month, day };
|
|
164
|
+
}
|
|
165
|
+
function formatMonthDay(dateKey, includeYear) {
|
|
166
|
+
const { year, month, day } = parseIsoDateParts(dateKey);
|
|
167
|
+
const monthName = MONTH_NAMES[month - 1];
|
|
168
|
+
const base = `${monthName} ${day}`;
|
|
169
|
+
return includeYear ? `${base}, ${year}` : base;
|
|
170
|
+
}
|
|
171
|
+
function addDaysToIso(dateKey, days) {
|
|
172
|
+
const { year, month, day } = parseIsoDateParts(dateKey);
|
|
173
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
174
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
175
|
+
return toIsoDate(d);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* "Today · Sept 26" / "Tomorrow · Sept 27" / else "Sept 28"-style.
|
|
179
|
+
* Includes year only when the event's year differs from today's year.
|
|
180
|
+
*/
|
|
181
|
+
export function formatDayHeader(dateKey, today) {
|
|
182
|
+
const { year: todayYear } = parseIsoDateParts(today);
|
|
183
|
+
const { year: eventYear } = parseIsoDateParts(dateKey);
|
|
184
|
+
const includeYear = eventYear !== todayYear;
|
|
185
|
+
const monthDay = formatMonthDay(dateKey, includeYear);
|
|
186
|
+
if (dateKey === today)
|
|
187
|
+
return `Today · ${monthDay}`;
|
|
188
|
+
if (dateKey === addDaysToIso(today, 1))
|
|
189
|
+
return `Tomorrow · ${monthDay}`;
|
|
190
|
+
return monthDay;
|
|
191
|
+
}
|
|
192
|
+
const JOIN_URL_REGEX = /https?:\/\/[^\s<>"']*(?:zoom\.us|meet\.google\.com|teams\.microsoft\.com)[^\s<>"']*/i;
|
|
193
|
+
export function deriveSelfResponseStatus(raw) {
|
|
194
|
+
if (!Array.isArray(raw.attendees))
|
|
195
|
+
return 'accepted';
|
|
196
|
+
const status = raw.attendees.find((a) => a?.self === true)?.responseStatus;
|
|
197
|
+
if (status === 'accepted' || status === 'tentative' || status === 'needsAction' || status === 'declined') {
|
|
198
|
+
return status;
|
|
199
|
+
}
|
|
200
|
+
return 'needsAction';
|
|
201
|
+
}
|
|
202
|
+
export function extractJoinUrl(raw) {
|
|
203
|
+
if (raw.hangoutLink)
|
|
204
|
+
return raw.hangoutLink;
|
|
205
|
+
const video = raw.conferenceData?.entryPoints?.find((ep) => ep.entryPointType === 'video');
|
|
206
|
+
if (video?.uri)
|
|
207
|
+
return video.uri;
|
|
208
|
+
const loc = typeof raw.location === 'string' ? raw.location.match(JOIN_URL_REGEX) : null;
|
|
209
|
+
if (loc)
|
|
210
|
+
return loc[0];
|
|
211
|
+
const desc = typeof raw.description === 'string' ? raw.description.match(JOIN_URL_REGEX) : null;
|
|
212
|
+
return desc ? desc[0] : null;
|
|
213
|
+
}
|
|
214
|
+
/** Parses a raw Google Calendar event. Returns null for cancelled/malformed input; never throws. */
|
|
215
|
+
export function parseEvent(raw, ctx) {
|
|
216
|
+
try {
|
|
217
|
+
if (!raw || typeof raw !== 'object')
|
|
218
|
+
return null;
|
|
219
|
+
if (typeof raw.id !== 'string' || raw.id === '')
|
|
220
|
+
return null;
|
|
221
|
+
if (raw.status === 'cancelled')
|
|
222
|
+
return null;
|
|
223
|
+
const start = raw.start ?? {};
|
|
224
|
+
const end = raw.end ?? {};
|
|
225
|
+
return {
|
|
226
|
+
id: raw.id,
|
|
227
|
+
sourceId: ctx.sourceId,
|
|
228
|
+
calendarId: ctx.calendarId,
|
|
229
|
+
summary: raw.summary ?? '',
|
|
230
|
+
start: { dateTime: start.dateTime, date: start.date, timeZone: start.timeZone },
|
|
231
|
+
end: { dateTime: end.dateTime, date: end.date, timeZone: end.timeZone },
|
|
232
|
+
isAllDay: Boolean(start.date && !start.dateTime),
|
|
233
|
+
selfResponseStatus: deriveSelfResponseStatus(raw),
|
|
234
|
+
joinUrl: extractJoinUrl(raw),
|
|
235
|
+
organizer: {
|
|
236
|
+
displayName: raw.organizer?.displayName,
|
|
237
|
+
email: raw.organizer?.email,
|
|
238
|
+
self: Boolean(raw.organizer?.self),
|
|
239
|
+
},
|
|
240
|
+
htmlLink: raw.htmlLink ?? '',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function parseEvents(items, ctx) {
|
|
248
|
+
const out = [];
|
|
249
|
+
if (!Array.isArray(items))
|
|
250
|
+
return out;
|
|
251
|
+
for (const item of items) {
|
|
252
|
+
const e = parseEvent(item, ctx);
|
|
253
|
+
if (e)
|
|
254
|
+
out.push(e);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CalendarInfo } from './types.js';
|
|
2
|
+
export declare class CalendarFetchError extends Error {
|
|
3
|
+
kind: 'network' | 'auth' | 'scope' | 'gone' | 'http';
|
|
4
|
+
status: number;
|
|
5
|
+
constructor(kind: 'network' | 'auth' | 'scope' | 'gone' | 'http', status?: number);
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchCalendarList(token: string): Promise<CalendarInfo[]>;
|
|
8
|
+
export interface EventsPage {
|
|
9
|
+
items: any[];
|
|
10
|
+
nextSyncToken?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Fetches all pages of events. With syncToken, no time bounds are sent (the API forbids them). */
|
|
13
|
+
export declare function fetchEvents(token: string, calendarId: string, opts: {
|
|
14
|
+
syncToken?: string;
|
|
15
|
+
timeMin?: string;
|
|
16
|
+
timeMax?: string;
|
|
17
|
+
}): Promise<EventsPage>;
|