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

feat: add public booking IDs and self-service cancellation

92deb90
Alex Nord <[email protected]> 23 hours ago
ARCHITECTURE.md                                 |  10 +-
 README.md                                       |  14 ++
 migrations/0005_activity.sql                    |  85 +++++++
 migrations/0006_public_booking_cancellation.sql |  17 ++
 src/App.tsx                                     | 274 +++++++++++++++++++++-
 src/styles.css                                  | 207 ++++++++++++++++
 worker/google.ts                                |  10 +-
 worker/index.ts                                 | 298 +++++++++++++++++++++++-
 8 files changed, 899 insertions(+), 16 deletions(-)
 create mode 100644 migrations/0005_activity.sql
 create mode 100644 migrations/0006_public_booking_cancellation.sql

Diff

diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 64d7f4b..55cb3c3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -19,6 +19,7 @@ your-booking-domain.example
              │    basic rate limits
              │    Stripe payment/refund state
              │    reschedule proposals (hashed bearer tokens)
+             │    append-only admin activity events
              │
              ├── Google Calendar API
              │    FreeBusy
@@ -48,7 +49,7 @@ your-booking-domain.example
 
 ## Why D1 exists
 
-Google Calendar owns calendar truth. D1 only holds application state Google does not provide: UI settings, booking metadata, encrypted OAuth credentials, and concurrency locks.
+Google Calendar owns calendar truth. D1 only holds application state Google does not provide: UI settings, booking metadata, encrypted OAuth credentials, concurrency locks, payment/refund state, and the admin activity history.
 
 ## Booking management
 
@@ -57,3 +58,10 @@ A direct admin reschedule acquires short-lived D1 locks for the destination, rec
 A suggested new time does **not** move or release the original booking. Slot stores the proposed start/end plus a SHA-256 hash of a random bearer token, then PATCHes the Google event description with a secure acceptance URL using `sendUpdates=all`. When the attendee accepts, Slot re-runs normal availability checks before moving the existing event.
 
 For a paid cancellation, Slot creates an idempotent full refund using the stored Stripe PaymentIntent before finalizing cancellation. Refund webhook events keep the D1 refund status current. If the configured Stripe mode does not match the payment's Checkout Session mode, Slot refuses the paid cancellation rather than cancelling without a safe refund path.
+
+Every booking also has a separate high-entropy public booking ID (`SLT-...`). It is shown to the attendee on confirmation and written into the Google Calendar event description. The public cancellation endpoint accepts only this reference, rate-limits attempts, requires a future confirmed booking, and then delegates to the same cancellation/refund function used by Admin. The internal UUID remains the relational key and is not used as the guest-facing cancellation reference.
+
+
+## Activity history
+
+Slot writes lifecycle events to `activity_events` after successful booking, payment, reschedule, suggestion, refund, cancellation, and payment-hold transitions. Idempotency keys suppress duplicate webhook events. Activity writes are deliberately non-blocking so analytics can never make a booking or refund fail. The Admin Activity endpoint computes aggregate booking metrics from the booking table and reads the latest activity events for the chronological feed. Monetary totals stay grouped by currency.
diff --git a/README.md b/README.md
index b62fe63..6fba5f3 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,8 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
 - Admin rescheduling moves the existing Google Calendar event and sends attendee updates
 - Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
 - Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
+- Every confirmed booking gets a public `SLT-...` booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to cancel a future booking from the public booking page
+- Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
 
 ## First setup
 
@@ -228,6 +230,18 @@ Suggestion links contain a high-entropy bearer token. Slot stores only a SHA-256
 
 Paid reschedules keep the original charge because the meeting duration does not change. Slot does not create a second Checkout Session for a pure time change.
 
+Visitors can also cancel their own future confirmed booking from the public booking page. Each confirmation has a high-entropy `SLT-...` booking ID, also included in the Google Calendar invitation. Entering that ID under **Cancel a booking** uses the same cancellation path as Admin. Paid bookings are refunded through Stripe before the calendar event is cancelled. Public cancellation attempts are rate-limited.
+
+## Activity
+
+Open `/admin` -> **Activity** for an all-time operational snapshot and recent lifecycle feed. The dashboard tracks:
+
+- total bookings, upcoming meetings, completed meetings, cancellations, reschedules, paid bookings, and average meeting length
+- when payments are enabled: open payment holds plus gross Stripe revenue, refunds, and net revenue, kept separate by currency if the configured currency changes over time
+- recent booking confirmations, Checkout starts, payments, released holds, reschedules, suggested times, accepted suggestions, refunds, and cancellations
+
+Activity events are stored separately from the booking rows so repeated reschedules and payment/refund changes are preserved as history. Migration `0005_activity.sql` backfills the lifecycle points that can be safely reconstructed from existing bookings. Activity logging is observational: a feed-write failure never blocks a booking, payment, refund, cancellation, or calendar update.
+
 ## Google project in Testing mode
 
 If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
