@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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const BASE = 'https://www.googleapis.com/calendar/v3';
|
|
2
|
+
export class CalendarFetchError extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
status;
|
|
5
|
+
constructor(kind, status = 0) {
|
|
6
|
+
super(`calendar fetch failed: ${kind} ${status}`);
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function request(url, token) {
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new CalendarFetchError('network');
|
|
18
|
+
}
|
|
19
|
+
if (res.ok) {
|
|
20
|
+
try {
|
|
21
|
+
return await res.json();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new CalendarFetchError('http', res.status);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (res.status === 410)
|
|
28
|
+
throw new CalendarFetchError('gone', 410);
|
|
29
|
+
if (res.status === 401)
|
|
30
|
+
throw new CalendarFetchError('auth', 401);
|
|
31
|
+
if (res.status === 403) {
|
|
32
|
+
let body = null;
|
|
33
|
+
try {
|
|
34
|
+
body = await res.json();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
const insufficient = JSON.stringify(body ?? {}).includes('insufficientPermissions');
|
|
40
|
+
throw new CalendarFetchError(insufficient ? 'scope' : 'http', 403);
|
|
41
|
+
}
|
|
42
|
+
throw new CalendarFetchError('http', res.status);
|
|
43
|
+
}
|
|
44
|
+
export async function fetchCalendarList(token) {
|
|
45
|
+
const out = [];
|
|
46
|
+
let pageToken;
|
|
47
|
+
do {
|
|
48
|
+
const u = new URL(`${BASE}/users/me/calendarList`);
|
|
49
|
+
if (pageToken)
|
|
50
|
+
u.searchParams.set('pageToken', pageToken);
|
|
51
|
+
const body = await request(u.toString(), token);
|
|
52
|
+
for (const c of body.items ?? []) {
|
|
53
|
+
out.push({
|
|
54
|
+
id: c.id,
|
|
55
|
+
summary: c.summaryOverride ?? c.summary ?? c.id,
|
|
56
|
+
backgroundColor: c.backgroundColor,
|
|
57
|
+
foregroundColor: c.foregroundColor,
|
|
58
|
+
primary: Boolean(c.primary),
|
|
59
|
+
accessRole: c.accessRole ?? '',
|
|
60
|
+
selected: c.selected,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
pageToken = body.nextPageToken;
|
|
64
|
+
} while (pageToken);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const noMillis = (iso) => iso.replace(/\.\d{3}Z$/, 'Z');
|
|
68
|
+
/** Fetches all pages of events. With syncToken, no time bounds are sent (the API forbids them). */
|
|
69
|
+
export async function fetchEvents(token, calendarId, opts) {
|
|
70
|
+
const items = [];
|
|
71
|
+
let pageToken;
|
|
72
|
+
let nextSyncToken;
|
|
73
|
+
do {
|
|
74
|
+
const u = new URL(`${BASE}/calendars/${encodeURIComponent(calendarId)}/events`);
|
|
75
|
+
u.searchParams.set('maxResults', '250');
|
|
76
|
+
if (opts.syncToken) {
|
|
77
|
+
u.searchParams.set('syncToken', opts.syncToken);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
u.searchParams.set('singleEvents', 'true');
|
|
81
|
+
if (opts.timeMin)
|
|
82
|
+
u.searchParams.set('timeMin', noMillis(opts.timeMin));
|
|
83
|
+
if (opts.timeMax)
|
|
84
|
+
u.searchParams.set('timeMax', noMillis(opts.timeMax));
|
|
85
|
+
}
|
|
86
|
+
if (pageToken)
|
|
87
|
+
u.searchParams.set('pageToken', pageToken);
|
|
88
|
+
const body = await request(u.toString(), token);
|
|
89
|
+
items.push(...(body.items ?? []));
|
|
90
|
+
pageToken = body.nextPageToken;
|
|
91
|
+
nextSyncToken = body.nextSyncToken ?? nextSyncToken;
|
|
92
|
+
} while (pageToken);
|
|
93
|
+
return { items, nextSyncToken };
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CalendarEvent, CalendarInfo } from './types.js';
|
|
2
|
+
/** Stored selection filtered to existing calendars; the primary calendar is always included. */
|
|
3
|
+
export declare function reconcileSelection(stored: string[] | null, calendars: CalendarInfo[]): string[];
|
|
4
|
+
/** Applies an incremental page of raw items to one calendar's events (cancelled items remove). */
|
|
5
|
+
export declare function applyChanges(existing: CalendarEvent[], items: any[], ctx: {
|
|
6
|
+
sourceId: string;
|
|
7
|
+
calendarId: string;
|
|
8
|
+
}): CalendarEvent[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { reconcileSelectedCalendarIds } from './events.js';
|
|
2
|
+
import { parseEvent } from './events.js';
|
|
3
|
+
/** Stored selection filtered to existing calendars; the primary calendar is always included. */
|
|
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;
|
|
10
|
+
}
|
|
11
|
+
/** Applies an incremental page of raw items to one calendar's events (cancelled items remove). */
|
|
12
|
+
export function applyChanges(existing, items, ctx) {
|
|
13
|
+
const map = new Map(existing.map((e) => [e.id, e]));
|
|
14
|
+
for (const raw of items) {
|
|
15
|
+
if (!raw || typeof raw.id !== 'string')
|
|
16
|
+
continue;
|
|
17
|
+
const parsed = parseEvent(raw, ctx);
|
|
18
|
+
if (parsed)
|
|
19
|
+
map.set(parsed.id, parsed);
|
|
20
|
+
else
|
|
21
|
+
map.delete(raw.id);
|
|
22
|
+
}
|
|
23
|
+
return [...map.values()];
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CalendarEvent } from './types.js';
|
|
2
|
+
export interface SyncState {
|
|
3
|
+
/** Per-calendar incremental sync tokens. */
|
|
4
|
+
tokens: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
/** Persisted per-source row. `events` is an encrypted JSON string when written by the controller. */
|
|
7
|
+
export interface StorageRow {
|
|
8
|
+
events: CalendarEvent[] | string | unknown[];
|
|
9
|
+
syncState: SyncState | string | null | unknown;
|
|
10
|
+
selectedIds: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface StorageAdapter {
|
|
13
|
+
get(sourceId: string): Promise<StorageRow | null>;
|
|
14
|
+
put(sourceId: string, row: StorageRow): Promise<void>;
|
|
15
|
+
delete(sourceId: string): Promise<void>;
|
|
16
|
+
clear(sourceId: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export interface CryptoAdapter {
|
|
19
|
+
encrypt(plaintext: string): Promise<string>;
|
|
20
|
+
decrypt(ciphertext: string): Promise<string>;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CalendarEvent } from './types.js';
|
|
2
|
+
export interface CalendarSyncResult {
|
|
3
|
+
events: CalendarEvent[];
|
|
4
|
+
syncToken?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Syncs one calendar: incremental when a token exists, full (windowed) otherwise or on 410. */
|
|
7
|
+
export declare function syncCalendar(args: {
|
|
8
|
+
token: string;
|
|
9
|
+
sourceId: string;
|
|
10
|
+
calendarId: string;
|
|
11
|
+
existing: CalendarEvent[];
|
|
12
|
+
syncToken?: string;
|
|
13
|
+
now: Date;
|
|
14
|
+
}): Promise<CalendarSyncResult>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CalendarFetchError, fetchEvents } from './fetch.js';
|
|
2
|
+
import { getRollingWindow, parseEvents } from './events.js';
|
|
3
|
+
import { applyChanges } from './reconcile.js';
|
|
4
|
+
/** Syncs one calendar: incremental when a token exists, full (windowed) otherwise or on 410. */
|
|
5
|
+
export async function syncCalendar(args) {
|
|
6
|
+
const { token, sourceId, calendarId, existing, syncToken, now } = args;
|
|
7
|
+
const ctx = { sourceId, calendarId };
|
|
8
|
+
const full = async () => {
|
|
9
|
+
const w = getRollingWindow(now);
|
|
10
|
+
const page = await fetchEvents(token, calendarId, { timeMin: w.start, timeMax: w.end });
|
|
11
|
+
return { events: parseEvents(page.items, ctx), syncToken: page.nextSyncToken };
|
|
12
|
+
};
|
|
13
|
+
if (!syncToken)
|
|
14
|
+
return full();
|
|
15
|
+
try {
|
|
16
|
+
const page = await fetchEvents(token, calendarId, { syncToken });
|
|
17
|
+
return {
|
|
18
|
+
events: applyChanges(existing, page.items, ctx),
|
|
19
|
+
syncToken: page.nextSyncToken ?? syncToken,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (e instanceof CalendarFetchError && e.kind === 'gone')
|
|
24
|
+
return full();
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A connected account that calendars are read from. */
|
|
2
|
+
export interface Source {
|
|
3
|
+
id: string;
|
|
4
|
+
email: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
export type RsvpStatus = 'accepted' | 'tentative' | 'needsAction' | 'declined';
|
|
8
|
+
export interface CalendarEventDateTime {
|
|
9
|
+
dateTime?: string;
|
|
10
|
+
date?: string;
|
|
11
|
+
timeZone?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CalendarEvent {
|
|
14
|
+
id: string;
|
|
15
|
+
/** ID of the Source (account) this event came from. */
|
|
16
|
+
sourceId: string;
|
|
17
|
+
/** ID of the calendar this event belongs to. */
|
|
18
|
+
calendarId?: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
start: CalendarEventDateTime;
|
|
21
|
+
end: CalendarEventDateTime;
|
|
22
|
+
/** True iff `start.date` is present with no `start.dateTime`. */
|
|
23
|
+
isAllDay: boolean;
|
|
24
|
+
selfResponseStatus: RsvpStatus;
|
|
25
|
+
joinUrl: string | null;
|
|
26
|
+
organizer: {
|
|
27
|
+
displayName?: string;
|
|
28
|
+
email?: string;
|
|
29
|
+
self: boolean;
|
|
30
|
+
};
|
|
31
|
+
htmlLink: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CalendarInfo {
|
|
34
|
+
id: string;
|
|
35
|
+
summary: string;
|
|
36
|
+
backgroundColor?: string;
|
|
37
|
+
foregroundColor?: string;
|
|
38
|
+
primary: boolean;
|
|
39
|
+
accessRole: string;
|
|
40
|
+
selected?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface ParseContext {
|
|
43
|
+
sourceId: string;
|
|
44
|
+
calendarId: string;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './core/types.js';
|
|
2
|
+
export * from './core/events.js';
|
|
3
|
+
export * from './core/storage.js';
|
|
4
|
+
export * from './core/fetch.js';
|
|
5
|
+
export * from './core/sync.js';
|
|
6
|
+
export * from './core/reconcile.js';
|
|
7
|
+
export * from './core/controller.js';
|
|
8
|
+
export * from './react/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './core/types.js';
|
|
2
|
+
export * from './core/events.js';
|
|
3
|
+
export * from './core/storage.js';
|
|
4
|
+
export * from './core/fetch.js';
|
|
5
|
+
export * from './core/sync.js';
|
|
6
|
+
export * from './core/reconcile.js';
|
|
7
|
+
export * from './core/controller.js';
|
|
8
|
+
export * from './react/index.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type JSX, type ReactNode } from 'react';
|
|
2
|
+
import type { CalendarEvent, RsvpStatus, Source } from '../core/types.js';
|
|
3
|
+
import type { CalendarController } from '../core/controller.js';
|
|
4
|
+
export type RsvpSelection = Set<RsvpStatus>;
|
|
5
|
+
export interface CalendarPaneProps {
|
|
6
|
+
sources: Source[];
|
|
7
|
+
controller: CalendarController;
|
|
8
|
+
selectedRsvpStatuses: RsvpSelection;
|
|
9
|
+
onRsvpStatusesChange: (next: RsvpSelection) => void;
|
|
10
|
+
renderEventAction?: (event: CalendarEvent) => ReactNode;
|
|
11
|
+
emptyState?: ReactNode;
|
|
12
|
+
sourceNotice?: (source: Source) => ReactNode;
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
/** Window name used for the event popup. */
|
|
15
|
+
popupName?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function CalendarPane(props: CalendarPaneProps): JSX.Element;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { filterByRsvp, groupByDay, getOrganizerLabel, formatDayHeader, isEventCurrent, findNextUpcomingEvent, getCalendarEventKey, DEFAULT_RSVP_STATUSES, } from '../core/events.js';
|
|
4
|
+
import { CalendarSelector } from './CalendarSelector.js';
|
|
5
|
+
function useWindowWidth() {
|
|
6
|
+
const [width, setWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const handler = () => setWidth(window.innerWidth);
|
|
9
|
+
window.addEventListener('resize', handler);
|
|
10
|
+
return () => window.removeEventListener('resize', handler);
|
|
11
|
+
}, []);
|
|
12
|
+
return width;
|
|
13
|
+
}
|
|
14
|
+
const SOURCE_COLORS = ['#4285f4', '#ea4335', '#34a853', '#fbbc04', '#a142f4', '#00acc1', '#ff7043', '#8d6e63'];
|
|
15
|
+
const RSVP_CHIPS = [
|
|
16
|
+
{
|
|
17
|
+
status: 'accepted',
|
|
18
|
+
label: 'Accepted',
|
|
19
|
+
icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M3 7.7L6 10.7L12 4.3", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
status: 'tentative',
|
|
23
|
+
label: 'Maybe',
|
|
24
|
+
icon: (_jsxs("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: [_jsx("path", { d: "M5.2 5.6C5.2 4.2 6.2 3.2 7.5 3.2C8.8 3.2 9.8 4.2 9.8 5.4C9.8 6.8 7.6 6.9 7.6 8.8", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }), _jsx("circle", { cx: "7.6", cy: "11.2", r: "0.9", fill: "currentColor" })] })),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
status: 'needsAction',
|
|
28
|
+
label: 'Unaccepted',
|
|
29
|
+
icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("circle", { cx: "7.5", cy: "7.5", r: "4.3", stroke: "currentColor", strokeWidth: "1.4" }) })),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
status: 'declined',
|
|
33
|
+
label: 'Declined',
|
|
34
|
+
icon: (_jsx("svg", { width: "15", height: "15", viewBox: "0 0 15 15", fill: "none", children: _jsx("path", { d: "M4 4L11 11M11 4L4 11", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round" }) })),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
const DEFAULT_POPUP_NAME = 'notesdiary-cal-event';
|
|
38
|
+
const POPUP_W = 560;
|
|
39
|
+
const POPUP_H = 760;
|
|
40
|
+
function openEventPopup(url, popupRef, popupName) {
|
|
41
|
+
const existingPopup = popupRef.current;
|
|
42
|
+
if (existingPopup && !existingPopup.closed) {
|
|
43
|
+
try {
|
|
44
|
+
existingPopup.location = url;
|
|
45
|
+
existingPopup.focus();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
popupRef.current = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const left = Math.max(0, Math.round((window.screenX ?? 0) + (window.outerWidth - POPUP_W) / 2));
|
|
53
|
+
const top = Math.max(0, Math.round((window.screenY ?? 0) + (window.outerHeight - POPUP_H) / 2));
|
|
54
|
+
const popup = window.open(url, popupName, `popup,width=${POPUP_W},height=${POPUP_H},left=${left},top=${top}`);
|
|
55
|
+
if (!popup) {
|
|
56
|
+
window.open(url, '_blank');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
popupRef.current = popup;
|
|
60
|
+
try {
|
|
61
|
+
popup.opener = null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
popup.focus();
|
|
67
|
+
}
|
|
68
|
+
function toIsoDate(d) {
|
|
69
|
+
return d.toISOString().slice(0, 10);
|
|
70
|
+
}
|
|
71
|
+
function formatTimeOfDay(dateTime) {
|
|
72
|
+
const d = new Date(dateTime);
|
|
73
|
+
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
74
|
+
}
|
|
75
|
+
function formatTimeRange(event) {
|
|
76
|
+
const startDateTime = event.start.dateTime;
|
|
77
|
+
const endDateTime = event.end.dateTime;
|
|
78
|
+
if (!startDateTime)
|
|
79
|
+
return '';
|
|
80
|
+
const start = formatTimeOfDay(startDateTime);
|
|
81
|
+
if (!endDateTime)
|
|
82
|
+
return start;
|
|
83
|
+
return `${start} – ${formatTimeOfDay(endDateTime)}`;
|
|
84
|
+
}
|
|
85
|
+
function usableUrl(url) {
|
|
86
|
+
if (typeof url !== 'string')
|
|
87
|
+
return null;
|
|
88
|
+
const trimmed = url.trim();
|
|
89
|
+
return trimmed === '' ? null : trimmed;
|
|
90
|
+
}
|
|
91
|
+
export function CalendarPane(props) {
|
|
92
|
+
const isDesktop = useWindowWidth() >= 960;
|
|
93
|
+
const { sources, controller, selectedRsvpStatuses } = props;
|
|
94
|
+
const popupName = props.popupName ?? DEFAULT_POPUP_NAME;
|
|
95
|
+
const [version, setVersion] = useState(0);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const unsubscribe = controller.subscribe(() => setVersion((v) => v + 1));
|
|
98
|
+
setVersion((v) => v + 1);
|
|
99
|
+
return unsubscribe;
|
|
100
|
+
}, [controller]);
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
const events = useMemo(() => controller.getEvents(), [controller, version]);
|
|
103
|
+
const statuses = controller.getSourceStatus();
|
|
104
|
+
const connected = sources.length > 0;
|
|
105
|
+
const multi = sources.length >= 2;
|
|
106
|
+
const needsScopeSources = sources.filter((s) => statuses[s.id] === 'needs-scope');
|
|
107
|
+
const needsReauth = connected && needsScopeSources.length === sources.length;
|
|
108
|
+
const sourceColor = (id) => SOURCE_COLORS[Math.max(0, sources.findIndex((s) => s.id === id)) % SOURCE_COLORS.length];
|
|
109
|
+
const sourceEmail = (id) => sources.find((s) => s.id === id)?.email ?? '';
|
|
110
|
+
const todayIso = toIsoDate(new Date());
|
|
111
|
+
const dayHeaderRefs = useRef(new Map());
|
|
112
|
+
const timedEventRefs = useRef(new Map());
|
|
113
|
+
const hasScrolledForEventsRef = useRef(false);
|
|
114
|
+
const paneRef = useRef(null);
|
|
115
|
+
const chipsRef = useRef(null);
|
|
116
|
+
const popupRef = useRef(null);
|
|
117
|
+
const [now, setNow] = useState(() => new Date());
|
|
118
|
+
const filteredEvents = useMemo(() => filterByRsvp(events, selectedRsvpStatuses), [events, selectedRsvpStatuses]);
|
|
119
|
+
const calendarsByKey = useMemo(() => {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
for (const s of sources)
|
|
122
|
+
for (const c of controller.getCalendars(s.id))
|
|
123
|
+
map.set(`${s.id}\u0000${c.id}`, c);
|
|
124
|
+
return map;
|
|
125
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
126
|
+
}, [sources, controller, version]);
|
|
127
|
+
const currentEventKeys = useMemo(() => {
|
|
128
|
+
const ids = new Set();
|
|
129
|
+
for (const event of filteredEvents) {
|
|
130
|
+
if (isEventCurrent(event, now)) {
|
|
131
|
+
ids.add(getCalendarEventKey(event));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ids;
|
|
135
|
+
}, [filteredEvents, now]);
|
|
136
|
+
const nextUpcomingEvent = findNextUpcomingEvent(filteredEvents, now);
|
|
137
|
+
const selectedEventKey = currentEventKeys.values().next().value ??
|
|
138
|
+
(nextUpcomingEvent ? getCalendarEventKey(nextUpcomingEvent) : undefined);
|
|
139
|
+
function handleToggle(status) {
|
|
140
|
+
const next = new Set(selectedRsvpStatuses);
|
|
141
|
+
if (next.has(status)) {
|
|
142
|
+
next.delete(status);
|
|
143
|
+
if (next.size === 0) {
|
|
144
|
+
props.onRsvpStatusesChange(new Set(DEFAULT_RSVP_STATUSES));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
next.add(status);
|
|
150
|
+
}
|
|
151
|
+
props.onRsvpStatusesChange(next);
|
|
152
|
+
}
|
|
153
|
+
const dayGroups = groupByDay(filteredEvents, todayIso);
|
|
154
|
+
function scrollToToday(behavior = 'auto') {
|
|
155
|
+
const todayEl = dayHeaderRefs.current.get(todayIso);
|
|
156
|
+
if (todayEl && typeof todayEl.scrollIntoView === 'function') {
|
|
157
|
+
todayEl.scrollIntoView({ behavior, block: 'start' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function recenter(behavior) {
|
|
161
|
+
const el = selectedEventKey ? timedEventRefs.current.get(selectedEventKey) : undefined;
|
|
162
|
+
if (el && typeof el.scrollIntoView === 'function') {
|
|
163
|
+
el.scrollIntoView({ behavior, block: 'center' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
scrollToToday(behavior);
|
|
167
|
+
}
|
|
168
|
+
const recenterRef = useRef(recenter);
|
|
169
|
+
recenterRef.current = recenter;
|
|
170
|
+
const idleRecenteredRef = useRef(false);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
let idleTimeoutId;
|
|
173
|
+
function fireIdle() {
|
|
174
|
+
recenterRef.current('smooth');
|
|
175
|
+
idleRecenteredRef.current = true;
|
|
176
|
+
}
|
|
177
|
+
function onActivity() {
|
|
178
|
+
if (idleRecenteredRef.current) {
|
|
179
|
+
idleRecenteredRef.current = false;
|
|
180
|
+
}
|
|
181
|
+
clearTimeout(idleTimeoutId);
|
|
182
|
+
idleTimeoutId = setTimeout(fireIdle, 30000);
|
|
183
|
+
}
|
|
184
|
+
window.addEventListener('mousemove', onActivity);
|
|
185
|
+
window.addEventListener('keydown', onActivity);
|
|
186
|
+
window.addEventListener('click', onActivity);
|
|
187
|
+
window.addEventListener('scroll', onActivity, { passive: true });
|
|
188
|
+
window.addEventListener('touchstart', onActivity, { passive: true });
|
|
189
|
+
onActivity();
|
|
190
|
+
return () => {
|
|
191
|
+
window.removeEventListener('mousemove', onActivity);
|
|
192
|
+
window.removeEventListener('keydown', onActivity);
|
|
193
|
+
window.removeEventListener('click', onActivity);
|
|
194
|
+
window.removeEventListener('scroll', onActivity);
|
|
195
|
+
window.removeEventListener('touchstart', onActivity);
|
|
196
|
+
clearTimeout(idleTimeoutId);
|
|
197
|
+
};
|
|
198
|
+
}, []);
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (events.length === 0) {
|
|
201
|
+
hasScrolledForEventsRef.current = false;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (hasScrolledForEventsRef.current)
|
|
205
|
+
return;
|
|
206
|
+
recenterRef.current('auto');
|
|
207
|
+
hasScrolledForEventsRef.current = true;
|
|
208
|
+
}, [events, todayIso]);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
let timeoutId;
|
|
211
|
+
function scheduleNext() {
|
|
212
|
+
const nowMs = Date.now();
|
|
213
|
+
let nextBoundaryMs = Infinity;
|
|
214
|
+
for (const event of filteredEvents) {
|
|
215
|
+
if (event.isAllDay)
|
|
216
|
+
continue;
|
|
217
|
+
const startMs = event.start.dateTime ? new Date(event.start.dateTime).getTime() : NaN;
|
|
218
|
+
const endMs = event.end.dateTime ? new Date(event.end.dateTime).getTime() : NaN;
|
|
219
|
+
if (!Number.isNaN(startMs) && startMs > nowMs && startMs < nextBoundaryMs) {
|
|
220
|
+
nextBoundaryMs = startMs;
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isNaN(endMs) && endMs > nowMs && endMs < nextBoundaryMs) {
|
|
223
|
+
nextBoundaryMs = endMs;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (nextBoundaryMs !== Infinity) {
|
|
227
|
+
timeoutId = setTimeout(() => {
|
|
228
|
+
setNow(new Date());
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
if (idleRecenteredRef.current) {
|
|
231
|
+
recenterRef.current('smooth');
|
|
232
|
+
}
|
|
233
|
+
}, 0);
|
|
234
|
+
scheduleNext();
|
|
235
|
+
}, nextBoundaryMs - nowMs);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
setNow(new Date());
|
|
239
|
+
scheduleNext();
|
|
240
|
+
return () => {
|
|
241
|
+
if (timeoutId !== undefined)
|
|
242
|
+
clearTimeout(timeoutId);
|
|
243
|
+
};
|
|
244
|
+
}, [filteredEvents]);
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
const chipsEl = chipsRef.current;
|
|
247
|
+
const paneEl = paneRef.current;
|
|
248
|
+
if (!chipsEl || !paneEl)
|
|
249
|
+
return;
|
|
250
|
+
const observer = new ResizeObserver((entries) => {
|
|
251
|
+
const entry = entries[0];
|
|
252
|
+
if (!entry)
|
|
253
|
+
return;
|
|
254
|
+
// contentRect excludes padding; the sticky day-header offset needs the
|
|
255
|
+
// chip row's full border-box height (chips have vertical padding).
|
|
256
|
+
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
|
|
257
|
+
paneEl.style.setProperty('--chip-row-height', `${height}px`);
|
|
258
|
+
});
|
|
259
|
+
observer.observe(chipsEl);
|
|
260
|
+
return () => observer.disconnect();
|
|
261
|
+
}, [connected, needsReauth]);
|
|
262
|
+
if (!connected) {
|
|
263
|
+
return (_jsx("div", { className: "calendar-pane", children: _jsx("div", { className: "calendar-pane-prompt", children: props.emptyState }) }));
|
|
264
|
+
}
|
|
265
|
+
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))) }) }));
|
|
267
|
+
}
|
|
268
|
+
const sourcesWithEvents = new Set(filteredEvents.map((e) => e.sourceId));
|
|
269
|
+
const hasErrorWithoutEvents = sources.some((s) => statuses[s.id] === 'error' && !sourcesWithEvents.has(s.id));
|
|
270
|
+
const calendarSources = sources.filter((s) => controller.getCalendars(s.id).length > 0);
|
|
271
|
+
const error = hasErrorWithoutEvents;
|
|
272
|
+
const loading = false;
|
|
273
|
+
const emptyStateText = 'No matching events in the next 30 days';
|
|
274
|
+
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) => {
|
|
276
|
+
if (el) {
|
|
277
|
+
dayHeaderRefs.current.set(group.dateKey, el);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
dayHeaderRefs.current.delete(group.dateKey);
|
|
281
|
+
}
|
|
282
|
+
}, children: formatDayHeader(group.dateKey, todayIso) }), group.events.map((event) => {
|
|
283
|
+
const eventKey = getCalendarEventKey(event);
|
|
284
|
+
const calendar = calendarsByKey.get(`${event.sourceId}\u0000${event.calendarId}`);
|
|
285
|
+
const showCalendarLabel = calendar && !calendar.primary;
|
|
286
|
+
const organizerLabel = getOrganizerLabel(event);
|
|
287
|
+
const showOrganizerLabel = multi
|
|
288
|
+
? true
|
|
289
|
+
: organizerLabel && (!showCalendarLabel || organizerLabel !== calendar.summary);
|
|
290
|
+
const organizerText = multi
|
|
291
|
+
? organizerLabel
|
|
292
|
+
? `${organizerLabel} · ${sourceEmail(event.sourceId)}`
|
|
293
|
+
: sourceEmail(event.sourceId)
|
|
294
|
+
: organizerLabel;
|
|
295
|
+
const rsvpStatus = RSVP_CHIPS.some((chip) => chip.status === event.selfResponseStatus)
|
|
296
|
+
? event.selfResponseStatus
|
|
297
|
+
: 'needsAction';
|
|
298
|
+
if (event.isAllDay) {
|
|
299
|
+
return (_jsxs("div", { className: `calendar-event-allday calendar-event-row--rsvp-${rsvpStatus}`, children: [_jsxs("span", { className: "calendar-event-title-wrap", children: [_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));
|
|
300
|
+
}
|
|
301
|
+
const hasLink = usableUrl(event.htmlLink) !== null;
|
|
302
|
+
return (_jsxs("div", { className: `calendar-event-row${currentEventKeys.has(eventKey) ? ' calendar-event-row--current' : ''}${selectedEventKey === eventKey ? ' calendar-event-row--selected' : ''} calendar-event-row--rsvp-${rsvpStatus}`, ref: (el) => {
|
|
303
|
+
if (el) {
|
|
304
|
+
timedEventRefs.current.set(eventKey, el);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
timedEventRefs.current.delete(eventKey);
|
|
308
|
+
}
|
|
309
|
+
}, children: [_jsxs("div", { className: "calendar-event-header", children: [_jsx("span", { className: "calendar-event-time", children: formatTimeRange(event) }), event.joinUrl && (_jsx("a", { className: "calendar-event-join", href: event.joinUrl, target: "_blank", rel: "noopener noreferrer", onClick: (e) => e.stopPropagation(), children: "Join" }))] }), _jsxs("div", { className: "calendar-event-title-wrap", children: [hasLink ? (_jsx("a", { className: "calendar-event-title-link", href: event.htmlLink, target: "_blank", rel: "noopener noreferrer", onClick: (e) => {
|
|
310
|
+
if (isDesktop &&
|
|
311
|
+
e.button === 0 &&
|
|
312
|
+
!e.ctrlKey &&
|
|
313
|
+
!e.metaKey &&
|
|
314
|
+
!e.shiftKey &&
|
|
315
|
+
!e.altKey) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
openEventPopup(event.htmlLink, popupRef, popupName);
|
|
318
|
+
}
|
|
319
|
+
}, 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) => {
|
|
321
|
+
void controller.setSelectedIds(s.id, ids);
|
|
322
|
+
}, heading: multi ? s.email : undefined }, s.id))) }))] }));
|
|
323
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CalendarInfo } from '../core/types.js';
|
|
2
|
+
interface CalendarSelectorProps {
|
|
3
|
+
calendars: CalendarInfo[];
|
|
4
|
+
selectedIds: string[];
|
|
5
|
+
onChange: (selectedIds: string[]) => void;
|
|
6
|
+
/** Optional account heading (used when several sources are shown). */
|
|
7
|
+
heading?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function CalendarSelector({ calendars, selectedIds, onChange, heading }: CalendarSelectorProps): import("react").JSX.Element;
|
|
10
|
+
export {};
|