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 attendee insights and booking search

862116d
Alex Nord <[email protected]> 22 hours ago
README.md       |   8 +++
 src/App.tsx     | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/styles.css  | 151 ++++++++++++++++++++++++++++++++++++++++++++
 worker/index.ts | 151 ++++++++++++++++++++++++++++++++++++++++++--
 4 files changed, 491 insertions(+), 11 deletions(-)

Diff

diff --git a/README.md b/README.md
index 6fba5f3..042330c 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,8 @@ No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service,
 - 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
+- Admin Attendees dashboard groups booking history by attendee email, showing repeat bookings, completions, cancellations, reschedules, booked minutes, and payment/refund counts when payments are enabled
+- Admin booking search matches public `SLT-...` booking IDs, attendee names, and email addresses across upcoming and past bookings
 
 ## First setup
 
@@ -242,6 +244,12 @@ Open `/admin` -> **Activity** for an all-time operational snapshot and recent li
 
 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.
 
+## Attendees and booking search
+
+Open `/admin` -> **Attendees** to see repeat-booking history grouped by normalized attendee email. Each attendee shows total bookings, upcoming/completed meetings, cancellations, reschedules, booked minutes, average duration, and (when Stripe payments are enabled) paid/refunded booking counts. Opening an attendee shows their recent booking history and links back into the normal booking detail view.
+
+The **Bookings** tab also has server-backed search. Search by the public `SLT-...` booking ID, internal booking UUID, attendee name, or attendee email; results can still be switched between **Upcoming** and **Past**. Search is not limited to the initially loaded booking list.
+
 ## 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/src/App.tsx b/src/App.tsx
index 875ab5b..3ba0fc8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,8 +17,10 @@ import {
   MapPin,
   MonitorUp,
   Phone,
+  Search,
   Settings2,
   Sparkles,
+  Users,
   Video,
 } from 'lucide-react';
 import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
@@ -164,6 +166,26 @@ type ActivityData = {
   events: ActivityEvent[];
 };
 