diff --git a/migrations/0005_activity.sql b/migrations/0005_activity.sql
new file mode 100644
index 0000000..3d07ccd
--- /dev/null
+++ b/migrations/0005_activity.sql
@@ -0,0 +1,85 @@
+CREATE TABLE IF NOT EXISTS activity_events (
+  id TEXT PRIMARY KEY,
+  booking_id TEXT,
+  type TEXT NOT NULL,
+  occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
+  metadata TEXT,
+  idempotency_key TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_activity_events_occurred_at
+  ON activity_events(occurred_at DESC);
+CREATE INDEX IF NOT EXISTS idx_activity_events_booking_id
+  ON activity_events(booking_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_activity_events_idempotency_key
+  ON activity_events(idempotency_key)
+  WHERE idempotency_key IS NOT NULL;
+
+-- Backfill the lifecycle points that can be reconstructed safely from existing
+-- booking rows. Future changes are recorded as first-class activity events.
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-confirmed-' || id,
+  id,
+  'booking_confirmed',
+  created_at,
+  '{"backfilled":true}',
+  'booking:' || id || ':confirmed'
+FROM bookings
+WHERE status = 'confirmed'
+   OR (status = 'cancelled' AND (payment_status <> 'cancelled' OR paid_at IS NOT NULL));
+
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-paid-' || id,
+  id,
+  'payment_received',
+  paid_at,
+  '{"backfilled":true}',
+  'booking:' || id || ':paid'
+FROM bookings
+WHERE paid_at IS NOT NULL;
+
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-rescheduled-' || id,
+  id,
+  'booking_rescheduled',
+  rescheduled_at,
+  json_object('backfilled', 1, 'count', reschedule_count),
+  'booking:' || id || ':reschedule-backfill'
+FROM bookings
+WHERE rescheduled_at IS NOT NULL AND reschedule_count > 0;
+
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-suggested-' || id,
+  id,
+  'time_suggested',
+  proposed_at,
+  json_object('backfilled', 1, 'proposedStart', proposed_start_time, 'proposedEnd', proposed_end_time),
+  'booking:' || id || ':suggestion-backfill'
+FROM bookings
+WHERE proposed_at IS NOT NULL;
+
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-refunded-' || id,
+  id,
+  'refund_succeeded',
+  refunded_at,
+  json_object('backfilled', 1, 'amount', refunded_amount, 'currency', payment_currency),
+  CASE WHEN stripe_refund_id IS NOT NULL THEN 'refund:' || stripe_refund_id || ':succeeded' ELSE 'booking:' || id || ':refund-backfill' END
+FROM bookings
+WHERE refunded_at IS NOT NULL;
+
+INSERT OR IGNORE INTO activity_events (id, booking_id, type, occurred_at, metadata, idempotency_key)
+SELECT
+  'backfill-cancelled-' || id,
+  id,
+  CASE WHEN payment_status = 'cancelled' AND paid_at IS NULL THEN 'payment_hold_released' ELSE 'booking_cancelled' END,
+  cancelled_at,
+  '{"backfilled":true}',
+  'booking:' || id || ':cancelled'
+FROM bookings
+WHERE cancelled_at IS NOT NULL;
diff --git a/migrations/0006_public_booking_cancellation.sql b/migrations/0006_public_booking_cancellation.sql
new file mode 100644
index 0000000..f4b7c81
--- /dev/null
+++ b/migrations/0006_public_booking_cancellation.sql
@@ -0,0 +1,17 @@
+ALTER TABLE bookings ADD COLUMN public_booking_id TEXT;
+
+-- Give existing bookings a stable public reference without exposing the full
+-- internal UUID. Existing UUIDs are random, so the first 20 hexadecimal
+-- characters still provide a high-entropy reference for migrated rows.
+UPDATE bookings
+SET public_booking_id =
+  'SLT-' || upper(substr(replace(id, '-', ''), 1, 4)) || '-' ||
+  upper(substr(replace(id, '-', ''), 5, 4)) || '-' ||
+  upper(substr(replace(id, '-', ''), 9, 4)) || '-' ||
+  upper(substr(replace(id, '-', ''), 13, 4)) || '-' ||
+  upper(substr(replace(id, '-', ''), 17, 4))
+WHERE public_booking_id IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_public_booking_id
+  ON bookings(public_booking_id)
+  WHERE public_booking_id IS NOT NULL;
diff --git a/src/App.tsx b/src/App.tsx
index 6ab2933..875ab5b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
 import {
+  Activity,
   ArrowLeft,
   ArrowRight,
   CalendarDays,
@@ -56,6 +57,7 @@ type Slot = {
 
 type Confirmation = {
   id: string;
+  bookingId?: string | null;
   start: string;
   end: string;
   duration: number;
@@ -89,6 +91,7 @@ type AppSettings = {
 
 type BookingRow = {
   id: string;
+  public_booking_id?: string | null;
   name: string;
   email: string;
   start_time: string;
@@ -121,6 +124,46 @@ type BookingRow = {
   refunded_at?: string;
 };
 
+type ActivityStats = {
+  total_bookings: number;
+  upcoming: number;
+  completed: number;
+  cancelled: number;
+  reschedules: number;
+  paid_bookings: number;
+  average_minutes: number;
+  open_payment_holds: number;
+};
+
+type ActivityMoney = {
+  currency: string;
+  revenue: number;
+  refunds: number;
+  net: number;
+};
+
+type ActivityEvent = {
+  id: string;
+  booking_id?: string | null;
+  type: string;
+  occurred_at: string;
+  metadata: Record<string, unknown>;
+  name?: string | null;
+  email?: string | null;
+  start_time?: string | null;
+  end_time?: string | null;
+  duration_minutes?: number | null;
+  payment_amount?: number | null;
+  payment_currency?: string | null;
+};
+
+type ActivityData = {
+  paymentsEnabled: boolean;
+  stats: ActivityStats;
+  money: ActivityMoney[];
+  events: ActivityEvent[];
+};
+
 
 type RescheduleProposal = {
   title: string;
@@ -326,6 +369,65 @@ function formatMoneyMajor(amount: number, currency: string): string {
   }
 }
 
+function databaseInstant(value: string): string {
+  if (/Z$|[+-]\d{2}:?\d{2}$/.test(value)) return value;
+  return `${value.replace(' ', 'T')}Z`;
+}
+
+function activityMoneyText(money: ActivityMoney[], field: 'revenue' | 'refunds' | 'net', preferredCurrency: string): string {
+  if (!money.length) return formatMoneyMinor(0, preferredCurrency);
+  const sorted = [...money].sort((a, b) => {
+    if (a.currency === preferredCurrency.toLowerCase()) return -1;
+    if (b.currency === preferredCurrency.toLowerCase()) return 1;
+    return a.currency.localeCompare(b.currency);
+  });
+  return sorted.map((row) => formatMoneyMinor(row[field], row.currency)).join(' · ');
+}
+
+function activityEventCopy(event: ActivityEvent, timezone: string): { title: string; detail: string } {
+  const name = event.name || 'A visitor';
+  const meta = event.metadata ?? {};
+  const amount = typeof meta.amount === 'number' ? meta.amount : event.payment_amount;
+  const currency = typeof meta.currency === 'string' ? meta.currency : event.payment_currency;
+  const money = amount != null && currency ? formatMoneyMinor(amount, currency) : null;
+  const duration = typeof meta.duration === 'number' ? meta.duration : event.duration_minutes;
+  const eventStart = typeof meta.newStart === 'string'
+    ? meta.newStart
+    : typeof meta.proposedStart === 'string'
+      ? meta.proposedStart
+      : typeof meta.start === 'string'
+        ? meta.start
+        : event.start_time;
+  const oldStart = typeof meta.oldStart === 'string' ? meta.oldStart : null;
+
+  switch (event.type) {
+    case 'booking_confirmed':
+      return { title: `${name} booked a meeting`, detail: `${duration ?? ''}${duration ? ' min · ' : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Confirmed'}` };
+    case 'payment_checkout_started':
+      return { title: `${name} started checkout`, detail: `${money ? `${money} · ` : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Payment pending'}` };
+    case 'payment_received':
+      return { title: `Payment received from ${name}`, detail: money ?? 'Stripe payment completed' };
+    case 'payment_hold_released':
+      return { title: `${name}'s payment hold was released`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)} is available again` : 'The held time is available again' };
+    case 'booking_rescheduled':
+      return { title: `${name}'s meeting was rescheduled`, detail: `${oldStart ? `${formatDateShort(oldStart, timezone)} ${formatClock(oldStart, timezone)} → ` : ''}${eventStart ? `${formatDateShort(eventStart, timezone)} ${formatClock(eventStart, timezone)}` : 'New time saved'}` };
+    case 'time_suggested':
+      return { title: `New time suggested to ${name}`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Waiting for the guest' };
+    case 'time_suggestion_accepted':
+      return { title: `${name} accepted the suggested time`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Meeting rescheduled' };
+    case 'refund_started':
+      return { title: `Refund started for ${name}`, detail: money ?? 'Stripe is processing the refund' };
+    case 'refund_succeeded':
+      return { title: `Refund completed for ${name}`, detail: money ?? 'Stripe refund completed' };
+    case 'refund_failed':
+      return { title: `Refund failed for ${name}`, detail: money ? `${money} · Check Stripe` : 'Check Stripe for the failure details' };
+    case 'booking_cancelled':
+      return { title: `${name}'s meeting was cancelled`, detail: eventStart ? `${formatDateShort(eventStart, timezone)} at ${formatClock(eventStart, timezone)}` : 'Booking cancelled' };
+    default:
+      return { title: event.type.replaceAll('_', ' '), detail: event.email ?? 'Slot activity' };
+  }
+}
+
 function monthCells(month: Date): Array<{ date: Date; current: boolean }> {
   const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
   const mondayIndex = (first.getDay() + 6) % 7;
@@ -376,6 +478,9 @@ function PublicBooking() {
   const [availabilityNotice, setAvailabilityNotice] = useState('');
   const [confirmation, setConfirmation] = useState<Confirmation | null>(null);
   const [paymentReturnState, setPaymentReturnState] = useState<{ kind: 'checking' | 'cancelled' | 'error'; message: string } | null>(null);
+  const [cancelOpen, setCancelOpen] = useState(false);
+  const [cancelBookingId, setCancelBookingId] = useState('');
+  const [cancelState, setCancelState] = useState<{ kind: 'idle' | 'loading' | 'success' | 'error'; message: string }>({ kind: 'idle', message: '' });
 
   useEffect(() => {
     api<PublicConfig>('/api/config')
@@ -543,6 +648,38 @@ function PublicBooking() {
     }
   }
 
+  async function submitPublicCancellation(event: FormEvent<HTMLFormElement>) {
+    event.preventDefault();
+    const bookingId = cancelBookingId.trim();
+    if (!bookingId) {
+      setCancelState({ kind: 'error', message: 'Enter the booking ID from your confirmation or calendar invitation.' });
+      return;
+    }
+
+    setCancelState({ kind: 'loading', message: 'Cancelling booking…' });
+    try {
+      const result = await api<{ ok: boolean; refunded?: boolean; refundStatus?: string; alreadyCancelled?: boolean; warning?: string }>('/api/bookings/cancel', {
+        method: 'POST',
+        body: JSON.stringify({ bookingId }),
+      });
+      let message = result.alreadyCancelled
+        ? 'This booking was already cancelled.'
+        : result.refunded
+          ? result.refundStatus === 'pending'
+            ? 'Booking cancelled. Stripe accepted the refund and it is currently pending.'
+            : 'Booking cancelled. The Stripe payment has been refunded.'
+          : 'Booking cancelled. The calendar invitation has been cancelled.';
+      if (result.warning) message += ` ${result.warning}`;
+      setCancelState({ kind: 'success', message });
+      if (confirmation?.bookingId && confirmation.bookingId.toUpperCase() === bookingId.toUpperCase()) {
+        setConfirmation(null);
+      }
+      if (selectedDate) await chooseDate(selectedDate);
+    } catch (err) {
+      setCancelState({ kind: 'error', message: err instanceof Error ? err.message : 'Could not cancel this booking.' });
+    }
+  }
+
   if (!config) {
     return <div className="center-screen"><Loader2 className="spin" /> Loading calendar…</div>;
   }
@@ -837,11 +974,53 @@ function PublicBooking() {
             <h1>{activeConfig.title}</h1>
             <p className="intro-copy">{activeConfig.subtitle}</p>
           </div>
-          <div className="intro-note">
-            <CalendarDays size={18} />
-            <div>
-              <strong>Your calendar, not a form maze.</strong>
-              <span>Choose the day first. Everything else follows.</span>
+          <div className="intro-bottom">
+            <div className="intro-note">
+              <CalendarDays size={18} />
+              <div>
+                <strong>Your calendar, not a form maze.</strong>
+                <span>Choose the day first. Everything else follows.</span>
+              </div>
+            </div>
+            <div className={`public-cancel ${cancelOpen ? 'open' : ''}`}>
+              {!cancelOpen ? (
+                <button
+                  className="public-cancel-link"
+                  type="button"
+                  onClick={() => {
+                    setCancelOpen(true);
+                    setCancelState({ kind: 'idle', message: '' });
+                  }}
+                >Cancel a booking</button>
+              ) : (
+                <form className="public-cancel-form" onSubmit={submitPublicCancellation}>
+                  <div className="public-cancel-heading">
+                    <strong>Cancel a booking</strong>
+                    <button className="text-button" type="button" onClick={() => setCancelOpen(false)}>Close</button>
+                  </div>
+                  <label>
+                    Booking ID
+                    <input
+                      value={cancelBookingId}
+                      onChange={(event) => {
+                        setCancelBookingId(event.target.value.toUpperCase());
+                        if (cancelState.kind !== 'loading') setCancelState({ kind: 'idle', message: '' });
+                      }}
+                      autoComplete="off"
+                      spellCheck={false}
+                      placeholder="SLT-7K4Q-9M2P-W8RX-3FJN"
+                      aria-label="Booking ID"
+                    />
+                  </label>
+                  <button className="danger-button public-cancel-submit" type="submit" disabled={cancelState.kind === 'loading'}>
+                    {cancelState.kind === 'loading' ? <><Loader2 className="spin" size={15} /> Cancelling…</> : 'Cancel booking'}
+                  </button>
+                  <small>Paid bookings are refunded to the original payment method before Slot cancels the meeting.</small>
+                  {cancelState.kind !== 'idle' && cancelState.kind !== 'loading' && (
+                    <p className={`public-cancel-message ${cancelState.kind}`} role={cancelState.kind === 'error' ? 'alert' : 'status'}>{cancelState.message}</p>
+                  )}
+                </form>
+              )}
             </div>
           </div>
         </aside>
@@ -910,6 +1089,7 @@ function ConfirmationView({ title, confirmation, onBookAnother }: { title: strin
         <div className="confirm-row"><dt>Date</dt><dd>{formatDateShort(confirmation.start, tz)}</dd></div>
         <div className="confirm-row"><dt>Time</dt><dd>{formatClock(confirmation.start, tz)} – {formatClock(confirmation.end, tz)}</dd></div>
         <div className="confirm-row"><dt>Length</dt><dd>{confirmation.duration} min</dd></div>
+        {confirmation.bookingId && <div className="confirm-row booking-id-row"><dt>Booking ID</dt><dd>{confirmation.bookingId}</dd></div>}
         <div className="confirm-row"><dt>Timezone</dt><dd>{tz}</dd></div>
         <div className="confirm-row"><dt>Meeting</dt><dd>{modeMeta[confirmation.meetingMode].label}</dd></div>
       </dl>
@@ -1095,7 +1275,9 @@ function Admin() {
   const [bookings, setBookings] = useState<BookingRow[]>([]);
   const [recurringUnavailable, setRecurringUnavailable] = useState<RecurringUnavailableRule[]>([]);
   const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
-  const [tab, setTab] = useState<'settings' | 'bookings'>('bookings');
+  const [activity, setActivity] = useState<ActivityData | null>(null);
+  const [activityLoading, setActivityLoading] = useState(false);
+  const [tab, setTab] = useState<'settings' | 'bookings' | 'activity'>('bookings');
   const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
   const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
   const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
@@ -1157,6 +1339,11 @@ function Admin() {
   }, [session]);
 
 
+  useEffect(() => {
+    if (!session?.authenticated || tab !== 'activity') return;
+    void loadActivity();
+  }, [session?.authenticated, tab]);
+
   useEffect(() => {
     if (!scheduleTarget || !scheduleDate) {
       setScheduleSlots([]);
@@ -1222,6 +1409,18 @@ function Admin() {
     }
   }
 
+  async function loadActivity() {
+    setActivityLoading(true);
+    setError('');
+    try {
+      setActivity(await api<ActivityData>('/api/admin/activity'));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not load activity.');
+    } finally {
+      setActivityLoading(false);
+    }
+  }
+
   async function refreshAdminData() {
     const [bookingData, availabilityData] = await Promise.all([
       api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
@@ -1374,6 +1573,7 @@ function Admin() {
         <aside className="admin-nav">
           <button className={tab === 'bookings' ? 'active' : ''} onClick={() => setTab('bookings')}><CalendarDays size={17} /> Bookings</button>
           <button className={tab === 'settings' ? 'active' : ''} onClick={() => setTab('settings')}><Settings2 size={17} /> Settings</button>
+          <button className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}><Activity size={17} /> Activity</button>
           <a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
         </aside>
 
@@ -1639,6 +1839,67 @@ function Admin() {
                 </section>
               </div>
             </>
+          ) : tab === 'activity' ? (
+            <>
+              <div className="admin-heading">
+                <div><p className="eyebrow">ACTIVITY</p><h1>Your Slot at a glance.</h1></div>
+                <button className="secondary-button activity-refresh" type="button" onClick={loadActivity} disabled={activityLoading}>
+                  {activityLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Activity size={16} /> Refresh</>}
+                </button>
+              </div>
+              {error && <div className="error-banner" role="alert">{error}</div>}
+              {activityLoading && !activity ? (
+                <section className="activity-loading"><Loader2 className="spin" size={20} /> Loading activity…</section>
+              ) : activity ? (
+                <>
+                  <section className="activity-metrics" aria-label="Booking activity summary">
+                    <article className="activity-metric"><span>Bookings</span><strong>{activity.stats.total_bookings}</strong><small>All confirmed bookings, including later cancellations</small></article>
+                    <article className="activity-metric"><span>Upcoming</span><strong>{activity.stats.upcoming}</strong><small>Confirmed meetings still ahead</small></article>
+                    <article className="activity-metric"><span>Completed</span><strong>{activity.stats.completed}</strong><small>Confirmed meetings whose end time has passed</small></article>
+                    <article className="activity-metric"><span>Cancelled</span><strong>{activity.stats.cancelled}</strong><small>Cancelled meetings, excluding abandoned payment holds</small></article>
+                    <article className="activity-metric"><span>Reschedules</span><strong>{activity.stats.reschedules}</strong><small>Total times confirmed meetings were moved</small></article>
+                    <article className="activity-metric"><span>Paid bookings</span><strong>{activity.stats.paid_bookings}</strong><small>Bookings with a completed Stripe payment</small></article>
+                    <article className="activity-metric"><span>Average length</span><strong>{activity.stats.average_minutes} min</strong><small>Average duration of current confirmed meetings</small></article>
+                    {activity.paymentsEnabled && (
+                      <>
+                        <article className="activity-metric money"><span>Revenue</span><strong>{activityMoneyText(activity.money, 'revenue', activeSettings.paymentCurrency)}</strong><small>Gross successful Stripe payments</small></article>
+                        <article className="activity-metric money"><span>Refunds</span><strong>{activityMoneyText(activity.money, 'refunds', activeSettings.paymentCurrency)}</strong><small>Successful or currently pending refunds</small></article>
+                        <article className="activity-metric money"><span>Net revenue</span><strong>{activityMoneyText(activity.money, 'net', activeSettings.paymentCurrency)}</strong><small>Gross revenue minus refunds</small></article>
+                        <article className="activity-metric"><span>Open payment holds</span><strong>{activity.stats.open_payment_holds}</strong><small>Visitors currently inside the payment window</small></article>
+                      </>
+                    )}
+                  </section>
+
+                  <section className="activity-feed-card">
+                    <div className="activity-feed-heading">
+                      <div><p className="eyebrow">RECENT ACTIVITY</p><h2>What happened.</h2></div>
+                      <span>Latest {Math.min(activity.events.length, 100)} events</span>
+                    </div>
+                    {activity.events.length === 0 ? (
+                      <div className="empty-list activity-empty"><Activity size={25} /><strong>No activity yet</strong><span>Bookings, payments, reschedules, cancellations, and refunds will appear here.</span></div>
+                    ) : (
+                      <div className="activity-feed">
+                        {activity.events.map((event) => {
+                          const copy = activityEventCopy(event, activeSettings.timezone);
+                          return (
+                            <article className={`activity-feed-row type-${event.type}`} key={event.id}>
+                              <span className="activity-feed-mark" aria-hidden="true" />
+                              <div className="activity-feed-copy">
+                                <strong>{copy.title}</strong>
+                                <span>{copy.detail}</span>
+                              </div>
+                              <time dateTime={databaseInstant(event.occurred_at)}>{formatInstant(databaseInstant(event.occurred_at), activeSettings.timezone)}</time>
+                            </article>
+                          );
+                        })}
+                      </div>
+                    )}
+                  </section>
+                </>
+              ) : (
+                <section className="activity-loading">Activity could not be loaded.</section>
+              )}
+            </>
           ) : (
             <>
               <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
@@ -1686,6 +1947,7 @@ function Admin() {
                   </div>
                   <dl className="booking-detail-grid">
                     <div><dt>Status</dt><dd>{selectedBooking.status}</dd></div>
+                    {selectedBooking.public_booking_id && <div><dt>Booking ID</dt><dd>{selectedBooking.public_booking_id}</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>
diff --git a/src/styles.css b/src/styles.css
index b0003af..b0d1505 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1561,3 +1561,210 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
   .schedule-compare { grid-template-columns: 1fr; }
   .schedule-compare > svg { transform: rotate(90deg); justify-self: center; }
 }
+
+/* Admin activity */
+.activity-refresh { width: auto; min-height: 38px; padding: 9px 13px; }
+.activity-loading {
+  min-height: 180px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface);
+  color: var(--muted);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  font-size: 12px;
+}
+.activity-metrics {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 16px;
+}
+.activity-metric {
+  min-width: 0;
+  min-height: 126px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+  padding: 17px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  gap: 8px;
+}
+.activity-metric > span {
+  color: var(--muted);
+  font-size: 10px;
+  font-weight: 720;
+  text-transform: uppercase;
+  letter-spacing: .07em;
+}
+.activity-metric strong {
+  color: var(--text);
+  font-size: clamp(24px, 2.3vw, 34px);
+  line-height: 1;
+  font-variant-numeric: tabular-nums;
+  overflow-wrap: anywhere;
+}
+.activity-metric.money strong { font-size: clamp(18px, 1.65vw, 26px); line-height: 1.12; }
+.activity-metric small { color: var(--faint); font-size: 9px; line-height: 1.45; }
+.activity-feed-card {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface);
+  overflow: hidden;
+}
+.activity-feed-heading {
+  padding: 19px 20px;
+  display: flex;
+  align-items: end;
+  justify-content: space-between;
+  gap: 16px;
+  border-bottom: 1px solid var(--border);
+}
+.activity-feed-heading h2 { font-size: 20px; margin-top: 3px; }
+.activity-feed-heading > span { color: var(--faint); font-size: 10px; }
+.activity-feed { display: grid; }
+.activity-feed-row {
+  display: grid;
+  grid-template-columns: 10px minmax(0, 1fr) auto;
+  align-items: center;
+  gap: 13px;
+  padding: 14px 20px;
+  border-top: 1px solid var(--border);
+}
+.activity-feed-row:first-child { border-top: 0; }
+.activity-feed-row:hover { background: var(--surface-soft); }
+.activity-feed-mark {
+  width: 8px;
+  height: 8px;
+  border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border));
+  border-radius: 50%;
+  background: color-mix(in srgb, var(--accent) 20%, transparent);
+}
+.activity-feed-row.type-payment_received .activity-feed-mark,
+.activity-feed-row.type-refund_succeeded .activity-feed-mark,
+.activity-feed-row.type-booking_confirmed .activity-feed-mark {
+  border-color: color-mix(in srgb, var(--success) 55%, var(--border));
+  background: color-mix(in srgb, var(--success) 22%, transparent);
+}
+.activity-feed-row.type-booking_cancelled .activity-feed-mark,
+.activity-feed-row.type-refund_failed .activity-feed-mark {
+  border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
+  background: color-mix(in srgb, var(--danger) 20%, transparent);
+}
+.activity-feed-copy { min-width: 0; display: grid; gap: 4px; }
+.activity-feed-copy strong { color: var(--text); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.activity-feed-copy span { color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.activity-feed-row time { color: var(--faint); font-size: 9px; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.activity-empty { min-height: 180px; }
+
+@media (max-width: 1100px) {
+  .activity-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 760px) {
+  .activity-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .activity-feed-row { grid-template-columns: 10px minmax(0, 1fr); }
+  .activity-feed-row time { grid-column: 2; }
+}
+
+@media (max-width: 480px) {
+  .activity-metrics { grid-template-columns: 1fr; }
+  .activity-metric { min-height: 112px; }
+  .activity-feed-heading { align-items: flex-start; flex-direction: column; }
+}
+
+/* Public self-service cancellation */
+.intro-bottom {
+  display: grid;
+  gap: 13px;
+}
+.public-cancel {
+  display: grid;
+  justify-items: start;
+}
+.public-cancel-link {
+  appearance: none;
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  padding: 0;
+  font: inherit;
+  font-size: 11px;
+  text-decoration: underline;
+  text-underline-offset: 3px;
+  cursor: pointer;
+}
+.public-cancel-link:hover { color: var(--text); }
+.public-cancel-form {
+  width: 100%;
+  display: grid;
+  gap: 9px;
+  padding: 13px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface-soft);
+}
+.public-cancel-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+.public-cancel-heading strong { font-size: 12px; }
+.public-cancel-heading .text-button { padding: 0; min-height: auto; font-size: 10px; }
+.public-cancel-form label {
+  display: grid;
+  gap: 6px;
+  color: var(--muted);
+  font-size: 10px;
+  font-weight: 650;
+}
+.public-cancel-form input {
+  width: 100%;
+  min-width: 0;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface);
+  color: var(--text);
+  padding: 10px 11px;
+  font: inherit;
+  font-size: 11px;
+  letter-spacing: .02em;
+  text-transform: uppercase;
+}
+.public-cancel-submit {
+  width: 100%;
+  min-height: 36px;
+  padding: 8px 11px;
+  font-size: 11px;
+}
+.public-cancel-form small {
+  color: var(--faint);
+  font-size: 9.5px;
+  line-height: 1.45;
+}
+.public-cancel-message {
+  margin: 0;
+  padding: 9px 10px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: 10px;
+  line-height: 1.45;
+}
+.public-cancel-message.success {
+  color: var(--success);
+  background: color-mix(in srgb, var(--success) 7%, var(--surface));
+}
+.public-cancel-message.error {
+  color: var(--danger);
+  background: color-mix(in srgb, var(--danger) 7%, var(--surface));
+}
+.booking-id-row dd {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  letter-spacing: .015em;
+}
diff --git a/worker/google.ts b/worker/google.ts
index f38f3a6..f9ddddb 100644
--- a/worker/google.ts
+++ b/worker/google.ts
@@ -221,6 +221,7 @@ export async function createCalendarEvent(
   settings: AppSettings,
   booking: {
     id: string;
+    publicBookingId: string;
     name: string;
     email: string;
     phone?: string;
@@ -239,6 +240,7 @@ export async function createCalendarEvent(
 
   const descriptionLines = [
     `Booked through ${env.APP_URL}`,
+    `Booking ID: ${booking.publicBookingId}`,
     booking.phone ? `Phone: ${booking.phone}` : '',
     booking.message ? `Message: ${booking.message}` : '',
   ].filter(Boolean);
@@ -357,6 +359,7 @@ export async function confirmCalendarHold(
   eventId: string,
   booking: {
     id: string;
+    publicBookingId: string;
     name: string;
     email: string;
     phone?: string;
@@ -373,6 +376,7 @@ export async function confirmCalendarHold(
 
   const descriptionLines = [
     `Booked through ${env.APP_URL}`,
+    `Booking ID: ${booking.publicBookingId}`,
     booking.phone ? `Phone: ${booking.phone}` : '',
     booking.message ? `Message: ${booking.message}` : '',
     'Payment received via Stripe.',
@@ -421,11 +425,12 @@ export async function confirmCalendarHold(
 
 function managedBookingDescription(
   env: Env,
-  booking: { phone?: string; message?: string; paid?: boolean },
+  booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string },
   extraLines: string[] = [],
 ): string {
   return [
     `Booked through ${env.APP_URL}`,
+    booking.publicBookingId ? `Booking ID: ${booking.publicBookingId}` : '',
     booking.phone ? `Phone: ${booking.phone}` : '',
     booking.message ? `Message: ${booking.message}` : '',
     booking.paid ? 'Payment received via Stripe.' : '',
@@ -441,6 +446,7 @@ export async function rescheduleCalendarEvent(
     phone?: string;
     message?: string;
     paid?: boolean;
+    publicBookingId?: string;
     start: string;
     end: string;
   },
@@ -477,7 +483,7 @@ export async function sendRescheduleSuggestion(
   env: Env,
   settings: AppSettings,
   eventId: string,
-  booking: { phone?: string; message?: string; paid?: boolean },
+  booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string },
   proposal: { start: string; end: string; url: string },
 ): Promise<void> {
   const token = await accessToken(env);
diff --git a/worker/index.ts b/worker/index.ts
index 903124e..8d46757 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -113,6 +113,43 @@ async function enforceBookingRateLimit(request: Request, env: Env): Promise<bool
   return (row?.count ?? 1) <= 12;
 }
 
+async function enforceCancellationRateLimit(request: Request, env: Env): Promise<boolean> {
+  const ip = request.headers.get('CF-Connecting-IP');
+  if (!ip) return true;
+
+  const hourBucket = Math.floor(Date.now() / 3_600_000);
+  const material = new TextEncoder().encode(`cancel:${ip}:${hourBucket}:${env.SESSION_SECRET}`);
+  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', material));
+  const key = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
+  const resetAt = (hourBucket + 1) * 3_600_000;
+
+  await env.DB.prepare('DELETE FROM rate_limits WHERE reset_at < ?1').bind(Date.now()).run();
+  await env.DB.prepare(`
+    INSERT INTO rate_limits (key, count, reset_at)
+    VALUES (?1, 1, ?2)
+    ON CONFLICT(key) DO UPDATE SET count = count + 1
+  `).bind(key, resetAt).run();
+  const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE key = ?1')
+    .bind(key)
+    .first<{ count: number }>();
+  return (row?.count ?? 1) <= 10;
+}
+
+function newPublicBookingId(): string {
+  const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
+  const bytes = crypto.getRandomValues(new Uint8Array(20));
+  const chars = Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
+  return `SLT-${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}-${chars.slice(12, 16)}-${chars.slice(16, 20)}`;
+}
+
+function normalizePublicBookingId(value: unknown): string | null {
+  const compact = String(value ?? '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
+  if (!compact.startsWith('SLT') || compact.length !== 23) return null;
+  const chars = compact.slice(3);
+  if (!/^[A-Z0-9]{20}$/.test(chars)) return null;
+  return `SLT-${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}-${chars.slice(12, 16)}-${chars.slice(16, 20)}`;
+}
+
 async function requireAdmin(request: Request, env: Env): Promise<string | null> {
   const email = await verifySession(env, getCookie(request, 'meet_session'));
   if (!email || email.toLowerCase() !== env.ADMIN_EMAIL.toLowerCase()) return null;
@@ -372,6 +409,7 @@ async function computeAvailability(env: Env, dateKey: string) {
 
 type PaymentBookingRow = {
   id: string;
+  public_booking_id: string | null;
   name: string;
   email: string;
   phone: string | null;
@@ -407,6 +445,7 @@ type PaymentBookingRow = {
 function publicConfirmation(booking: PaymentBookingRow, receiptUrl: string | null = null) {
   return {
     id: booking.id,
+    bookingId: booking.public_booking_id,
     start: booking.start_time,
     end: booking.end_time,
     duration: booking.duration_minutes,
@@ -417,9 +456,146 @@ function publicConfirmation(booking: PaymentBookingRow, receiptUrl: string | nul
   };
 }
 
+type ActivityMetadata = Record<string, string | number | boolean | null | undefined>;
+
+async function logActivity(
+  env: Env,
+  bookingId: string | null,
+  type: string,
+  metadata: ActivityMetadata = {},
+  idempotencyKey: string | null = null,
+): Promise<void> {
+  try {
+    const cleanMetadata = Object.fromEntries(
+      Object.entries(metadata).filter(([, value]) => value !== undefined),
+    );
+    await env.DB.prepare(`
+      INSERT OR IGNORE INTO activity_events (id, booking_id, type, metadata, idempotency_key)
+      VALUES (?1, ?2, ?3, ?4, ?5)
+    `).bind(
+      crypto.randomUUID(),
+      bookingId,
+      type,
+      Object.keys(cleanMetadata).length ? JSON.stringify(cleanMetadata) : null,
+      idempotencyKey,
+    ).run();
+  } catch {
+    // Activity is observational. A missing/new migration or a feed write must
+    // never be allowed to break booking, payment, refund, or calendar flows.
+  }
+}
+
+async function adminActivity(env: Env): Promise<Response> {
+  const settings = await getSettings(env);
+  await cleanupStalePendingBookings(env, settings);
+
+  const stats = await env.DB.prepare(`
+    WITH facts AS (
+      SELECT
+        *,
+        CASE
+          WHEN status = 'confirmed' THEN 1
+          WHEN status = 'cancelled' AND (payment_status <> 'cancelled' OR paid_at IS NOT NULL) THEN 1
+          ELSE 0
+        END AS is_booking
+      FROM bookings
+    )
+    SELECT
+      COALESCE(SUM(is_booking), 0) AS total_bookings,
+      COALESCE(SUM(CASE WHEN status = 'confirmed' AND julianday(end_time) >= julianday('now') THEN 1 ELSE 0 END), 0) AS upcoming,
+      COALESCE(SUM(CASE WHEN status = 'confirmed' AND julianday(end_time) < julianday('now') THEN 1 ELSE 0 END), 0) AS completed,
+      COALESCE(SUM(CASE WHEN status = 'cancelled' AND is_booking = 1 THEN 1 ELSE 0 END), 0) AS cancelled,
+      COALESCE(SUM(CASE WHEN is_booking = 1 THEN reschedule_count ELSE 0 END), 0) AS reschedules,
+      COALESCE(SUM(CASE WHEN paid_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS paid_bookings,
+      COALESCE(ROUND(AVG(CASE WHEN status = 'confirmed' THEN duration_minutes END), 1), 0) AS average_minutes,
+      COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS open_payment_holds
+    FROM facts
+  `).first<{
+    total_bookings: number;
+    upcoming: number;
+    completed: number;
+    cancelled: number;
+    reschedules: number;
+    paid_bookings: number;
+    average_minutes: number;
+    open_payment_holds: number;
+  }>();
+
+  const money = await env.DB.prepare(`
+    SELECT
+      lower(payment_currency) AS currency,
+      COALESCE(SUM(CASE WHEN paid_at IS NOT NULL THEN payment_amount ELSE 0 END), 0) AS revenue,
+      COALESCE(SUM(CASE WHEN refund_status IN ('succeeded', 'pending') THEN refunded_amount ELSE 0 END), 0) AS refunds
+    FROM bookings
+    WHERE payment_currency IS NOT NULL
+    GROUP BY lower(payment_currency)
+    ORDER BY lower(payment_currency)
+  `).all<{ currency: string; revenue: number; refunds: number }>();
+
+  const feed = await env.DB.prepare(`
+    SELECT
+      a.id,
+      a.booking_id,
+      a.type,
+      a.occurred_at,
+      a.metadata,
+      b.name,
+      b.email,
+      b.start_time,
+      b.end_time,
+      b.duration_minutes,
+      b.payment_amount,
+      b.payment_currency
+    FROM activity_events a
+    LEFT JOIN bookings b ON b.id = a.booking_id
+    ORDER BY datetime(a.occurred_at) DESC, a.rowid DESC
+    LIMIT 100
+  `).all<{
+    id: string;
+    booking_id: string | null;
+    type: string;
+    occurred_at: string;
+    metadata: string | null;
+    name: string | null;
+    email: string | null;
+    start_time: string | null;
+    end_time: string | null;
+    duration_minutes: number | null;
+    payment_amount: number | null;
+    payment_currency: string | null;
+  }>();
+
+  return json({
+    paymentsEnabled: settings.paymentEnabled,
+    stats: stats ?? {
+      total_bookings: 0,
+      upcoming: 0,
+      completed: 0,
+      cancelled: 0,
+      reschedules: 0,
+      paid_bookings: 0,
+      average_minutes: 0,
+      open_payment_holds: 0,
+    },
+    money: settings.paymentEnabled ? (money.results ?? []).map((row) => ({
+      currency: row.currency,
+      revenue: Number(row.revenue ?? 0),
+      refunds: Number(row.refunds ?? 0),
+      net: Number(row.revenue ?? 0) - Number(row.refunds ?? 0),
+    })) : [],
+    events: (feed.results ?? []).map((row) => {
+      let metadata: Record<string, unknown> = {};
+      if (row.metadata) {
+        try { metadata = JSON.parse(row.metadata) as Record<string, unknown>; } catch { metadata = {}; }
+      }
+      return { ...row, metadata };
+    }),
+  });
+}
+
 async function paymentBookingById(env: Env, bookingId: string): Promise<PaymentBookingRow | null> {
   return env.DB.prepare(`
-    SELECT id, name, email, phone, message, start_time, end_time, duration_minutes,
+    SELECT id, public_booking_id, name, email, phone, message, start_time, end_time, duration_minutes,
            timezone, meeting_mode, status, google_event_id, meet_url, payment_status,
            payment_amount, payment_currency, stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
            rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time, proposed_at,
@@ -466,6 +642,12 @@ async function releasePendingBooking(env: Env, settings: AppSettings, bookingId:
       WHERE id = ?1 AND status = 'pending'
     `).bind(bookingId),
   ]);
+  await logActivity(env, bookingId, 'payment_hold_released', {
+    start: booking.start_time,
+    end: booking.end_time,
+    amount: booking.payment_amount,
+    currency: booking.payment_currency,
+  }, `booking:${bookingId}:hold-released`);
 }
 
 async function recordPaidSession(
@@ -490,6 +672,10 @@ async function recordPaidSession(
     stripePaymentIntentId(session),
     bookingId,
   ).run();
+  await logActivity(env, bookingId, 'payment_received', {
+    amount: session.amount_total ?? null,
+    currency: session.currency ?? null,
+  }, `booking:${bookingId}:paid`);
 }
 
 async function fulfillPaidBooking(env: Env, bookingId: string): Promise<PaymentBookingRow> {
@@ -540,6 +726,7 @@ async function fulfillPaidBooking(env: Env, bookingId: string): Promise<PaymentB
     const created = booking.google_event_id
       ? await confirmCalendarHold(env, settings, booking.google_event_id, {
           id: booking.id,
+          publicBookingId: booking.public_booking_id ?? booking.id,
           name: booking.name,
           email: booking.email,
           phone: booking.phone ?? undefined,
@@ -550,6 +737,7 @@ async function fulfillPaidBooking(env: Env, bookingId: string): Promise<PaymentB
         })
       : await createCalendarEvent(env, settings, {
           id: booking.id,
+          publicBookingId: booking.public_booking_id ?? booking.id,
           name: booking.name,
           email: booking.email,
           phone: booking.phone ?? undefined,
@@ -574,6 +762,12 @@ async function fulfillPaidBooking(env: Env, bookingId: string): Promise<PaymentB
       env.DB.prepare('UPDATE slot_locks SET expires_at = ?1 WHERE booking_id = ?2')
         .bind(Date.now() + 10 * 60_000, booking.id),
     ]);
+    await logActivity(env, booking.id, 'booking_confirmed', {
+      start: booking.start_time,
+      end: booking.end_time,
+      duration: booking.duration_minutes,
+      paid: true,
+    }, `booking:${booking.id}:confirmed`);
   } catch (error) {
     await env.DB.prepare(`
       UPDATE bookings
@@ -684,6 +878,7 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
   const lockStart = addMinutes(startMs, -settings.bufferBeforeMinutes);
   const lockEnd = addMinutes(endMs, settings.bufferAfterMinutes);
   const id = crypto.randomUUID();
+  const publicBookingId = newPublicBookingId();
   const lockExpiry = Date.now() + (paymentRequired ? 35 : 3) * 60_000;
   const keys = slotKeys(lockStart, lockEnd, settings.slotIncrementMinutes);
 
@@ -692,12 +887,13 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
   const statements: D1PreparedStatement[] = [
     env.DB.prepare(`
       INSERT INTO bookings (
-        id, name, email, phone, message, start_time, end_time,
+        id, public_booking_id, name, email, phone, message, start_time, end_time,
         duration_minutes, timezone, meeting_mode, status,
         payment_status, payment_amount, payment_currency
-      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'pending', ?11, ?12, ?13)
+      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12, ?13, ?14)
     `).bind(
       id,
+      publicBookingId,
       name,
       email,
       phone || null,
@@ -774,6 +970,13 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
             updated_at = datetime('now')
         WHERE id = ?2
       `).bind(session.id, id).run();
+      await logActivity(env, id, 'payment_checkout_started', {
+        start: new Date(startMs).toISOString(),
+        end: new Date(endMs).toISOString(),
+        duration,
+        amount: amountMinor,
+        currency: settings.paymentCurrency,
+      }, `checkout:${session.id}:opened`);
 
       return json({
         ok: true,
@@ -787,6 +990,7 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
 
     const created = await createCalendarEvent(env, settings, {
       id,
+      publicBookingId,
       name,
       email,
       phone: phone || undefined,
@@ -807,12 +1011,19 @@ async function createBooking(env: Env, httpRequest: Request, request: BookingReq
       env.DB.prepare('UPDATE slot_locks SET expires_at = ?1 WHERE booking_id = ?2')
         .bind(Date.now() + 10 * 60_000, id),
     ]);
+    await logActivity(env, id, 'booking_confirmed', {
+      start: new Date(startMs).toISOString(),
+      end: new Date(endMs).toISOString(),
+      duration,
+      paid: false,
+    }, `booking:${id}:confirmed`);
 
     return json({
       ok: true,
       requiresPayment: false,
       booking: {
         id,
+        bookingId: publicBookingId,
         start: new Date(startMs).toISOString(),
         end: new Date(endMs).toISOString(),
         duration,
@@ -1018,6 +1229,11 @@ async function handleStripeWebhook(request: Request, env: Env): Promise<Response
               updated_at = datetime('now')
           WHERE id = ?4
         `).bind(status, refund.id, refund.amount ?? null, bookingId).run();
+        await logActivity(env, bookingId,
+          status === 'succeeded' ? 'refund_succeeded' : status === 'failed' ? 'refund_failed' : 'refund_started',
+          { amount: refund.amount ?? null, currency: refund.currency ?? null, refundStatus: status },
+          `refund:${refund.id}:${status}`,
+        );
       }
       return json({ received: true });
     }
@@ -1084,6 +1300,7 @@ async function rescheduleConfirmedBooking(
   env: Env,
   booking: PaymentBookingRow,
   startIso: string,
+  source: 'admin' | 'guest' = 'admin',
 ): Promise<PaymentBookingRow> {
   if (booking.status !== 'confirmed') throw new Error('Only confirmed bookings can be rescheduled.');
   if (!booking.google_event_id) throw new Error('This booking is missing its Google Calendar event.');
@@ -1112,6 +1329,7 @@ async function rescheduleConfirmedBooking(
       phone: booking.phone ?? undefined,
       message: booking.message ?? undefined,
       paid: booking.payment_status === 'paid',
+      publicBookingId: booking.public_booking_id ?? undefined,
       start: new Date(startMs).toISOString(),
       end: new Date(endMs).toISOString(),
     });
@@ -1144,6 +1362,7 @@ async function rescheduleConfirmedBooking(
         phone: booking.phone ?? undefined,
         message: booking.message ?? undefined,
         paid: booking.payment_status === 'paid',
+        publicBookingId: booking.public_booking_id ?? undefined,
         start: booking.start_time,
         end: booking.end_time,
       }).catch(() => undefined);
@@ -1156,6 +1375,14 @@ async function rescheduleConfirmedBooking(
 
   const result = await paymentBookingById(env, booking.id);
   if (!result) throw new Error('Rescheduled booking record was not found.');
+  await logActivity(env, booking.id, source === 'guest' ? 'time_suggestion_accepted' : 'booking_rescheduled', {
+    source,
+    oldStart: booking.start_time,
+    oldEnd: booking.end_time,
+    newStart: result.start_time,
+    newEnd: result.end_time,
+    duration: booking.duration_minutes,
+  }, `booking:${booking.id}:reschedule:${result.reschedule_count}`);
   return result;
 }
 
@@ -1164,7 +1391,7 @@ async function adminRescheduleBooking(env: Env, bookingId: string, start: string
   const booking = await paymentBookingById(env, bookingId);
   if (!booking) return json({ error: 'Booking not found.' }, 404);
   try {
-    const updated = await rescheduleConfirmedBooking(env, booking, start);
+    const updated = await rescheduleConfirmedBooking(env, booking, start, 'admin');
     return json({ ok: true, booking: publicConfirmation(updated) });
   } catch (error) {
     if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
@@ -1200,6 +1427,7 @@ async function adminSuggestNewTime(env: Env, bookingId: string, start: string):
         phone: booking.phone ?? undefined,
         message: booking.message ?? undefined,
         paid: booking.payment_status === 'paid',
+        publicBookingId: booking.public_booking_id ?? undefined,
       }, {
         start: new Date(startMs).toISOString(),
         end: new Date(endMs).toISOString(),
@@ -1215,6 +1443,14 @@ async function adminSuggestNewTime(env: Env, bookingId: string, start: string):
       throw error;
     }
 
+    await logActivity(env, booking.id, 'time_suggested', {
+      currentStart: booking.start_time,
+      currentEnd: booking.end_time,
+      proposedStart: new Date(startMs).toISOString(),
+      proposedEnd: new Date(endMs).toISOString(),
+      duration: booking.duration_minutes,
+    }, `suggest:${tokenHash}`);
+
     return json({
       ok: true,
       suggestionUrl,
@@ -1231,7 +1467,7 @@ async function proposalBookingByToken(env: Env, token: string): Promise<PaymentB
   if (!/^[A-Za-z0-9_-]{20,200}$/.test(token)) return null;
   const hash = await sha256Base64Url(token);
   return env.DB.prepare(`
-    SELECT id, name, email, phone, message, start_time, end_time, duration_minutes,
+    SELECT id, public_booking_id, name, email, phone, message, start_time, end_time, duration_minutes,
            timezone, meeting_mode, status, google_event_id, meet_url, payment_status,
            payment_amount, payment_currency, stripe_checkout_session_id, stripe_payment_intent_id, paid_at,
            rescheduled_at, reschedule_count, proposed_start_time, proposed_end_time, proposed_at,
@@ -1268,7 +1504,7 @@ async function acceptRescheduleProposal(env: Env, token: string): Promise<Respon
     return json({ error: 'This reschedule suggestion is invalid or no longer active.' }, 404);
   }
   try {
-    const updated = await rescheduleConfirmedBooking(env, booking, booking.proposed_start_time);
+    const updated = await rescheduleConfirmedBooking(env, booking, booking.proposed_start_time, 'guest');
     return json({ ok: true, booking: publicConfirmation(updated) });
   } catch (error) {
     if (error instanceof GoogleCalendarError) return json({ error: error.publicMessage }, 502);
@@ -1343,6 +1579,11 @@ async function cancelBooking(env: Env, bookingId: string, allowStaleStripeReleas
               updated_at = datetime('now')
           WHERE id = ?4
         `).bind(refundStatus, refund.id, refund.amount, booking.id).run();
+        await logActivity(env, booking.id, 'refund_failed', {
+          amount: refund.amount,
+          currency: booking.payment_currency,
+          refundStatus,
+        }, `refund:${refund.id}:${refundStatus}`);
         return json({ error: `Stripe created refund ${refund.id}, but its status is ${refundStatus}. The booking has not been cancelled.` }, 502);
       }
       refunded = true;
@@ -1355,6 +1596,11 @@ async function cancelBooking(env: Env, bookingId: string, allowStaleStripeReleas
             updated_at = datetime('now')
         WHERE id = ?4
       `).bind(refundStatus, refund.id, refund.amount, booking.id).run();
+      await logActivity(env, booking.id, refundStatus === 'succeeded' ? 'refund_succeeded' : 'refund_started', {
+        amount: refund.amount,
+        currency: booking.payment_currency,
+        refundStatus,
+      }, `refund:${refund.id}:${refundStatus}`);
       if (refundStatus === 'pending') {
         refundWarning = 'Stripe accepted the refund, but it is still pending.';
       }
@@ -1402,6 +1648,13 @@ async function cancelBooking(env: Env, bookingId: string, allowStaleStripeReleas
     `).bind(calendarWarning ? 'failed' : 'synced', calendarWarning, bookingId),
     env.DB.prepare('DELETE FROM slot_locks WHERE booking_id = ?1').bind(bookingId),
   ]);
+  await logActivity(env, bookingId, 'booking_cancelled', {
+    start: booking.start_time,
+    end: booking.end_time,
+    duration: booking.duration_minutes,
+    refunded,
+    refundStatus,
+  }, `booking:${bookingId}:cancelled`);
 
   return json({
     ok: true,
@@ -1411,6 +1664,28 @@ async function cancelBooking(env: Env, bookingId: string, allowStaleStripeReleas
   });
 }
 
+async function publicCancelBooking(request: Request, env: Env, value: unknown): Promise<Response> {
+  if (!(await enforceCancellationRateLimit(request, env))) {
+    return json({ error: 'Too many cancellation attempts. Try again later.' }, 429);
+  }
+
+  const publicBookingId = normalizePublicBookingId(value);
+  if (!publicBookingId) return json({ error: 'Enter a valid Slot booking ID.' }, 400);
+
+  const row = await env.DB.prepare(`
+    SELECT id, status, end_time
+    FROM bookings
+    WHERE public_booking_id = ?1
+  `).bind(publicBookingId).first<{ id: string; status: string; end_time: string }>();
+
+  if (!row) return json({ error: 'Booking ID not found.' }, 404);
+  if (row.status === 'cancelled') return json({ ok: true, alreadyCancelled: true });
+  if (row.status !== 'confirmed') return json({ error: 'This booking is not currently confirmed.' }, 409);
+  if (Date.parse(row.end_time) <= Date.now()) return json({ error: 'Past bookings cannot be cancelled.' }, 409);
+
+  return cancelBooking(env, row.id);
+}
+
 function cleanLabel(value: unknown): string | null {
   const label = String(value ?? '').trim().slice(0, 80);
   return label || null;
@@ -1557,6 +1832,11 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
     return createBooking(env, request, body);
   }
 
+  if (request.method === 'POST' && url.pathname === '/api/bookings/cancel') {
+    const body = await request.json().catch(() => ({})) as { bookingId?: string };
+    return publicCancelBooking(request, env, body.bookingId);
+  }
+
   if (request.method === 'POST' && url.pathname === '/api/payments/checkout') {
     const body = (await request.json()) as { bookingId?: string };
     return createPaymentCheckout(env, String(body.bookingId ?? ''));
@@ -1611,11 +1891,15 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
       return json({ ok: true, settings });
     }
 
+    if (request.method === 'GET' && url.pathname === '/api/admin/activity') {
+      return adminActivity(env);
+    }
+
     if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
       const settings = await getSettings(env);
       await cleanupStalePendingBookings(env, settings);
       const result = await env.DB.prepare(`
-        SELECT id, name, email, start_time, end_time, duration_minutes,
+        SELECT id, public_booking_id, name, email, start_time, end_time, duration_minutes,
                meeting_mode, status, meet_url, created_at,
                cancelled_at, calendar_provider, calendar_sync_status,
                calendar_sync_error, calendar_sync_updated_at,