public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
Commit
Brand app as Slot and sanitize calendar errors
7bda237
index.html | 4 ++--
package-lock.json | 4 ++--
package.json | 2 +-
scripts/setup.mjs | 4 ++--
src/App.tsx | 17 ++++++++++++-----
worker/google.ts | 31 +++++++++++++++++++++++++++----
worker/index.ts | 30 +++++++++++++++++++++++-------
worker/settings.ts | 4 ++--
8 files changed, 71 insertions(+), 25 deletions(-)
Diff
diff --git a/index.html b/index.html
index 227aade..d86ded2 100644
--- a/index.html
+++ b/index.html
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0c0f" />
- <meta name="description" content="Book a meeting." />
- <title>Meet</title>
+ <meta name="description" content="Book a slot." />
+ <title>Slot</title>
<script>
try {
document.documentElement.dataset.theme = localStorage.getItem('meet-theme') || 'true-black';
diff --git a/package-lock.json b/package-lock.json
index 5f21789..739389e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "meet-worker",
+ "name": "slot",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "meet-worker",
+ "name": "slot",
"version": "0.1.1",
"dependencies": {
"lucide-react": "1.26.0",
diff --git a/package.json b/package.json
index 7421140..014a6d6 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "meet-worker",
+ "name": "slot",
"version": "0.1.1",
"private": true,
"type": "module",
diff --git a/scripts/setup.mjs b/scripts/setup.mjs
index 9140789..95d2c41 100644
--- a/scripts/setup.mjs
+++ b/scripts/setup.mjs
@@ -25,7 +25,7 @@ function fail(message) {
function ensureProjectRoot() {
if (!fs.existsSync(workerConfigPath)) {
- fail('Run npm run setup from the Meet project root.');
+ fail('Run npm run setup from the Slot project root.');
}
if (!fs.existsSync(wranglerCli) || !fs.existsSync(viteCli)) {
fail('Dependencies are missing. Run npm install, then npm run setup again.');
@@ -227,7 +227,7 @@ function assertSecrets(existingSecrets) {
ensureProjectRoot();
-console.log('\nMeet setup\n==========');
+console.log('\nSlot setup\n==========');
console.log('Cloudflare + Google Calendar. No GitHub Actions, no Pages project, no API token.\n');
if ((wrangler(['whoami'], { allowFailure: true, capture: true }).status ?? 1) !== 0) {
diff --git a/src/App.tsx b/src/App.tsx
index d379871..c829779 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -180,9 +180,9 @@ function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
function Logo() {
return (
- <a className="brand" href="/" aria-label="Meet home">
- <span className="brand-mark">M</span>
- <span>Meet</span>
+ <a className="brand" href="/" aria-label="Slot home">
+ <span className="brand-mark">S</span>
+ <span>Slot</span>
</a>
);
}
@@ -198,6 +198,7 @@ function PublicBooking() {
const [loadingSlots, setLoadingSlots] = useState(false);
const [booking, setBooking] = useState(false);
const [error, setError] = useState('');
+ const [availabilityNotice, setAvailabilityNotice] = useState('');
const [confirmation, setConfirmation] = useState<{ start: string; duration: number; meetUrl?: string | null } | null>(null);
useEffect(() => {
@@ -221,12 +222,16 @@ function PublicBooking() {
setDuration(null);
setSlots([]);
setError('');
+ setAvailabilityNotice('');
setLoadingSlots(true);
try {
- const data = await api<{ slots: Slot[] }>(`/api/availability?date=${encodeURIComponent(key)}`);
+ const data = await api<{ slots: Slot[]; calendarError?: string }>(`/api/availability?date=${encodeURIComponent(key)}`);
setSlots(data.slots);
+ setAvailabilityNotice(data.calendarError ?? '');
} catch (err) {
- setError(err instanceof Error ? err.message : 'Could not load availability');
+ const message = err instanceof Error ? err.message : 'Could not load availability';
+ if (message.startsWith('Calendar setup needs attention')) setAvailabilityNotice(message);
+ else setError(message);
} finally {
setLoadingSlots(false);
}
@@ -371,6 +376,8 @@ function PublicBooking() {
{loadingSlots ? (
<div className="loading-row"><Loader2 className="spin" /> Checking Google Calendar…</div>
+ ) : availabilityNotice ? (
+ <div className="no-slots">{availabilityNotice}</div>
) : slots.length === 0 ? (
<div className="no-slots">Nothing open on this day.</div>
) : (
diff --git a/worker/google.ts b/worker/google.ts
index f04e501..3ed5b16 100644
--- a/worker/google.ts
+++ b/worker/google.ts
@@ -34,6 +34,26 @@ interface GoogleEventResponse {
};
}
+export class GoogleCalendarError extends Error {
+ public readonly publicMessage: string;
+ public readonly status: number;
+
+ constructor(operation: string, status: number, details: string) {
+ super(`${operation} failed (${status})`);
+ this.name = 'GoogleCalendarError';
+ this.status = status;
+ this.publicMessage = status === 403
+ ? 'Calendar setup needs attention. The owner needs to enable the Google Calendar API, then reconnect Google Calendar.'
+ : 'Calendar setup needs attention. The owner needs to reconnect Google Calendar or check the calendar settings.';
+ console.warn(`${operation} failed (${status}): ${details.slice(0, 1000)}`);
+ }
+}
+
+async function throwGoogleCalendarError(response: Response, operation: string): Promise<never> {
+ const details = await response.text().catch(() => '');
+ throw new GoogleCalendarError(operation, response.status, details);
+}
+
function redirectUri(env: Env): string {
return `${env.APP_URL.replace(/\/$/, '')}/auth/google/callback`;
}
@@ -186,8 +206,7 @@ export async function queryBusy(
});
if (!response.ok) {
- const message = await response.text();
- throw new Error(`Google FreeBusy failed (${response.status}): ${message.slice(0, 300)}`);
+ await throwGoogleCalendarError(response, 'Google FreeBusy');
}
const data = (await response.json()) as {
@@ -257,9 +276,13 @@ export async function createCalendarEvent(
},
);
+ if (!response.ok) {
+ await throwGoogleCalendarError(response, 'Google event creation');
+ }
+
const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
- if (!response.ok || !data.id) {
- throw new Error(data.error?.message || `Google event creation failed (${response.status})`);
+ if (!data.id) {
+ throw new GoogleCalendarError('Google event creation', 502, data.error?.message ?? 'Missing event id');
}
const meetUrl =
diff --git a/worker/index.ts b/worker/index.ts
index a6c8feb..b5c4363 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -6,6 +6,7 @@ import {
verifySession,
} from './crypto';
import {
+ GoogleCalendarError,
completeGoogleOAuth,
createCalendarEvent,
createGoogleAuthUrl,
@@ -43,6 +44,7 @@ function redirect(location: string, headers: HeadersInit = {}): Response {
}
function errorMessage(error: unknown): string {
+ if (error instanceof GoogleCalendarError) return error.publicMessage;
return error instanceof Error ? error.message : 'Unexpected error';
}
@@ -370,13 +372,27 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
if (request.method === 'GET' && url.pathname === '/api/availability') {
const date = url.searchParams.get('date') ?? '';
- const result = await computeAvailability(env, date);
- return json({
- date: result.date,
- timezone: result.settings.timezone,
- connected: result.connected,
- slots: result.slots,
- });
+ try {
+ const result = await computeAvailability(env, date);
+ return json({
+ date: result.date,
+ timezone: result.settings.timezone,
+ connected: result.connected,
+ slots: result.slots,
+ });
+ } catch (error) {
+ if (error instanceof GoogleCalendarError) {
+ const settings = await getSettings(env);
+ return json({
+ date,
+ timezone: settings.timezone,
+ connected: true,
+ slots: [],
+ calendarError: error.publicMessage,
+ });
+ }
+ throw error;
+ }
}
if (request.method === 'POST' && url.pathname === '/api/book') {
diff --git a/worker/settings.ts b/worker/settings.ts
index e381af3..b88eb3a 100644
--- a/worker/settings.ts
+++ b/worker/settings.ts
@@ -1,8 +1,8 @@
import type { AppSettings, Env } from './types';
export const DEFAULT_SETTINGS: AppSettings = {
- title: 'Book time with Alex',
- subtitle: 'Pick a day, choose a time, and we are set.',
+ title: 'Book a slot',
+ subtitle: 'Pick a day, choose a time, and you are set.',
timezone: 'Europe/Stockholm',
bookingWindowDays: 365,
minimumNoticeMinutes: 120,