diff --git a/modules/rschedule/components/folk-schedule-admin.ts b/modules/rschedule/components/folk-schedule-admin.ts index 5761a553..dade76af 100644 --- a/modules/rschedule/components/folk-schedule-admin.ts +++ b/modules/rschedule/components/folk-schedule-admin.ts @@ -102,3 +102,5 @@ function escapeHtml(s: string): string { } customElements.define("folk-schedule-admin", FolkScheduleAdmin); + +export {}; diff --git a/modules/rschedule/components/folk-schedule-booking.ts b/modules/rschedule/components/folk-schedule-booking.ts index f9bf5f64..dfdb18c5 100644 --- a/modules/rschedule/components/folk-schedule-booking.ts +++ b/modules/rschedule/components/folk-schedule-booking.ts @@ -91,3 +91,5 @@ function escapeHtml(s: string): string { } customElements.define("folk-schedule-booking", FolkScheduleBooking); + +export {}; diff --git a/modules/rschedule/components/folk-schedule-cancel.ts b/modules/rschedule/components/folk-schedule-cancel.ts index 12b54fd0..a0e52085 100644 --- a/modules/rschedule/components/folk-schedule-cancel.ts +++ b/modules/rschedule/components/folk-schedule-cancel.ts @@ -123,3 +123,5 @@ function escapeHtml(s: string): string { } customElements.define("folk-schedule-cancel", FolkScheduleCancel); + +export {}; diff --git a/modules/rschedule/lib/availability.ts b/modules/rschedule/lib/availability.ts new file mode 100644 index 00000000..17219913 --- /dev/null +++ b/modules/rschedule/lib/availability.ts @@ -0,0 +1,236 @@ +/** + * Availability engine — port of schedule-jeffemmett/src/lib/availability.ts. + * + * Pure computation: takes config doc + bookings doc + a range, returns free + * slots. No side effects. Two callers: + * - GET /api/availability (public booking page) + * - Admin dashboard preview + * + * Deliberately avoids date-fns / date-fns-tz to keep the module tree light — + * uses Intl.DateTimeFormat for timezone math instead. + */ + +import type { + ScheduleConfigDoc, + ScheduleBookingsDoc, + AvailabilityRule, + AvailabilityOverride, + CachedGcalEvent, +} from "../schemas"; + +export interface TimeSlot { + /** UTC ISO string — acts as stable slot id. */ + id: string; + startUtc: string; + endUtc: string; + /** YYYY-MM-DD in viewer timezone. */ + date: string; + /** "9:00 AM" in viewer timezone. */ + startDisplay: string; + endDisplay: string; + startMs: number; + endMs: number; +} + +export interface GetSlotsOpts { + rangeStartMs: number; + rangeEndMs: number; + viewerTimezone: string; + /** Client-selected duration override (clamped 15–120). */ + durationOverride?: number; +} + +interface Interval { startMs: number; endMs: number; } + +// ── Timezone helpers (no external deps) ── + +/** + * Return the UTC ms offset to apply to a wall-clock instant in `tz` to get + * the real UTC instant. Uses Intl.DateTimeFormat with `timeZoneName: 'longOffset'`. + */ +function tzOffsetMinutes(dateMs: number, tz: string): number { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + hour12: false, + }); + const parts = dtf.formatToParts(new Date(dateMs)); + const map: Record = {}; + for (const p of parts) if (p.type !== "literal") map[p.type] = p.value; + const asUTC = Date.UTC( + Number(map.year), + Number(map.month) - 1, + Number(map.day), + Number(map.hour === "24" ? "0" : map.hour), + Number(map.minute), + Number(map.second), + ); + return Math.round((asUTC - dateMs) / 60000); +} + +/** Convert a wall-clock time in `tz` (y/m/d/h/m) to UTC ms. */ +function wallClockToUtcMs(y: number, m: number, d: number, h: number, min: number, tz: string): number { + // First pass: treat as UTC, then correct by the tz offset at that moment. + const guess = Date.UTC(y, m - 1, d, h, min, 0); + const off = tzOffsetMinutes(guess, tz); + return guess - off * 60000; +} + +/** Format a UTC ms in `tz` as YYYY-MM-DD. */ +function fmtDate(ms: number, tz: string): string { + const dtf = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", + }); + return dtf.format(new Date(ms)); +} + +/** Format a UTC ms in `tz` as "h:mm AM/PM". */ +function fmtTime(ms: number, tz: string): string { + const dtf = new Intl.DateTimeFormat("en-US", { + timeZone: tz, hour: "numeric", minute: "2-digit", hour12: true, + }); + return dtf.format(new Date(ms)); +} + +/** Day-of-week in `tz` (0=Sun..6=Sat). */ +function dayOfWeekInTz(ms: number, tz: string): number { + const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }); + const short = dtf.format(new Date(ms)); + return ({ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record)[short] ?? 0; +} + +function parseHHMM(s: string): [number, number] { + const [h, m] = s.split(":").map((n) => Number(n)); + return [h || 0, m || 0]; +} + +// ── Main engine ── + +export function getAvailableSlots( + config: ScheduleConfigDoc, + bookings: ScheduleBookingsDoc, + opts: GetSlotsOpts, +): TimeSlot[] { + const { settings, rules: rulesMap, overrides: overridesMap, googleEvents } = config; + const hostTz = settings.timezone || "UTC"; + const slotMin = opts.durationOverride + ? Math.max(15, Math.min(120, opts.durationOverride)) + : settings.slotDurationMin; + + const now = Date.now(); + const earliest = now + settings.minNoticeHours * 3600_000; + const latest = now + settings.maxAdvanceDays * 86400_000; + + const startMs = Math.max(opts.rangeStartMs, earliest); + const endMs = Math.min(opts.rangeEndMs, latest); + if (startMs >= endMs) return []; + + const rules = Object.values(rulesMap).filter((r) => r.isActive); + const overrides = Object.values(overridesMap); + const gcalBusy: CachedGcalEvent[] = Object.values(googleEvents).filter( + (e) => e.transparency === "opaque" && e.status !== "cancelled", + ); + + // Build busy intervals from confirmed bookings + gcal cache + const busy: Interval[] = []; + for (const b of Object.values(bookings.bookings)) { + if (b.status !== "confirmed") continue; + if (b.endTime < startMs || b.startTime > endMs) continue; + busy.push({ + startMs: b.startTime - settings.bufferBeforeMin * 60000, + endMs: b.endTime + settings.bufferAfterMin * 60000, + }); + } + for (const g of gcalBusy) { + if (g.endTime < startMs || g.startTime > endMs) continue; + busy.push({ startMs: g.startTime, endMs: g.endTime }); + } + + const slots: TimeSlot[] = []; + + // Walk day-by-day in host timezone + const msPerDay = 86400_000; + // Start at midnight in host tz of the effective range start + const startDate = fmtDate(startMs, hostTz); + const endDate = fmtDate(endMs, hostTz); + + let cursor = parseDate(startDate); + const end = parseDate(endDate); + + while (cursor.getTime() <= end.getTime()) { + const dateStr = toDateStr(cursor); + const override = overrides.find((o) => o.date === dateStr); + if (override?.isBlocked) { + cursor = addDays(cursor, 1); + continue; + } + + // Windows for this day + type Window = { startHHMM: string; endHHMM: string }; + let windows: Window[] = []; + if (override && override.startTime && override.endTime) { + windows = [{ startHHMM: override.startTime, endHHMM: override.endTime }]; + } else if (!override) { + const dow = dayOfWeekInTz( + wallClockToUtcMs(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), 12, 0, hostTz), + hostTz, + ); + windows = rules + .filter((r: AvailabilityRule) => r.dayOfWeek === dow) + .map((r) => ({ startHHMM: r.startTime, endHHMM: r.endTime })); + } + + for (const w of windows) { + const [sh, sm] = parseHHMM(w.startHHMM); + const [eh, em] = parseHHMM(w.endHHMM); + const wStart = wallClockToUtcMs( + cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), sh, sm, hostTz, + ); + const wEnd = wallClockToUtcMs( + cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), eh, em, hostTz, + ); + + let t = wStart; + while (t + slotMin * 60000 <= wEnd) { + const slotEnd = t + slotMin * 60000; + if (t < earliest) { t += slotMin * 60000; continue; } + if (t > latest) break; + + const conflict = busy.some((b) => t < b.endMs && slotEnd > b.startMs); + if (!conflict) { + slots.push({ + id: new Date(t).toISOString(), + startUtc: new Date(t).toISOString(), + endUtc: new Date(slotEnd).toISOString(), + date: fmtDate(t, opts.viewerTimezone), + startDisplay: fmtTime(t, opts.viewerTimezone), + endDisplay: fmtTime(slotEnd, opts.viewerTimezone), + startMs: t, + endMs: slotEnd, + }); + } + t += slotMin * 60000; + } + } + + cursor = addDays(cursor, 1); + } + + return slots; + + // ── local helpers ── + function parseDate(s: string): Date { + const [y, m, d] = s.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d)); + } + function toDateStr(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + function addDays(d: Date, n: number): Date { + return new Date(d.getTime() + n * msPerDay); + } +} diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 0f5ffe4a..69a71bca 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -40,6 +40,7 @@ import { type Invitation, type EntityRef, } from "./schemas"; +import { getAvailableSlots } from "./lib/availability"; let _syncServer: SyncServer | null = null; @@ -104,22 +105,25 @@ async function requireAdmin(c: any): Promise<{ ok: true; did: string } | { ok: f let claims; try { claims = await verifyToken(token); } catch { return { ok: false, status: 401, error: "Invalid token" }; } - const space = c.req.param("space"); - const role = await resolveCallerRole(space, claims); - if (!roleAtLeast(role, "moderator")) return { ok: false, status: 403, error: "Moderator role required" }; - return { ok: true, did: claims.did }; + const space = String(c.req.param("space") || ""); + const resolved = await resolveCallerRole(space, claims); + if (!resolved || !roleAtLeast(resolved.role, "moderator")) { + return { ok: false, status: 403, error: "Moderator role required" }; + } + return { ok: true, did: String(claims.did || claims.sub || "") }; } // ── Public routes ── routes.get("/", (c) => { - const space = c.req.param("space"); + const space = String(c.req.param("space") || "demo"); return c.html( renderShell({ title: `${space} — Book a time | rSpace`, moduleId: "rschedule", - space, - enabledModules: getModuleInfoList().map((m) => m.id), + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", scripts: ``, styles: ``, body: ``, @@ -128,14 +132,15 @@ routes.get("/", (c) => { }); routes.get("/cancel/:id", (c) => { - const space = c.req.param("space"); - const id = c.req.param("id"); + const space = String(c.req.param("space") || "demo"); + const id = String(c.req.param("id") || ""); return c.html( renderShell({ title: `Cancel booking | rSpace`, moduleId: "rschedule", - space, - enabledModules: getModuleInfoList().map((m) => m.id), + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", scripts: ``, body: ``, }), @@ -143,13 +148,14 @@ routes.get("/cancel/:id", (c) => { }); routes.get("/admin", (c) => { - const space = c.req.param("space"); + const space = String(c.req.param("space") || "demo"); return c.html( renderShell({ title: `${space} — Schedule admin | rSpace`, moduleId: "rschedule", - space, - enabledModules: getModuleInfoList().map((m) => m.id), + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", scripts: ``, body: ``, }), @@ -159,7 +165,7 @@ routes.get("/admin", (c) => { // ── Public API ── routes.get("/api/settings/public", (c) => { - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureConfigDoc(space); const s = doc.settings; return c.json({ @@ -172,15 +178,33 @@ routes.get("/api/settings/public", (c) => { }); }); -routes.get("/api/availability", (_c) => { - // Phase C wires the real engine. For now return empty so UI renders. - return _c.json({ slots: [] }); +routes.get("/api/availability", (c) => { + const space = String(c.req.param("space") || ""); + const config = ensureConfigDoc(space); + const bookings = ensureBookingsDoc(space); + + const fromStr = c.req.query("from"); + const toStr = c.req.query("to"); + const viewerTz = String(c.req.query("tz") || config.settings.timezone || "UTC"); + const durationStr = c.req.query("duration"); + + const now = Date.now(); + const rangeStartMs = fromStr ? Date.parse(fromStr) : now; + const rangeEndMs = toStr ? Date.parse(toStr) : now + 30 * 86400_000; + + const slots = getAvailableSlots(config, bookings, { + rangeStartMs, + rangeEndMs, + viewerTimezone: viewerTz, + durationOverride: durationStr ? Number(durationStr) : undefined, + }); + return c.json({ slots }); }); routes.get("/api/bookings/:id", (c) => { - const space = c.req.param("space"); - const id = c.req.param("id"); - const token = c.req.query("token"); + const space = String(c.req.param("space") || ""); + const id = String(c.req.param("id") || ""); + const token = String(c.req.query("token") || ""); const doc = ensureBookingsDoc(space); const booking = doc.bookings[id]; if (!booking) return c.json({ error: "Not found" }, 404); @@ -198,7 +222,7 @@ routes.get("/api/bookings/:id", (c) => { }); routes.post("/api/bookings", async (c) => { - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const body = await c.req.json().catch(() => null); if (!body?.startTime || !body?.endTime || !body?.guestName || !body?.guestEmail) { return c.json({ error: "Missing required fields" }, 400); @@ -259,8 +283,8 @@ routes.post("/api/bookings", async (c) => { }); routes.post("/api/bookings/:id/cancel", async (c) => { - const space = c.req.param("space"); - const id = c.req.param("id"); + const space = String(c.req.param("space") || ""); + const id = String(c.req.param("id") || ""); const body = await c.req.json().catch(() => ({})); const token = body?.token || c.req.query("token"); const docId = scheduleBookingsDocId(space); @@ -284,7 +308,7 @@ routes.post("/api/bookings/:id/cancel", async (c) => { routes.get("/api/admin/settings", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureConfigDoc(space); return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } }); }); @@ -292,7 +316,7 @@ routes.get("/api/admin/settings", async (c) => { routes.put("/api/admin/settings", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const patch = await c.req.json().catch(() => ({})); _syncServer!.changeDoc(scheduleConfigDocId(space), "update settings", (d) => { Object.assign(d.settings, patch); @@ -304,7 +328,7 @@ routes.put("/api/admin/settings", async (c) => { routes.get("/api/admin/availability", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureConfigDoc(space); return c.json({ rules: Object.values(doc.rules) }); }); @@ -312,7 +336,7 @@ routes.get("/api/admin/availability", async (c) => { routes.post("/api/admin/availability", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const body = await c.req.json().catch(() => ({})); const id = `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; _syncServer!.changeDoc(scheduleConfigDocId(space), `add rule ${id}`, (d) => { @@ -331,8 +355,8 @@ routes.post("/api/admin/availability", async (c) => { routes.put("/api/admin/availability/:id", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); - const id = c.req.param("id"); + const space = String(c.req.param("space") || ""); + const id = String(c.req.param("id") || ""); const patch = await c.req.json().catch(() => ({})); _syncServer!.changeDoc(scheduleConfigDocId(space), `update rule ${id}`, (d) => { if (!d.rules[id]) return; @@ -344,8 +368,8 @@ routes.put("/api/admin/availability/:id", async (c) => { routes.delete("/api/admin/availability/:id", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); - const id = c.req.param("id"); + const space = String(c.req.param("space") || ""); + const id = String(c.req.param("id") || ""); _syncServer!.changeDoc(scheduleConfigDocId(space), `delete rule ${id}`, (d) => { delete d.rules[id]; }); @@ -355,7 +379,7 @@ routes.delete("/api/admin/availability/:id", async (c) => { routes.get("/api/admin/overrides", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureConfigDoc(space); return c.json({ overrides: Object.values(doc.overrides) }); }); @@ -363,7 +387,7 @@ routes.get("/api/admin/overrides", async (c) => { routes.post("/api/admin/overrides", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const body = await c.req.json().catch(() => ({})); if (!body?.date) return c.json({ error: "date required (YYYY-MM-DD)" }, 400); const id = `ov-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; @@ -384,8 +408,8 @@ routes.post("/api/admin/overrides", async (c) => { routes.delete("/api/admin/overrides/:id", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); - const id = c.req.param("id"); + const space = String(c.req.param("space") || ""); + const id = String(c.req.param("id") || ""); _syncServer!.changeDoc(scheduleConfigDocId(space), `delete override ${id}`, (d) => { delete d.overrides[id]; }); @@ -395,7 +419,7 @@ routes.delete("/api/admin/overrides/:id", async (c) => { routes.get("/api/admin/bookings", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureBookingsDoc(space); const rows = Object.values(doc.bookings) .sort((a, b) => b.startTime - a.startTime) @@ -413,7 +437,7 @@ routes.get("/api/admin/bookings", async (c) => { }); routes.get("/api/invitations", (c) => { - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureInvitationsDoc(space); return c.json({ invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime), @@ -423,7 +447,7 @@ routes.get("/api/invitations", (c) => { routes.get("/api/admin/google/status", async (c) => { const auth = await requireAdmin(c); if (!auth.ok) return c.json({ error: auth.error }, auth.status as any); - const space = c.req.param("space"); + const space = String(c.req.param("space") || ""); const doc = ensureConfigDoc(space); return c.json(doc.googleAuth); });