NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
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
Alex Nord <[email protected]> 3 days ago
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,