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

Add cancellation and availability blocks

5562973
Alex Nord <[email protected]> 3 days ago
.../0002_cancellation_and_availability_blocks.sql  |  31 +++
 src/App.tsx                                        | 252 +++++++++++++++++++-
 src/styles.css                                     | 167 +++++++++++++
 worker/google.ts                                   |  24 ++
 worker/index.ts                                    | 260 ++++++++++++++++++++-
 worker/types.ts                                    |  17 ++
 6 files changed, 739 insertions(+), 12 deletions(-)
 create mode 100644 migrations/0002_cancellation_and_availability_blocks.sql

Diff

diff --git a/migrations/0002_cancellation_and_availability_blocks.sql b/migrations/0002_cancellation_and_availability_blocks.sql
new file mode 100644
index 0000000..449503c
--- /dev/null
+++ b/migrations/0002_cancellation_and_availability_blocks.sql
@@ -0,0 +1,31 @@
+ALTER TABLE bookings ADD COLUMN cancelled_at TEXT;
+ALTER TABLE bookings ADD COLUMN updated_at TEXT;
+ALTER TABLE bookings ADD COLUMN calendar_provider TEXT NOT NULL DEFAULT 'google';
+ALTER TABLE bookings ADD COLUMN calendar_sync_status TEXT NOT NULL DEFAULT 'synced';
+ALTER TABLE bookings ADD COLUMN calendar_sync_error TEXT;
+ALTER TABLE bookings ADD COLUMN calendar_sync_updated_at TEXT;
+
+CREATE TABLE IF NOT EXISTS recurring_unavailable_periods (
+  id TEXT PRIMARY KEY,
+  weekdays TEXT NOT NULL,
+  start_time TEXT NOT NULL,
+  end_time TEXT NOT NULL,
+  label TEXT,
+  created_at TEXT NOT NULL DEFAULT (datetime('now')),
+  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS availability_blocks (
+  id TEXT PRIMARY KEY,
+  date TEXT NOT NULL,
+  start_time TEXT,
+  end_time TEXT,
+  all_day INTEGER NOT NULL DEFAULT 0,
+  label TEXT,
+  created_at TEXT NOT NULL DEFAULT (datetime('now')),
+  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_recurring_unavailable_periods_weekdays ON recurring_unavailable_periods(weekdays);
+CREATE INDEX IF NOT EXISTS idx_availability_blocks_date ON availability_blocks(date);
+CREATE INDEX IF NOT EXISTS idx_bookings_sync_status ON bookings(calendar_sync_status);
diff --git a/src/App.tsx b/src/App.tsx
index 437c6b5..7c92350 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -74,6 +74,29 @@ type BookingRow = {
   meeting_mode: MeetingMode;
   status: string;
   meet_url?: string;
+  calendar_provider?: string;
+  calendar_sync_status?: string;
+  calendar_sync_error?: string;
+  calendar_sync_updated_at?: string;
+  cancelled_at?: string;
+  created_at?: string;
+};
+
+type RecurringUnavailableRule = {
+  id: string;
+  weekdays: number[];
+  start: string;
+  end: string;
+  label?: string | null;
+};
+
+type AvailabilityBlock = {
+  id: string;
+  date: string;
+  start?: string | null;
+  end?: string | null;
+  allDay: boolean;
+  label?: string | null;
 };
 
 type AdminSession = { authenticated: boolean; email: string; connected: boolean };
@@ -700,12 +723,21 @@ function ConfirmationView({ title, confirmation, onBookAnother }: { title: strin
   );
 }
 
-function BookingRowItem({ booking, timezone }: { booking: BookingRow; timezone: string }) {
+function BookingRowItem({
+  booking,
+  timezone,
+  onOpen,
+}: {
+  booking: BookingRow;
+  timezone: string;
+  onOpen: (booking: BookingRow) => void;
+}) {
   const [copied, copy] = useCopy();
-  const isPast = Date.parse(booking.end_time) < Date.now();
+  const isCancelled = booking.status === 'cancelled';
+  const isPast = Date.parse(booking.end_time) < Date.now() || isCancelled;
   const Icon = modeMeta[booking.meeting_mode].icon;
   return (
-    <div className={`booking-list-row ${isPast ? 'past' : ''}`}>
+    <div className={`booking-list-row ${isPast ? 'past' : ''} ${isCancelled ? 'cancelled' : ''}`}>
       <div className="booking-avatar" aria-hidden="true">{booking.name.slice(0, 1).toUpperCase()}</div>
       <div className="booking-person">
         <strong>{booking.name}</strong>
@@ -720,7 +752,12 @@ function BookingRowItem({ booking, timezone }: { booking: BookingRow; timezone:
         <span>{booking.duration_minutes} min</span>
       </div>
       <div className="booking-actions">
-        <span className={`status-pill ${isPast ? 'past' : 'upcoming'}`}>{isPast ? 'Past' : 'Upcoming'}</span>
+        <span className={`status-pill ${isCancelled ? 'cancelled' : isPast ? 'past' : 'upcoming'}`}>
+          {isCancelled ? 'Cancelled' : isPast ? 'Past' : 'Upcoming'}
+        </span>
+        <button type="button" className="icon-button" aria-label="Open booking details" title="Open details" onClick={() => onOpen(booking)}>
+          <Inbox size={15} />
+        </button>
         {booking.meet_url && (
           <>
             <button
@@ -746,8 +783,15 @@ function Admin() {
   const [session, setSession] = useState<AdminSession | null>(null);
   const [settings, setSettings] = useState<AppSettings | null>(null);
   const [bookings, setBookings] = useState<BookingRow[]>([]);
+  const [recurringUnavailable, setRecurringUnavailable] = useState<RecurringUnavailableRule[]>([]);
+  const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
   const [tab, setTab] = useState<'settings' | 'bookings'>('bookings');
   const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
+  const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
+  const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
+  const [cancelling, setCancelling] = useState(false);
+  const [recurringForm, setRecurringForm] = useState({ weekdays: [1, 2, 3, 4, 5], start: '12:00', end: '13:00', label: 'Lunch' });
+  const [blockForm, setBlockForm] = useState({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
   const [theme, setTheme] = useState<ThemeId>(() => {
     const stored = localStorage.getItem('meet-theme') as ThemeId | null;
     return stored && themes.some((item) => item.id === stored) ? stored : 'true-black';
@@ -780,12 +824,25 @@ function Admin() {
     Promise.all([
       api<{ settings: AppSettings }>('/api/admin/settings'),
       api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
-    ]).then(([settingsData, bookingData]) => {
+      api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
+    ]).then(([settingsData, bookingData, availabilityData]) => {
       setSettings(settingsData.settings);
       setBookings(bookingData.bookings);
+      setRecurringUnavailable(availabilityData.recurring);
+      setAvailabilityBlocks(availabilityData.blocks);
     }).catch((err) => setError(err.message));
   }, [session]);
 
+  async function refreshAdminData() {
+    const [bookingData, availabilityData] = await Promise.all([
+      api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
+      api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
+    ]);
+    setBookings(bookingData.bookings);
+    setRecurringUnavailable(availabilityData.recurring);
+    setAvailabilityBlocks(availabilityData.blocks);
+  }
+
   async function save() {
     if (!settings) return;
     setSaving(true);
@@ -805,6 +862,63 @@ function Admin() {
     }
   }
 
+  async function addRecurringUnavailable() {
+    setError('');
+    try {
+      await api('/api/admin/availability-rules/recurring', {
+        method: 'POST',
+        body: JSON.stringify(recurringForm),
+      });
+      await refreshAdminData();
+      setNotice('Recurring unavailable time added.');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not add unavailable time');
+    }
+  }
+
+  async function addAvailabilityBlock() {
+    setError('');
+    try {
+      await api('/api/admin/availability-rules/blocks', {
+        method: 'POST',
+        body: JSON.stringify(blockForm),
+      });
+      await refreshAdminData();
+      setBlockForm({ date: '', start: '09:00', end: '12:00', allDay: false, label: '' });
+      setNotice('Blocked time added.');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not add blocked time');
+    }
+  }
+
+  async function deleteAvailabilityRule(kind: 'recurring' | 'block', id: string) {
+    setError('');
+    try {
+      await api(`/api/admin/availability-rules/${kind}/${id}`, { method: 'DELETE' });
+      await refreshAdminData();
+      setNotice('Availability rule removed.');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not remove availability rule');
+    }
+  }
+
+  async function confirmCancelBooking() {
+    if (!cancelTarget) return;
+    setCancelling(true);
+    setError('');
+    try {
+      await api(`/api/admin/bookings/${cancelTarget.id}/cancel`, { method: 'POST' });
+      setCancelTarget(null);
+      setSelectedBooking(null);
+      await refreshAdminData();
+      setNotice('Meeting cancelled. Google Calendar will notify the attendee.');
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not cancel meeting');
+    } finally {
+      setCancelling(false);
+    }
+  }
+
   if (session === null) return <div className="center-screen"><Loader2 className="spin" /> Loading admin…</div>;
 
   if (!session.authenticated) {
@@ -844,8 +958,8 @@ function Admin() {
 
   const activeSettings = settings;
   const now = Date.now();
-  const upcoming = bookings.filter((b) => Date.parse(b.end_time) >= now);
-  const past = bookings.filter((b) => Date.parse(b.end_time) < now);
+  const upcoming = bookings.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
+  const past = bookings.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
   const shown = bookingFilter === 'upcoming' ? upcoming : past;
 
   return (
@@ -927,6 +1041,66 @@ function Admin() {
                       );
                     })}
                   </div>
+                  <div className="availability-tools">
+                    <section className="availability-tool">
+                      <div className="card-heading"><h3>Recurring unavailable</h3><p>Private blocks such as lunch or focus time.</p></div>
+                      <div className="weekday-picker">
+                        {dayNames.map((name, day) => (
+                          <button
+                            type="button"
+                            key={name}
+                            className={recurringForm.weekdays.includes(day) ? 'active' : ''}
+                            onClick={() => setRecurringForm((current) => ({
+                              ...current,
+                              weekdays: current.weekdays.includes(day)
+                                ? current.weekdays.filter((value) => value !== day)
+                                : [...current.weekdays, day].sort((a, b) => a - b),
+                            }))}
+                          >
+                            {name.slice(0, 3)}
+                          </button>
+                        ))}
+                      </div>
+                      <div className="calendar-settings-row">
+                        <label>From<input type="time" value={recurringForm.start} onChange={(e) => setRecurringForm({ ...recurringForm, start: e.target.value })} /></label>
+                        <label>Until<input type="time" value={recurringForm.end} onChange={(e) => setRecurringForm({ ...recurringForm, end: e.target.value })} /></label>
+                      </div>
+                      <label>Private label<input value={recurringForm.label} onChange={(e) => setRecurringForm({ ...recurringForm, label: e.target.value })} placeholder="Lunch" /></label>
+                      <button className="secondary-button" type="button" onClick={addRecurringUnavailable}>Add unavailable period</button>
+                      <div className="rule-list">
+                        {recurringUnavailable.length === 0 ? <span>No recurring unavailable periods.</span> : recurringUnavailable.map((rule) => (
+                          <div className="rule-row" key={rule.id}>
+                            <strong>{rule.weekdays.map((day) => dayNames[day].slice(0, 3)).join(', ')}</strong>
+                            <span>{rule.start}-{rule.end}{rule.label ? ` · ${rule.label}` : ''}</span>
+                            <button type="button" onClick={() => deleteAvailabilityRule('recurring', rule.id)}>Remove</button>
+                          </div>
+                        ))}
+                      </div>
+                    </section>
+
+                    <section className="availability-tool">
+                      <div className="card-heading"><h3>One-off blocked time</h3><p>Private exceptions for a single date.</p></div>
+                      <label>Date<input type="date" value={blockForm.date} onChange={(e) => setBlockForm({ ...blockForm, date: e.target.value })} /></label>
+                      <label className="switch-row"><input type="checkbox" checked={blockForm.allDay} onChange={(e) => setBlockForm({ ...blockForm, allDay: e.target.checked })} /><span>All day</span></label>
+                      {!blockForm.allDay && (
+                        <div className="calendar-settings-row">
+                          <label>From<input type="time" value={blockForm.start} onChange={(e) => setBlockForm({ ...blockForm, start: e.target.value })} /></label>
+                          <label>Until<input type="time" value={blockForm.end} onChange={(e) => setBlockForm({ ...blockForm, end: e.target.value })} /></label>
+                        </div>
+                      )}
+                      <label>Private label<input value={blockForm.label} onChange={(e) => setBlockForm({ ...blockForm, label: e.target.value })} placeholder="Dentist" /></label>
+                      <button className="secondary-button" type="button" onClick={addAvailabilityBlock}>Block time</button>
+                      <div className="rule-list">
+                        {availabilityBlocks.length === 0 ? <span>No one-off blocks.</span> : availabilityBlocks.map((block) => (
+                          <div className="rule-row" key={block.id}>
+                            <strong>{block.date}</strong>
+                            <span>{block.allDay ? 'All day' : `${block.start}-${block.end}`}{block.label ? ` · ${block.label}` : ''}</span>
+                            <button type="button" onClick={() => deleteAvailabilityRule('block', block.id)}>Remove</button>
+                          </div>
+                        ))}
+                      </div>
+                    </section>
+                  </div>
                 </section>
 
                 <section className="settings-card">
@@ -1033,6 +1207,7 @@ function Admin() {
           ) : (
             <>
               <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
+              {notice && <div className="success-banner" role="status">{notice}</div>}
               {error && <div className="error-banner" role="alert">{error}</div>}
               <div className="booking-tabs" role="tablist" aria-label="Filter bookings">
                 <button
@@ -1061,14 +1236,75 @@ function Admin() {
                   </div>
                 ) : (
                   shown.map((booking) => (
-                    <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} />
+                    <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={setSelectedBooking} />
                   ))
                 )}
               </section>
+              {selectedBooking && (
+                <section className="booking-detail-card">
+                  <div className="card-heading">
+                    <div>
+                      <h2>{selectedBooking.name}</h2>
+                      <p>{selectedBooking.email}</p>
+                    </div>
+                    <button type="button" className="text-button" onClick={() => setSelectedBooking(null)}>Close</button>
+                  </div>
+                  <dl className="booking-detail-grid">
+                    <div><dt>Status</dt><dd>{selectedBooking.status}</dd></div>
+                    <div><dt>Date</dt><dd>{formatDateShort(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
+                    <div><dt>Start</dt><dd>{formatClock(selectedBooking.start_time, activeSettings.timezone)}</dd></div>
+                    <div><dt>End</dt><dd>{formatClock(selectedBooking.end_time, activeSettings.timezone)}</dd></div>
+                    <div><dt>Duration</dt><dd>{selectedBooking.duration_minutes} min</dd></div>
+                    <div><dt>Timezone</dt><dd>{activeSettings.timezone}</dd></div>
+                    <div><dt>Meeting</dt><dd>{modeMeta[selectedBooking.meeting_mode].label}</dd></div>
+                    <div><dt>Calendar</dt><dd>{selectedBooking.calendar_provider ?? 'google'}</dd></div>
+                    <div><dt>Sync</dt><dd>{selectedBooking.calendar_sync_status ?? 'synced'}</dd></div>
+                    {selectedBooking.created_at && <div><dt>Created</dt><dd>{formatInstant(`${selectedBooking.created_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
+                    {selectedBooking.cancelled_at && <div><dt>Cancelled</dt><dd>{formatInstant(`${selectedBooking.cancelled_at.replace(' ', 'T')}Z`, activeSettings.timezone)}</dd></div>}
+                  </dl>
+                  {selectedBooking.calendar_sync_status === 'failed' && selectedBooking.calendar_sync_error && (
+                    <div className="error-banner" role="alert">{selectedBooking.calendar_sync_error}</div>
+                  )}
+                  <div className="booking-detail-actions">
+                    {selectedBooking.meet_url && <a className="secondary-button" href={selectedBooking.meet_url} target="_blank" rel="noreferrer">Open meeting link</a>}
+                    <button className="secondary-button" type="button" disabled>Reschedule</button>
+                    <button className="secondary-button" type="button" disabled>Suggest new time</button>
+                    <button
+                      className="danger-button"
+                      type="button"
+                      disabled={selectedBooking.status !== 'confirmed' || Date.parse(selectedBooking.end_time) < Date.now()}
+                      onClick={() => setCancelTarget(selectedBooking)}
+                    >
+                      Cancel meeting
+                    </button>
+                  </div>
+                </section>
+              )}
             </>
           )}
         </section>
       </div>
+      {cancelTarget && (
+        <div className="modal-backdrop" role="presentation">
+          <section className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="cancel-title">
+            <p className="eyebrow">CANCEL MEETING</p>
+            <h2 id="cancel-title">Cancel meeting?</h2>
+            <div className="cancel-summary">
+              <strong>{cancelTarget.name}</strong>
+              <span>{formatDateShort(cancelTarget.start_time, activeSettings.timezone)}</span>
+              <span>{formatClock(cancelTarget.start_time, activeSettings.timezone)}-{formatClock(cancelTarget.end_time, activeSettings.timezone)}</span>
+              <span>{cancelTarget.duration_minutes} min</span>
+            </div>
+            <p className="muted">This will also cancel the Google Calendar event and send normal Calendar cancellation notifications.</p>
+            <div className="dialog-actions">
+              <button className="secondary-button" type="button" onClick={() => setCancelTarget(null)} disabled={cancelling}>Keep meeting</button>
+              <button className="danger-button" type="button" onClick={confirmCancelBooking} disabled={cancelling}>
+                {cancelling ? <><Loader2 className="spin" size={16} /> Cancelling...</> : 'Cancel meeting'}
+              </button>
+            </div>
+          </section>
+        </div>
+      )}
     </main>
   );
 }
diff --git a/src/styles.css b/src/styles.css
index 69a4516..6f6ee73 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1010,6 +1010,62 @@ input[aria-invalid="true"]:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(
 .time-range { display: flex; align-items: center; gap: 8px; color: var(--faint); font-size: 10px; }
 .time-range input { width: 108px; padding: 7px 8px; font-size: 11px; }
 .unavailable { color: var(--faint); font-size: 10px; }
+.availability-tools {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+  border-top: 1px solid var(--border);
+  padding-top: 18px;
+}
+.availability-tool {
+  display: grid;
+  gap: 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 16px;
+  background: color-mix(in srgb, var(--surface) 82%, transparent);
+}
+.availability-tool .card-heading { margin-bottom: 0; }
+.availability-tool h3 { margin: 0; font-size: 15px; letter-spacing: -0.02em; }
+.weekday-picker {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 6px;
+}
+.weekday-picker button {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-xs);
+  background: var(--surface);
+  color: var(--muted);
+  min-height: 34px;
+  cursor: pointer;
+  font-size: 10px;
+  font-weight: 760;
+}
+.weekday-picker button.active { color: var(--selected-text); background: var(--selected-bg); border-color: var(--selected-bg); }
+.rule-list {
+  display: grid;
+  gap: 7px;
+  color: var(--faint);
+  font-size: 11px;
+}
+.rule-row {
+  display: grid;
+  grid-template-columns: minmax(78px, auto) 1fr auto;
+  align-items: center;
+  gap: 8px;
+  border-top: 1px solid var(--border);
+  padding-top: 8px;
+}
+.rule-row strong { color: var(--text); font-size: 11px; }
+.rule-row button {
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  cursor: pointer;
+  font-size: 10px;
+}
+.rule-row button:hover { color: var(--danger); }
 .two-col-fields, .calendar-settings-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
 .timezone-control {
   display: grid;
@@ -1102,6 +1158,7 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
 }
 .booking-list-row:first-child { border-top: 0; }
 .booking-list-row.past { opacity: .62; }
+.booking-list-row.cancelled { opacity: .52; }
 .booking-avatar { width: 36px; height: 36px; border-radius: var(--radius-xs); display: grid; place-items: center; background: var(--surface-soft); font-weight: 750; font-size: 13px; }
 .booking-person, .booking-when, .booking-meta { display: grid; gap: 3px; min-width: 0; }
 .booking-person strong, .booking-when strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
@@ -1125,6 +1182,7 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
 }
 .status-pill.upcoming { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, var(--border)); background: color-mix(in srgb, var(--success) 8%, transparent); }
 .status-pill.past { color: var(--faint); }
+.status-pill.cancelled { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); background: color-mix(in srgb, var(--danger) 8%, transparent); }
 .booking-actions { display: inline-flex; align-items: center; gap: 6px; justify-self: end; }
 .icon-button {
   width: var(--control-h-sm);
@@ -1144,6 +1202,110 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
 .empty-list svg { color: var(--faint); }
 .empty-list strong { color: var(--text); font-size: 15px; letter-spacing: -0.02em; }
 .empty-list span { font-size: 12px; }
+.booking-detail-card {
+  margin-top: 14px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface);
+  padding: 22px;
+  display: grid;
+  gap: 16px;
+}
+.booking-detail-card .card-heading {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 0;
+}
+.booking-detail-grid {
+  margin: 0;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 1px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  overflow: hidden;
+  background: var(--border);
+}
+.booking-detail-grid div {
+  background: var(--surface);
+  padding: 12px;
+  min-width: 0;
+}
+.booking-detail-grid dt {
+  color: var(--faint);
+  font-size: 9px;
+  font-weight: 760;
+  letter-spacing: .09em;
+  text-transform: uppercase;
+}
+.booking-detail-grid dd {
+  margin: 4px 0 0;
+  color: var(--text);
+  font-size: 12px;
+  font-weight: 650;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.booking-detail-actions,
+.dialog-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.danger-button {
+  border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
+  border-radius: var(--radius-md);
+  padding: 12px 15px;
+  min-height: var(--control-h);
+  background: color-mix(in srgb, var(--danger) 10%, var(--surface));
+  color: var(--danger);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  font-weight: 720;
+  font-size: 12px;
+  cursor: pointer;
+}
+.danger-button:disabled,
+.secondary-button:disabled {
+  opacity: .45;
+  cursor: not-allowed;
+}
+.modal-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 80;
+  display: grid;
+  place-items: center;
+  padding: 20px;
+  background: rgba(0, 0, 0, .62);
+}
+.confirm-dialog {
+  width: min(100%, 430px);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface);
+  box-shadow: var(--shadow);
+  padding: 24px;
+  display: grid;
+  gap: 14px;
+}
+.confirm-dialog h2 { margin: 0; font-size: 26px; letter-spacing: -0.04em; }
+.cancel-summary {
+  display: grid;
+  gap: 5px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+  padding: 14px;
+  color: var(--muted);
+  font-size: 13px;
+}
+.cancel-summary strong { color: var(--text); font-size: 15px; }
 
 @media (max-width: 1180px) {
   .booking-shell { grid-template-columns: 250px minmax(440px, 1fr); }
@@ -1193,6 +1355,8 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
   .admin-nav { position: static; flex-direction: row; overflow-x: auto; padding: 0; }
   .settings-grid { grid-template-columns: 1fr; }
   .wide-card { grid-column: span 1; }
+  .availability-tools { grid-template-columns: 1fr; }
+  .booking-detail-grid { grid-template-columns: 1fr 1fr; }
   .appearance-heading { display: grid; }
   .theme-grid { grid-template-columns: 1fr; }
   .mode-admin-grid { grid-template-columns: repeat(2, 1fr); }
@@ -1223,6 +1387,9 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
   .slot-skeleton { grid-template-columns: repeat(3, 1fr); }
   .duration-grid { grid-template-columns: repeat(4, 1fr); }
   .form-row, .mode-options, .two-col-fields, .calendar-settings-row { grid-template-columns: 1fr; }
+  .booking-detail-grid { grid-template-columns: 1fr; }
+  .rule-row { grid-template-columns: 1fr; }
+  .weekday-picker { grid-template-columns: repeat(4, 1fr); }
   .timezone-control { grid-template-columns: 1fr; }
   .confirmation-card, .admin-login-card { padding: 28px 22px; }
   .admin-user > span { display: none; }
diff --git a/worker/google.ts b/worker/google.ts
index 3ed5b16..25c6c06 100644
--- a/worker/google.ts
+++ b/worker/google.ts
@@ -293,6 +293,30 @@ export async function createCalendarEvent(
   return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
 }
 
+export async function deleteCalendarEvent(
+  env: Env,
+  settings: AppSettings,
+  eventId: string,
+): Promise<void> {
+  const token = await accessToken(env);
+  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
+  const encodedEventId = encodeURIComponent(eventId);
+  const params = new URLSearchParams({ sendUpdates: 'all' });
+
+  const response = await fetch(
+    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
+    {
+      method: 'DELETE',
+      headers: { Authorization: `Bearer ${token}` },
+    },
+  );
+
+  if (response.status === 410 || response.status === 404) return;
+  if (!response.ok) {
+    await throwGoogleCalendarError(response, 'Google event cancellation');
+  }
+}
+
 export async function disconnectGoogle(env: Env): Promise<void> {
   await env.DB.prepare("DELETE FROM oauth_tokens WHERE provider = 'google'").run();
 }
diff --git a/worker/index.ts b/worker/index.ts
index b5c4363..87128a0 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -10,6 +10,7 @@ import {
   completeGoogleOAuth,
   createCalendarEvent,
   createGoogleAuthUrl,
+  deleteCalendarEvent,
   disconnectGoogle,
   hasGoogleConnection,
   queryBusy,
@@ -25,7 +26,15 @@ import {
   weekdayForDate,
   zonedDateTimeToUtc,
 } from './time';
-import type { AppSettings, BookingRequest, BusyRange, Env, MeetingMode } from './types';
+import type {
+  AppSettings,
+  AvailabilityBlock,
+  BookingRequest,
+  BusyRange,
+  Env,
+  MeetingMode,
+  RecurringUnavailablePeriod,
+} from './types';
 
 const JSON_HEADERS = {
   'Content-Type': 'application/json; charset=utf-8',
@@ -52,6 +61,15 @@ function isEmail(value: string): boolean {
   return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
 }
 
+function isTime(value: string): boolean {
+  return /^\d{2}:\d{2}$/.test(value) && timeToMinutes(value) >= 0 && timeToMinutes(value) <= 24 * 60;
+}
+
+function privateCalendarError(error: unknown): string {
+  if (error instanceof GoogleCalendarError) return error.publicMessage;
+  return 'Calendar sync failed. Check the calendar connection and try again.';
+}
+
 async function enforceBookingRateLimit(request: Request, env: Env): Promise<boolean> {
   const ip = request.headers.get('CF-Connecting-IP');
   if (!ip) return true; // Local development.
@@ -157,6 +175,79 @@ async function d1BusyRanges(env: Env, start: number, end: number): Promise<BusyR
   }));
 }
 
+async function recurringUnavailableForDay(env: Env, weekday: number): Promise<RecurringUnavailablePeriod[]> {
+  const result = await env.DB.prepare(`
+    SELECT id, weekdays, start_time, end_time, label
+    FROM recurring_unavailable_periods
+    ORDER BY start_time
+  `).all<{ id: string; weekdays: string; start_time: string; end_time: string; label: string | null }>();
+
+  return (result.results ?? [])
+    .map((row) => ({
+      id: row.id,
+      weekdays: row.weekdays.split(',').map(Number).filter((day) => day >= 0 && day <= 6),
+      start: row.start_time,
+      end: row.end_time,
+      label: row.label ?? undefined,
+    }))
+    .filter((period) => period.weekdays.includes(weekday));
+}
+
+async function oneOffBlocksForDate(env: Env, dateKey: string): Promise<AvailabilityBlock[]> {
+  const result = await env.DB.prepare(`
+    SELECT id, date, start_time, end_time, all_day, label
+    FROM availability_blocks
+    WHERE date = ?1
+    ORDER BY all_day DESC, start_time
+  `).bind(dateKey).all<{
+    id: string;
+    date: string;
+    start_time: string | null;
+    end_time: string | null;
+    all_day: number;
+    label: string | null;
+  }>();
+
+  return (result.results ?? []).map((row) => ({
+    id: row.id,
+    date: row.date,
+    start: row.start_time ?? undefined,
+    end: row.end_time ?? undefined,
+    allDay: row.all_day === 1,
+    label: row.label ?? undefined,
+  }));
+}
+
+async function slotUnavailableRanges(env: Env, dateKey: string, timezone: string): Promise<BusyRange[]> {
+  const weekday = weekdayForDate(dateKey, timezone);
+  const [recurring, oneOff] = await Promise.all([
+    recurringUnavailableForDay(env, weekday),
+    oneOffBlocksForDate(env, dateKey),
+  ]);
+
+  const ranges: BusyRange[] = [];
+  for (const period of recurring) {
+    ranges.push({
+      start: zonedDateTimeToUtc(dateKey, period.start, timezone).getTime(),
+      end: zonedDateTimeToUtc(dateKey, period.end, timezone).getTime(),
+    });
+  }
+  for (const block of oneOff) {
+    if (block.allDay) {
+      ranges.push({
+        start: zonedDateTimeToUtc(dateKey, '00:00', timezone).getTime(),
+        end: zonedDateTimeToUtc(addDaysToDateKey(dateKey, 1), '00:00', timezone).getTime(),
+      });
+    } else if (block.start && block.end) {
+      ranges.push({
+        start: zonedDateTimeToUtc(dateKey, block.start, timezone).getTime(),
+        end: zonedDateTimeToUtc(dateKey, block.end, timezone).getTime(),
+      });
+    }
+  }
+  return ranges;
+}
+
 async function computeAvailability(env: Env, dateKey: string) {
   const settings = await getSettings(env);
   const connected = await hasGoogleConnection(env);
@@ -179,14 +270,16 @@ async function computeAvailability(env: Env, dateKey: string) {
 
   const queryStart = zonedDateTimeToUtc(dateKey, intervals[0].start, settings.timezone).getTime() - 6 * 3600_000;
   const queryEnd = zonedDateTimeToUtc(dateKey, intervals[intervals.length - 1].end, settings.timezone).getTime() + 6 * 3600_000;
-  const [googleBusy, storedBusy] = await Promise.all([
+  const [googleBusy, storedBusy, slotBusy] = await Promise.all([
     queryBusy(env, settings, new Date(queryStart).toISOString(), new Date(queryEnd).toISOString()),
     d1BusyRanges(env, queryStart, queryEnd),
+    slotUnavailableRanges(env, dateKey, settings.timezone),
   ]);
 
   const busy: BusyRange[] = [
     ...googleBusy.map((range) => ({ start: Date.parse(range.start), end: Date.parse(range.end) })),
     ...storedBusy,
+    ...slotBusy,
   ];
   const earliestAllowed = Date.now() + settings.minimumNoticeMinutes * 60_000;
   const slots: Array<{ start: string; time: string; durations: number[] }> = [];
@@ -351,6 +444,141 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
   }
 }
 
+async function cancelBooking(env: Env, bookingId: string): Promise<Response> {
+  if (!/^[0-9a-f-]{36}$/i.test(bookingId)) return json({ error: 'Invalid booking id.' }, 400);
+  const booking = await env.DB.prepare(`
+    SELECT id, google_event_id, status
+    FROM bookings
+    WHERE id = ?1
+  `).bind(bookingId).first<{ id: string; google_event_id: string | null; status: string }>();
+
+  if (!booking) return json({ error: 'Booking not found.' }, 404);
+  if (booking.status === 'cancelled') return json({ ok: true });
+  if (booking.status !== 'confirmed') return json({ error: 'Only confirmed bookings can be cancelled.' }, 409);
+
+  const settings = await getSettings(env);
+  try {
+    if (booking.google_event_id) {
+      await deleteCalendarEvent(env, settings, booking.google_event_id);
+    }
+  } catch (error) {
+    const message = privateCalendarError(error);
+    await env.DB.prepare(`
+      UPDATE bookings
+      SET calendar_sync_status = 'failed',
+          calendar_sync_error = ?1,
+          calendar_sync_updated_at = datetime('now'),
+          updated_at = datetime('now')
+      WHERE id = ?2
+    `).bind(message, bookingId).run();
+    return json({ error: 'Slot could not cancel the Google Calendar event. The booking has not been cancelled.' }, 502);
+  }
+
+  await env.DB.batch([
+    env.DB.prepare(`
+      UPDATE bookings
+      SET status = 'cancelled',
+          cancelled_at = datetime('now'),
+          calendar_sync_status = 'synced',
+          calendar_sync_error = NULL,
+          calendar_sync_updated_at = datetime('now'),
+          updated_at = datetime('now')
+      WHERE id = ?1
+    `).bind(bookingId),
+    env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(bookingId),
+  ]);
+
+  return json({ ok: true });
+}
+
+function cleanLabel(value: unknown): string | null {
+  const label = String(value ?? '').trim().slice(0, 80);
+  return label || null;
+}
+
+async function listAvailabilityRules(env: Env): Promise<Response> {
+  const recurring = await env.DB.prepare(`
+    SELECT id, weekdays, start_time, end_time, label
+    FROM recurring_unavailable_periods
+    ORDER BY start_time
+  `).all<{ id: string; weekdays: string; start_time: string; end_time: string; label: string | null }>();
+  const blocks = await env.DB.prepare(`
+    SELECT id, date, start_time, end_time, all_day, label
+    FROM availability_blocks
+    ORDER BY date DESC, all_day DESC, start_time
+    LIMIT 100
+  `).all<{ id: string; date: string; start_time: string | null; end_time: string | null; all_day: number; label: string | null }>();
+
+  return json({
+    recurring: (recurring.results ?? []).map((row) => ({
+      id: row.id,
+      weekdays: row.weekdays.split(',').map(Number).filter((day) => day >= 0 && day <= 6),
+      start: row.start_time,
+      end: row.end_time,
+      label: row.label,
+    })),
+    blocks: (blocks.results ?? []).map((row) => ({
+      id: row.id,
+      date: row.date,
+      start: row.start_time,
+      end: row.end_time,
+      allDay: row.all_day === 1,
+      label: row.label,
+    })),
+  });
+}
+
+async function createRecurringUnavailable(env: Env, input: unknown): Promise<Response> {
+  const raw = input as Partial<{ weekdays: unknown[]; start: string; end: string; label: string }>;
+  const weekdays = Array.isArray(raw.weekdays)
+    ? [...new Set(raw.weekdays.map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b)
+    : [];
+  const start = String(raw.start ?? '');
+  const end = String(raw.end ?? '');
+  if (!weekdays.length || !isTime(start) || !isTime(end) || timeToMinutes(end) <= timeToMinutes(start)) {
+    return json({ error: 'Choose weekdays plus a valid start and end time.' }, 400);
+  }
+
+  const id = crypto.randomUUID();
+  await env.DB.prepare(`
+    INSERT INTO recurring_unavailable_periods (id, weekdays, start_time, end_time, label)
+    VALUES (?1, ?2, ?3, ?4, ?5)
+  `).bind(id, weekdays.join(','), start, end, cleanLabel(raw.label)).run();
+  return json({ ok: true, id }, 201);
+}
+
+async function createAvailabilityBlock(env: Env, input: unknown): Promise<Response> {
+  const raw = input as Partial<{ date: string; start: string; end: string; allDay: boolean; label: string }>;
+  const date = String(raw.date ?? '');
+  const allDay = Boolean(raw.allDay);
+  const start = String(raw.start ?? '');
+  const end = String(raw.end ?? '');
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return json({ error: 'Choose a valid date.' }, 400);
+  if (!allDay && (!isTime(start) || !isTime(end) || timeToMinutes(end) <= timeToMinutes(start))) {
+    return json({ error: 'Choose a valid start and end time, or mark the block all day.' }, 400);
+  }
+
+  const id = crypto.randomUUID();
+  await env.DB.prepare(`
+    INSERT INTO availability_blocks (id, date, start_time, end_time, all_day, label)
+    VALUES (?1, ?2, ?3, ?4, ?5, ?6)
+  `).bind(id, date, allDay ? null : start, allDay ? null : end, allDay ? 1 : 0, cleanLabel(raw.label)).run();
+  return json({ ok: true, id }, 201);
+}
+
+async function deleteAvailabilityRule(env: Env, kind: string, id: string): Promise<Response> {
+  if (!/^[0-9a-f-]{36}$/i.test(id)) return json({ error: 'Invalid rule id.' }, 400);
+  if (kind === 'recurring') {
+    await env.DB.prepare('DELETE FROM recurring_unavailable_periods WHERE id = ?1').bind(id).run();
+    return json({ ok: true });
+  }
+  if (kind === 'block') {
+    await env.DB.prepare('DELETE FROM availability_blocks WHERE id = ?1').bind(id).run();
+    return json({ ok: true });
+  }
+  return json({ error: 'Invalid availability rule type.' }, 400);
+}
+
 async function handleApi(request: Request, env: Env, url: URL): Promise<Response> {
   if (request.method === 'GET' && url.pathname === '/api/config') {
     const settings = await getSettings(env);
@@ -422,15 +650,39 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
     if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
       const result = await env.DB.prepare(`
         SELECT id, name, email, start_time, end_time, duration_minutes,
-               meeting_mode, status, meet_url, created_at
+               meeting_mode, status, meet_url, created_at,
+               cancelled_at, calendar_provider, calendar_sync_status,
+               calendar_sync_error, calendar_sync_updated_at
         FROM bookings
-        WHERE status = 'confirmed'
+        WHERE status != 'pending'
         ORDER BY start_time DESC
         LIMIT 50
       `).all();
       return json({ bookings: result.results ?? [] });
     }
 
+    const cancelMatch = url.pathname.match(/^\/api\/admin\/bookings\/([^/]+)\/cancel$/);
+    if (request.method === 'POST' && cancelMatch) {
+      return cancelBooking(env, cancelMatch[1]);
+    }
+
+    if (request.method === 'GET' && url.pathname === '/api/admin/availability-rules') {
+      return listAvailabilityRules(env);
+    }
+
+    if (request.method === 'POST' && url.pathname === '/api/admin/availability-rules/recurring') {
+      return createRecurringUnavailable(env, await request.json());
+    }
+
+    if (request.method === 'POST' && url.pathname === '/api/admin/availability-rules/blocks') {
+      return createAvailabilityBlock(env, await request.json());
+    }
+
+    const availabilityDeleteMatch = url.pathname.match(/^\/api\/admin\/availability-rules\/(recurring|block)\/([^/]+)$/);
+    if (request.method === 'DELETE' && availabilityDeleteMatch) {
+      return deleteAvailabilityRule(env, availabilityDeleteMatch[1], availabilityDeleteMatch[2]);
+    }
+
     if (request.method === 'POST' && url.pathname === '/api/admin/disconnect-google') {
       await disconnectGoogle(env);
       return json({ ok: true });
diff --git a/worker/types.ts b/worker/types.ts
index 2e64a97..140a38b 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -41,6 +41,23 @@ export interface BusyRange {
   end: number;
 }
 
+export interface RecurringUnavailablePeriod {
+  id: string;
+  weekdays: number[];
+  start: string;
+  end: string;
+  label?: string;
+}
+
+export interface AvailabilityBlock {
+  id: string;
+  date: string;
+  start?: string;
+  end?: string;
+  allDay: boolean;
+  label?: string;
+}
+
 export interface BookingRequest {
   start: string;
   duration: number;