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