+type AttendeeRow = {
+  email: string;
+  name: string;
+  total_bookings: number;
+  upcoming: number;
+  completed: number;
+  cancelled: number;
+  reschedules: number;
+  paid_bookings: number;
+  refunded_bookings: number;
+  total_minutes: number;
+  average_minutes: number;
+  first_booked_at?: string | null;
+  last_booked_at?: string | null;
+};
+
+type AttendeeData = {
+  paymentsEnabled: boolean;
+  attendees: AttendeeRow[];
+};
 
 type RescheduleProposal = {
   title: string;
@@ -1277,8 +1299,17 @@ function Admin() {
   const [availabilityBlocks, setAvailabilityBlocks] = useState<AvailabilityBlock[]>([]);
   const [activity, setActivity] = useState<ActivityData | null>(null);
   const [activityLoading, setActivityLoading] = useState(false);
-  const [tab, setTab] = useState<'settings' | 'bookings' | 'activity'>('bookings');
+  const [attendeeData, setAttendeeData] = useState<AttendeeData | null>(null);
+  const [attendeesLoading, setAttendeesLoading] = useState(false);
+  const [attendeeSearch, setAttendeeSearch] = useState('');
+  const [selectedAttendee, setSelectedAttendee] = useState<AttendeeRow | null>(null);
+  const [attendeeBookings, setAttendeeBookings] = useState<BookingRow[]>([]);
+  const [attendeeBookingsLoading, setAttendeeBookingsLoading] = useState(false);
+  const [tab, setTab] = useState<'settings' | 'bookings' | 'activity' | 'attendees'>('bookings');
   const [bookingFilter, setBookingFilter] = useState<'upcoming' | 'past'>('upcoming');
+  const [bookingSearch, setBookingSearch] = useState('');
+  const [bookingSearchResults, setBookingSearchResults] = useState<BookingRow[] | null>(null);
+  const [bookingSearchLoading, setBookingSearchLoading] = useState(false);
   const [selectedBooking, setSelectedBooking] = useState<BookingRow | null>(null);
   const [cancelTarget, setCancelTarget] = useState<BookingRow | null>(null);
   const [cancelling, setCancelling] = useState(false);
@@ -1344,6 +1375,32 @@ function Admin() {
     void loadActivity();
   }, [session?.authenticated, tab]);
 
+  useEffect(() => {
+    if (!session?.authenticated || tab !== 'attendees') return;
+    void loadAttendees();
+  }, [session?.authenticated, tab]);
+
+  useEffect(() => {
+    if (!session?.authenticated || tab !== 'bookings') return;
+    const query = bookingSearch.trim();
+    if (!query) {
+      setBookingSearchResults(null);
+      setBookingSearchLoading(false);
+      return;
+    }
+    let cancelled = false;
+    setError('');
+    setBookingSearchResults(null);
+    const timer = window.setTimeout(() => {
+      setBookingSearchLoading(true);
+      api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`)
+        .then((data) => { if (!cancelled) setBookingSearchResults(data.bookings); })
+        .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Could not search bookings.'); })
+        .finally(() => { if (!cancelled) setBookingSearchLoading(false); });
+    }, 180);
+    return () => { cancelled = true; window.clearTimeout(timer); };
+  }, [session?.authenticated, tab, bookingSearch]);
+
   useEffect(() => {
     if (!scheduleTarget || !scheduleDate) {
       setScheduleSlots([]);
@@ -1421,14 +1478,51 @@ function Admin() {
     }
   }
 
+  async function loadAttendees() {
+    setAttendeesLoading(true);
+    setError('');
+    try {
+      setAttendeeData(await api<AttendeeData>('/api/admin/attendees'));
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not load attendees.');
+    } finally {
+      setAttendeesLoading(false);
+    }
+  }
+
+  async function openAttendee(attendee: AttendeeRow) {
+    setSelectedAttendee(attendee);
+    setAttendeeBookings([]);
+    setAttendeeBookingsLoading(true);
+    setError('');
+    try {
+      const data = await api<{ bookings: BookingRow[] }>(`/api/admin/attendee-bookings?email=${encodeURIComponent(attendee.email)}`);
+      setAttendeeBookings(data.bookings);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Could not load attendee history.');
+    } finally {
+      setAttendeeBookingsLoading(false);
+    }
+  }
+
+  function openBookingFromAttendee(booking: BookingRow) {
+    setTab('bookings');
+    setBookingSearch(booking.public_booking_id || booking.id);
+    setSelectedBooking(booking);
+  }
+
   async function refreshAdminData() {
-    const [bookingData, availabilityData] = await Promise.all([
+    const query = bookingSearch.trim();
+    const [bookingData, availabilityData, searchedData] = await Promise.all([
       api<{ bookings: BookingRow[] }>('/api/admin/bookings'),
       api<{ recurring: RecurringUnavailableRule[]; blocks: AvailabilityBlock[] }>('/api/admin/availability-rules'),
+      query ? api<{ bookings: BookingRow[] }>(`/api/admin/bookings?q=${encodeURIComponent(query)}`) : Promise.resolve(null),
     ]);
     setBookings(bookingData.bookings);
+    if (searchedData) setBookingSearchResults(searchedData.bookings);
     setRecurringUnavailable(availabilityData.recurring);
     setAvailabilityBlocks(availabilityData.blocks);
+    if (attendeeData) void loadAttendees();
   }
 
   async function save() {
@@ -1556,9 +1650,14 @@ function Admin() {
 
   const activeSettings = settings;
   const now = Date.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 bookingSource = bookingSearch.trim() ? (bookingSearchResults ?? []) : bookings;
+  const upcoming = bookingSource.filter((b) => b.status !== 'cancelled' && Date.parse(b.end_time) >= now);
+  const past = bookingSource.filter((b) => b.status === 'cancelled' || Date.parse(b.end_time) < now);
   const shown = bookingFilter === 'upcoming' ? upcoming : past;
+  const attendeeNeedle = attendeeSearch.trim().toLowerCase();
+  const shownAttendees = (attendeeData?.attendees ?? []).filter((attendee) => !attendeeNeedle
+    || attendee.name.toLowerCase().includes(attendeeNeedle)
+    || attendee.email.toLowerCase().includes(attendeeNeedle));
 
   return (
     <main className="admin-page">
@@ -1574,6 +1673,7 @@ function Admin() {
           <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>
+          <button className={tab === 'attendees' ? 'active' : ''} onClick={() => setTab('attendees')}><Users size={17} /> Attendees</button>
           <a href="/" target="_blank"><ExternalLink size={17} /> Open booking page</a>
         </aside>
 
@@ -1900,6 +2000,71 @@ function Admin() {
                 <section className="activity-loading">Activity could not be loaded.</section>
               )}
             </>
+          ) : tab === 'attendees' ? (
+            <>
+              <div className="admin-heading">
+                <div><p className="eyebrow">ATTENDEES</p><h1>Who keeps booking.</h1></div>
+                <button className="secondary-button activity-refresh" type="button" onClick={loadAttendees} disabled={attendeesLoading}>
+                  {attendeesLoading ? <><Loader2 className="spin" size={16} /> Refreshing…</> : <><Users size={16} /> Refresh</>}
+                </button>
+              </div>
+              {error && <div className="error-banner" role="alert">{error}</div>}
+              <label className="admin-search attendee-search">
+                <Search size={16} aria-hidden="true" />
+                <input value={attendeeSearch} onChange={(event) => setAttendeeSearch(event.target.value)} placeholder="Search attendee name or email" aria-label="Search attendees" />
+              </label>
+              {attendeesLoading && !attendeeData ? (
+                <section className="activity-loading"><Loader2 className="spin" size={20} /> Loading attendees…</section>
+              ) : attendeeData ? (
+                <>
+                  <section className="attendee-list-card">
+                    <div className="attendee-list-head">
+                      <span>Attendee</span><span>Bookings</span><span>Completed</span><span>Cancelled</span><span>Reschedules</span><span>Last booked</span><span />
+                    </div>
+                    {shownAttendees.length === 0 ? (
+                      <div className="empty-list activity-empty"><Users size={25} /><strong>No attendees found</strong><span>{attendeeSearch ? 'Try another name or email.' : 'Confirmed attendees will appear here after their first booking.'}</span></div>
+                    ) : shownAttendees.map((attendee) => (
+                      <article className="attendee-list-row" key={attendee.email.toLowerCase()}>
+                        <div className="attendee-person"><span className="attendee-avatar" aria-hidden="true">{attendee.name.slice(0, 1).toUpperCase()}</span><div><strong>{attendee.name}</strong><span>{attendee.email}</span></div></div>
+                        <strong>{attendee.total_bookings}</strong>
+                        <span>{attendee.completed}</span>
+                        <span>{attendee.cancelled}</span>
+                        <span>{attendee.reschedules}</span>
+                        <time>{attendee.last_booked_at ? formatInstant(databaseInstant(attendee.last_booked_at), activeSettings.timezone) : '—'}</time>
+                        <button type="button" className="icon-button" aria-label={`Open ${attendee.name} attendee history`} onClick={() => void openAttendee(attendee)}><Inbox size={15} /></button>
+                      </article>
+                    ))}
+                  </section>
+                  {selectedAttendee && (
+                    <section className="booking-detail-card attendee-detail-card">
+                      <div className="card-heading">
+                        <div><h2>{selectedAttendee.name}</h2><p>{selectedAttendee.email}</p></div>
+                        <button type="button" className="text-button" onClick={() => setSelectedAttendee(null)}>Close</button>
+                      </div>
+                      <div className="attendee-metrics">
+                        <article><span>Bookings</span><strong>{selectedAttendee.total_bookings}</strong></article>
+                        <article><span>Upcoming</span><strong>{selectedAttendee.upcoming}</strong></article>
+                        <article><span>Completed</span><strong>{selectedAttendee.completed}</strong></article>
+                        <article><span>Cancelled</span><strong>{selectedAttendee.cancelled}</strong></article>
+                        <article><span>Reschedules</span><strong>{selectedAttendee.reschedules}</strong></article>
+                        <article><span>Booked time</span><strong>{selectedAttendee.total_minutes} min</strong></article>
+                        <article><span>Average length</span><strong>{selectedAttendee.average_minutes} min</strong></article>
+                        {attendeeData.paymentsEnabled && <article><span>Paid bookings</span><strong>{selectedAttendee.paid_bookings}</strong></article>}
+                        {attendeeData.paymentsEnabled && <article><span>Refunded</span><strong>{selectedAttendee.refunded_bookings}</strong></article>}
+                      </div>
+                      <div className="attendee-history-heading"><div><p className="eyebrow">BOOKING HISTORY</p><h3>Recent meetings</h3></div><span>{attendeeBookings.length} shown</span></div>
+                      {attendeeBookingsLoading ? (
+                        <div className="activity-loading attendee-history-loading"><Loader2 className="spin" size={18} /> Loading booking history…</div>
+                      ) : attendeeBookings.length ? (
+                        <div className="attendee-booking-history">{attendeeBookings.map((booking) => <BookingRowItem key={booking.id} booking={booking} timezone={activeSettings.timezone} onOpen={openBookingFromAttendee} />)}</div>
+                      ) : (
+                        <div className="empty-list attendee-history-empty"><Inbox size={22} /><strong>No booking history</strong></div>
+                      )}
+                    </section>
+                  )}
+                </>
+              ) : <section className="activity-loading">Attendees could not be loaded.</section>}
+            </>
           ) : (
             <>
               <div className="admin-heading"><div><p className="eyebrow">BOOKINGS</p><h1>Your meetings.</h1></div></div>
@@ -1923,12 +2088,25 @@ function Admin() {
                   Past <span className="count">{past.length}</span>
                 </button>
               </div>
+              <label className="admin-search booking-search">
+                <Search size={16} aria-hidden="true" />
+                <input
+                  value={bookingSearch}
+                  onChange={(event) => setBookingSearch(event.target.value)}
+                  placeholder="Search booking ID, name or email"
+                  aria-label="Search bookings by booking ID, name or email"
+                  autoComplete="off"
+                />
+                {bookingSearchLoading && <Loader2 className="spin admin-search-spinner" size={15} aria-label="Searching" />}
+              </label>
               <section className="booking-list-card">
-                {shown.length === 0 ? (
+                {bookingSearchLoading && bookingSearch.trim() && bookingSearchResults === null ? (
+                  <div className="empty-list"><Loader2 className="spin" size={23} /><strong>Searching bookings…</strong></div>
+                ) : shown.length === 0 ? (
                   <div className="empty-list">
                     <Inbox size={26} />
-                    <strong>{bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
-                    <span>{bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
+                    <strong>{bookingSearch.trim() ? 'No matching bookings' : bookingFilter === 'upcoming' ? 'No upcoming bookings' : 'No past bookings'}</strong>
+                    <span>{bookingSearch.trim() ? `No ${bookingFilter} booking matches “${bookingSearch.trim()}”.` : bookingFilter === 'upcoming' ? 'No Slot bookings are upcoming. Google Calendar busy time and availability rules can still make public times unavailable.' : 'Completed meetings will be listed here.'}</span>
                   </div>
                 ) : (
                   shown.map((booking) => (
diff --git a/src/styles.css b/src/styles.css
index b0d1505..c5b4734 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1768,3 +1768,154 @@ select { width: 100%; color: var(--text); background: var(--surface-soft); borde
   font-size: 11px;
   letter-spacing: .015em;
 }
+
+/* Admin search + attendees */
+.admin-search {
+  width: min(100%, 520px);
+  min-height: 42px;
+  margin: 0 0 16px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  background: var(--surface);
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  padding: 0 12px;
+  color: var(--faint);
+}
+.admin-search:focus-within { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
+.admin-search input {
+  min-width: 0;
+  flex: 1;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: var(--text);
+  font-size: 12px;
+  padding: 10px 0;
+}
+.admin-search input::placeholder { color: var(--faint); }
+.admin-search-spinner { flex: 0 0 auto; }
+.attendee-search { width: min(100%, 560px); }
+
+.attendee-list-card {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  background: var(--surface);
+  overflow: hidden;
+}
+.attendee-list-head,
+.attendee-list-row {
+  display: grid;
+  grid-template-columns: minmax(220px, 1.55fr) 78px 78px 78px 88px minmax(145px, .9fr) 34px;
+  gap: 12px;
+  align-items: center;
+}
+.attendee-list-head {
+  padding: 10px 17px;
+  border-bottom: 1px solid var(--border);
+  color: var(--faint);
+  background: var(--surface-soft);
+  font-size: 9px;
+  font-weight: 760;
+  letter-spacing: .06em;
+  text-transform: uppercase;
+}
+.attendee-list-row {
+  min-height: 66px;
+  padding: 12px 17px;
+  border-top: 1px solid var(--border);
+  color: var(--muted);
+  font-size: 10px;
+  font-variant-numeric: tabular-nums;
+}
+.attendee-list-row:first-of-type { border-top: 0; }
+.attendee-list-row:hover { background: var(--surface-soft); }
+.attendee-list-row > strong { color: var(--text); font-size: 13px; }
+.attendee-list-row > time { color: var(--faint); font-size: 9px; white-space: nowrap; }
+.attendee-person { min-width: 0; display: flex; align-items: center; gap: 11px; }
+.attendee-avatar {
+  width: 36px;
+  height: 36px;
+  flex: 0 0 36px;
+  display: grid;
+  place-items: center;
+  border-radius: var(--radius-xs);
+  background: var(--surface-strong);
+  color: var(--text);
+  font-size: 13px;
+  font-weight: 780;
+}
+.attendee-person > div { min-width: 0; display: grid; gap: 3px; }
+.attendee-person strong,
+.attendee-person span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.attendee-person strong { color: var(--text); font-size: 12px; }
+.attendee-person span { color: var(--faint); font-size: 10px; }
+.attendee-detail-card { margin-top: 16px; }
+.attendee-metrics {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 8px;
+  margin-bottom: 22px;
+}
+.attendee-metrics article {
+  min-width: 0;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  background: var(--surface-soft);
+  padding: 12px;
+  display: grid;
+  gap: 7px;
+}
+.attendee-metrics span { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .05em; font-weight: 730; }
+.attendee-metrics strong { color: var(--text); font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.attendee-history-heading {
+  display: flex;
+  align-items: end;
+  justify-content: space-between;
+  gap: 14px;
+  padding: 15px 0 11px;
+  border-top: 1px solid var(--border);
+}
+.attendee-history-heading h3 { font-size: 16px; margin-top: 3px; }
+.attendee-history-heading > span { color: var(--faint); font-size: 9px; }
+.attendee-booking-history { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
+.attendee-history-loading { min-height: 110px; border-radius: var(--radius-md); }
+.attendee-history-empty { min-height: 110px; border: 1px solid var(--border); border-radius: var(--radius-md); }
+
+@media (max-width: 1180px) {
+  .attendee-list-head,
+  .attendee-list-row { grid-template-columns: minmax(210px, 1.5fr) 70px 70px 70px minmax(135px, .9fr) 34px; }
+  .attendee-list-head > :nth-child(5),
+  .attendee-list-row > :nth-child(5) { display: none; }
+  .attendee-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 760px) {
+  .admin-search { width: 100%; }
+  .attendee-list-head { display: none; }
+  .attendee-list-row {
+    grid-template-columns: minmax(0, 1fr) auto auto;
+    grid-template-areas:
+      "person bookings action"
+      "person cancelled action"
+      "last last last";
+    gap: 5px 12px;
+    padding: 14px;
+  }
+  .attendee-list-row > :nth-child(2) { grid-area: bookings; }
+  .attendee-list-row > :nth-child(2)::before { content: 'Bookings '; color: var(--faint); font-size: 8px; font-weight: 600; }
+  .attendee-list-row > :nth-child(3),
+  .attendee-list-row > :nth-child(5) { display: none; }
+  .attendee-list-row > :nth-child(4) { grid-area: cancelled; }
+  .attendee-list-row > :nth-child(4)::before { content: 'Cancelled '; color: var(--faint); font-size: 8px; }
+  .attendee-list-row > time { grid-area: last; padding-left: 47px; }
+  .attendee-list-row > button { grid-area: action; }
+  .attendee-person { grid-area: person; }
+  .attendee-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .attendee-history-heading { align-items: flex-start; flex-direction: column; }
+}
+
+@media (max-width: 480px) {
+  .attendee-metrics { grid-template-columns: 1fr 1fr; }
+}
diff --git a/worker/index.ts b/worker/index.ts
index 8d46757..df1dbbb 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -593,6 +593,129 @@ async function adminActivity(env: Env): Promise<Response> {
   });
 }
 
+async function adminAttendees(env: Env): Promise<Response> {
+  const settings = await getSettings(env);
+  await cleanupStalePendingBookings(env, settings);
+
+  const result = await env.DB.prepare(`
+    WITH facts AS (
+      SELECT
+        lower(trim(email)) AS email_key,
+        status,
+        start_time,
+        end_time,
+        duration_minutes,
+        payment_status,
+        paid_at,
+        refund_status,
+        refunded_at,
+        reschedule_count,
+        created_at,
+        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
+      WHERE trim(email) <> ''
+    )
+    SELECT
+      f.email_key,
+      (
+        SELECT b2.email FROM bookings b2
+        WHERE lower(trim(b2.email)) = f.email_key
+          AND (b2.status = 'confirmed' OR (b2.status = 'cancelled' AND (b2.payment_status <> 'cancelled' OR b2.paid_at IS NOT NULL)))
+        ORDER BY datetime(b2.created_at) DESC, b2.rowid DESC
+        LIMIT 1
+      ) AS email,
+      (
+        SELECT b2.name FROM bookings b2
+        WHERE lower(trim(b2.email)) = f.email_key
+          AND (b2.status = 'confirmed' OR (b2.status = 'cancelled' AND (b2.payment_status <> 'cancelled' OR b2.paid_at IS NOT NULL)))
+        ORDER BY datetime(b2.created_at) DESC, b2.rowid DESC
+        LIMIT 1
+      ) AS name,
+      COALESCE(SUM(f.is_booking), 0) AS total_bookings,
+      COALESCE(SUM(CASE WHEN f.status = 'confirmed' AND julianday(f.end_time) >= julianday('now') THEN 1 ELSE 0 END), 0) AS upcoming,
+      COALESCE(SUM(CASE WHEN f.status = 'confirmed' AND julianday(f.end_time) < julianday('now') THEN 1 ELSE 0 END), 0) AS completed,
+      COALESCE(SUM(CASE WHEN f.status = 'cancelled' AND f.is_booking = 1 THEN 1 ELSE 0 END), 0) AS cancelled,
+      COALESCE(SUM(CASE WHEN f.is_booking = 1 THEN f.reschedule_count ELSE 0 END), 0) AS reschedules,
+      COALESCE(SUM(CASE WHEN f.is_booking = 1 AND f.paid_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS paid_bookings,
+      COALESCE(SUM(CASE WHEN f.is_booking = 1 AND (f.refunded_at IS NOT NULL OR f.refund_status = 'succeeded') THEN 1 ELSE 0 END), 0) AS refunded_bookings,
+      COALESCE(SUM(CASE WHEN f.is_booking = 1 THEN f.duration_minutes ELSE 0 END), 0) AS total_minutes,
+      COALESCE(ROUND(AVG(CASE WHEN f.is_booking = 1 THEN f.duration_minutes END), 1), 0) AS average_minutes,
+      MIN(CASE WHEN f.is_booking = 1 THEN f.created_at END) AS first_booked_at,
+      MAX(CASE WHEN f.is_booking = 1 THEN f.created_at END) AS last_booked_at
+    FROM facts f
+    GROUP BY f.email_key
+    HAVING SUM(f.is_booking) > 0
+    ORDER BY MAX(CASE WHEN f.is_booking = 1 THEN datetime(f.created_at) END) DESC
+    LIMIT 500
+  `).all<{
+    email_key: string;
+    email: string;
+    name: string;
+    total_bookings: number;
+    upcoming: number;
+    completed: number;
+    cancelled: number;
+    reschedules: number;
+    paid_bookings: number;
+    refunded_bookings: number;
+    total_minutes: number;
+    average_minutes: number;
+    first_booked_at: string | null;
+    last_booked_at: string | null;
+  }>();
+
+  return json({
+    paymentsEnabled: settings.paymentEnabled,
+    attendees: (result.results ?? []).map((row) => ({
+      email: row.email,
+      name: row.name,
+      total_bookings: Number(row.total_bookings ?? 0),
+      upcoming: Number(row.upcoming ?? 0),
+      completed: Number(row.completed ?? 0),
+      cancelled: Number(row.cancelled ?? 0),
+      reschedules: Number(row.reschedules ?? 0),
+      paid_bookings: Number(row.paid_bookings ?? 0),
+      refunded_bookings: Number(row.refunded_bookings ?? 0),
+      total_minutes: Number(row.total_minutes ?? 0),
+      average_minutes: Number(row.average_minutes ?? 0),
+      first_booked_at: row.first_booked_at,
+      last_booked_at: row.last_booked_at,
+    })),
+  });
+}
+
+async function adminAttendeeBookings(env: Env, email: string): Promise<Response> {
+  const normalizedEmail = email.trim().toLowerCase();
+  if (!normalizedEmail || normalizedEmail.length > 320 || !normalizedEmail.includes('@')) {
+    return json({ error: 'Invalid attendee email.' }, 400);
+  }
+
+  const result = await env.DB.prepare(`
+    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,
+           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, proposal_status, refund_status, stripe_refund_id,
+           refunded_amount, refunded_at
+    FROM bookings
+    WHERE lower(trim(email)) = ?1
+      AND (
+        status = 'confirmed'
+        OR (status = 'cancelled' AND (payment_status <> 'cancelled' OR paid_at IS NOT NULL))
+      )
+    ORDER BY datetime(created_at) DESC, start_time DESC
+    LIMIT 100
+  `).bind(normalizedEmail).all();
+  return json({ bookings: result.results ?? [] });
+}
+
 async function paymentBookingById(env: Env, bookingId: string): Promise<PaymentBookingRow | null> {
   return env.DB.prepare(`
     SELECT id, public_booking_id, name, email, phone, message, start_time, end_time, duration_minutes,
@@ -1895,10 +2018,19 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
       return adminActivity(env);
     }
 
+    if (request.method === 'GET' && url.pathname === '/api/admin/attendees') {
+      return adminAttendees(env);
+    }
+
+    if (request.method === 'GET' && url.pathname === '/api/admin/attendee-bookings') {
+      return adminAttendeeBookings(env, url.searchParams.get('email') ?? '');
+    }
+
     if (request.method === 'GET' && url.pathname === '/api/admin/bookings') {
       const settings = await getSettings(env);
       await cleanupStalePendingBookings(env, settings);
-      const result = await env.DB.prepare(`
+      const query = (url.searchParams.get('q') ?? '').trim().toLowerCase();
+      const select = `
         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,
@@ -1909,9 +2041,20 @@ async function handleApi(request: Request, env: Env, url: URL): Promise<Response
                proposed_at, proposal_status, refund_status, stripe_refund_id,
                refunded_amount, refunded_at
         FROM bookings
-        ORDER BY start_time DESC
-        LIMIT 50
-      `).all();
+      `;
+      const result = query
+        ? await env.DB.prepare(`${select}
+            WHERE lower(COALESCE(public_booking_id, '')) LIKE ?1
+               OR lower(id) LIKE ?1
+               OR lower(name) LIKE ?1
+               OR lower(email) LIKE ?1
+            ORDER BY start_time DESC
+            LIMIT 100
+          `).bind(`%${query}%`).all()
+        : await env.DB.prepare(`${select}
+            ORDER BY start_time DESC
+            LIMIT 100
+          `).all();
       return json({ bookings: result.results ?? [] });
     }