diff --git a/modules/rnetwork/components/folk-delegation-manager.ts b/modules/rnetwork/components/folk-delegation-manager.ts index 8babcc30..ce5d84b1 100644 --- a/modules/rnetwork/components/folk-delegation-manager.ts +++ b/modules/rnetwork/components/folk-delegation-manager.ts @@ -576,3 +576,5 @@ class FolkDelegationManager extends HTMLElement { } customElements.define("folk-delegation-manager", FolkDelegationManager); + +export {}; diff --git a/modules/rnetwork/components/folk-power-indices.ts b/modules/rnetwork/components/folk-power-indices.ts index fc256c6a..4ccb34fe 100644 --- a/modules/rnetwork/components/folk-power-indices.ts +++ b/modules/rnetwork/components/folk-power-indices.ts @@ -334,3 +334,5 @@ class FolkPowerIndices extends HTMLElement { } customElements.define("folk-power-indices", FolkPowerIndices); + +export {}; diff --git a/modules/rschedule/components/booking.css b/modules/rschedule/components/booking.css new file mode 100644 index 00000000..05c12f2a --- /dev/null +++ b/modules/rschedule/components/booking.css @@ -0,0 +1,7 @@ +/* rSchedule booking + admin components β€” shared host styles. */ +folk-schedule-booking, +folk-schedule-cancel, +folk-schedule-admin { + display: block; + min-height: 100vh; +} diff --git a/modules/rschedule/components/folk-schedule-admin.ts b/modules/rschedule/components/folk-schedule-admin.ts new file mode 100644 index 00000000..5761a553 --- /dev/null +++ b/modules/rschedule/components/folk-schedule-admin.ts @@ -0,0 +1,104 @@ +/** + * β€” admin dashboard for rSchedule. + * + * Passkey-gated (server enforces moderator role on every admin API call). + * Phase A: minimal shell with tab placeholders. Phase E builds full UI + * (availability rules, overrides, bookings list, settings, gcal connect). + */ + +class FolkScheduleAdmin extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private tab: "overview" | "availability" | "bookings" | "calendar" | "settings" = "overview"; + private err = ""; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.render(); + } + + private authHeaders(): Record { + try { + const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}"); + const token = sess?.token; + return token ? { Authorization: `Bearer ${token}` } : {}; + } catch { return {}; } + } + + private async ping() { + const path = window.location.pathname; + const m = path.match(/^(\/[^/]+)?\/rschedule/); + const base = `${m?.[0] || "/rschedule"}/api/admin`; + const res = await fetch(`${base}/settings`, { headers: this.authHeaders() }); + if (res.status === 401) { this.err = "Sign in to manage this booking page."; return; } + if (res.status === 403) { this.err = "You need moderator or higher role in this space."; return; } + if (!res.ok) { this.err = `Error ${res.status}`; return; } + this.err = ""; + } + + private render() { + void this.ping().then(() => this.paint()); + this.paint(); + } + + private paint() { + this.shadow.innerHTML = ` + +
+

rSchedule admin · ${escapeHtml(this.space)}

+

Configure availability, bookings, and Google Calendar for this space.

+ ${this.err ? `
${escapeHtml(this.err)}
` : ""} +
+ ${this.tabBtn("overview", "Overview")} + ${this.tabBtn("availability", "Availability")} + ${this.tabBtn("bookings", "Bookings")} + ${this.tabBtn("calendar", "Google Calendar")} + ${this.tabBtn("settings", "Settings")} +
+
+
+ ${escapeHtml(this.tab)}
+ Full admin UI arrives in Phase E. For now, this confirms auth gate works. +
+
+
+ `; + this.shadow.querySelectorAll(".tab").forEach((btn) => { + btn.addEventListener("click", () => { + this.tab = btn.dataset.tab as any; + this.paint(); + }); + }); + } + + private tabBtn(id: string, label: string): string { + return ``; + } +} + +function escapeHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +customElements.define("folk-schedule-admin", FolkScheduleAdmin); diff --git a/modules/rschedule/components/folk-schedule-booking.ts b/modules/rschedule/components/folk-schedule-booking.ts new file mode 100644 index 00000000..f9bf5f64 --- /dev/null +++ b/modules/rschedule/components/folk-schedule-booking.ts @@ -0,0 +1,93 @@ +/** + * β€” public booking page. + * + * Phase A: stub that fetches public settings + availability and shows a + * placeholder date picker. Phase B ports the full UI from + * schedule-jeffemmett/src/app/page.tsx (month calendar, slot list, timezone + * toggle, world map, booking form). + */ + +interface PublicSettings { + displayName: string; + bookingMessage: string; + slotDurationMin: number; + maxAdvanceDays: number; + minNoticeHours: number; + timezone: string; +} + +class FolkScheduleBooking extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private settings: PublicSettings | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + void this.load(); + } + + private async load() { + try { + const base = this.apiBase(); + const res = await fetch(`${base}/settings/public`); + this.settings = res.ok ? await res.json() : null; + } catch { + this.settings = null; + } + this.render(); + } + + private apiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rschedule/); + return `${match?.[0] || "/rschedule"}/api`; + } + + private render() { + const s = this.settings; + const name = s?.displayName || this.space; + const msg = s?.bookingMessage || "Book a time to chat."; + const duration = s?.slotDurationMin ?? 30; + this.shadow.innerHTML = ` + +
+
+

Book with ${escapeHtml(name)}

+
+ ⏱ ${duration} min + ${s?.timezone ? `🌐 ${escapeHtml(s.timezone)}` : ""} +
+

${escapeHtml(msg)}

+
+ Date picker and slot list coming in Phase B.
+ Admin: configure availability +
+
+
+ `; + } +} + +function escapeHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +customElements.define("folk-schedule-booking", FolkScheduleBooking); diff --git a/modules/rschedule/components/folk-schedule-cancel.ts b/modules/rschedule/components/folk-schedule-cancel.ts new file mode 100644 index 00000000..12b54fd0 --- /dev/null +++ b/modules/rschedule/components/folk-schedule-cancel.ts @@ -0,0 +1,125 @@ +/** + * β€” guest self-cancel page. + * + * Reads booking via `/api/bookings/:id?token=...`, lets guest confirm cancel + * with optional reason. Phase F adds reschedule suggestions email. + */ + +class FolkScheduleCancel extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private bookingId = ""; + private token = ""; + private booking: any = null; + private err = ""; + private done = false; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.bookingId = this.getAttribute("booking-id") || ""; + const qs = new URLSearchParams(window.location.search); + this.token = qs.get("token") || ""; + void this.load(); + } + + private apiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rschedule/); + return `${match?.[0] || "/rschedule"}/api`; + } + + private async load() { + if (!this.token) { + this.err = "Missing cancel token in link."; + this.render(); + return; + } + try { + const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}?token=${encodeURIComponent(this.token)}`); + if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Failed to load"); + this.booking = await res.json(); + } catch (e: any) { + this.err = e?.message || String(e); + } + this.render(); + } + + private async cancel(reason: string) { + try { + const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: this.token, reason }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Cancel failed"); + this.done = true; + } catch (e: any) { + this.err = e?.message || String(e); + } + this.render(); + } + + private render() { + const b = this.booking; + let body = ""; + if (this.err) { + body = `
${escapeHtml(this.err)}
`; + } else if (this.done) { + body = `
Booking cancelled. A confirmation email is on the way.
`; + } else if (!b) { + body = `
Loading booking…
`; + } else { + const start = new Date(b.startTime).toLocaleString(); + body = ` +

Cancel this booking?

+
+
With
${escapeHtml(b.host?.label || b.host?.id || "")}
+
When
${escapeHtml(start)}
+
Guest
${escapeHtml(b.guestName || "")}
+
+ + + ← Back + `; + } + this.shadow.innerHTML = ` + +
${body}
+ `; + this.shadow.getElementById("cancel-btn")?.addEventListener("click", () => { + const reason = (this.shadow.getElementById("reason") as HTMLTextAreaElement)?.value || ""; + void this.cancel(reason); + }); + } +} + +function escapeHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +customElements.define("folk-schedule-cancel", FolkScheduleCancel); diff --git a/modules/rschedule/landing.ts b/modules/rschedule/landing.ts new file mode 100644 index 00000000..f55eba7d --- /dev/null +++ b/modules/rschedule/landing.ts @@ -0,0 +1,76 @@ +/** + * rSchedule landing β€” marketing page for rspace.online/rschedule standalone. + * + * In-space `/:space/rschedule` bypasses this and renders the public booking + * UI directly via . + */ +export function renderLanding(): string { + return ` +
+ + Public Booking Pages + +

+ Book time with (you)rSpace. +

+

+ Calendly-style public booking pages for every space and every user — backed by rCal availability, Google Calendar conflict checking, and encrypted invitations. +

+

+ Each space and each user gets their own bookable schedule page. Guests pick a slot, confirm, and get a calendar invite. Hosts see bookings in their own rSchedule view — including bookings they're invited to across other spaces. +

+ +
+ +
+
+
+
+
+ 📅 +
+

Weekly Rules + Overrides

+

Set recurring weekly availability, then block or open specific dates with one-off overrides.

+
+
+
+ 📧 +
+

Google Calendar Sync

+

Connect your Google Calendar — busy times block bookings, confirmed bookings create gcal events.

+
+
+
+ 🔗 +
+

Self-Cancel Links

+

Every confirmation email includes a signed cancel token — guests reschedule without logging in.

+
+
+
+ 👥 +
+

Cross-Space Invites

+

Invite other spaces or users as attendees. They see the booking in their own rSchedule view too.

+
+
+
+
+ +
+

+ Every space, bookable. +

+

+ ← Back to rSpace +

+
+`; +} diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts new file mode 100644 index 00000000..0f5ffe4a --- /dev/null +++ b/modules/rschedule/mod.ts @@ -0,0 +1,465 @@ +/** + * rSchedule module β€” Calendly-style public booking pages. + * + * Each space (and each user's personal space) gets a bookable schedule page at + * `/:space/rschedule`. Guests pick a slot; hosts manage availability rules, + * overrides, and connected Google Calendar under `/:space/rschedule/admin`. + * + * All persistence via Automerge β€” no PostgreSQL. Three doc collections per + * space: `config` (settings + rules + overrides + gcal state), `bookings` + * (bookings this space hosts), `invitations` (bookings this space is attendee + * on β€” written by server on create so cross-space visibility works without + * cross-space reads). + * + * Phase A: scaffold routes, schemas registered, stub handlers return JSON. + * Public UI + availability engine + gcal sync + admin come in later phases. + */ + +import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; +import { renderLanding } from "./landing"; +import type { SyncServer } from "../../server/local-first/sync-server"; +import { + scheduleConfigSchema, + scheduleBookingsSchema, + scheduleInvitationsSchema, + scheduleConfigDocId, + scheduleBookingsDocId, + scheduleInvitationsDocId, + DEFAULT_SETTINGS, + DEFAULT_GOOGLE_AUTH, + type ScheduleConfigDoc, + type ScheduleBookingsDoc, + type ScheduleInvitationsDoc, + type Booking, + type Invitation, + type EntityRef, +} from "./schemas"; + +let _syncServer: SyncServer | null = null; + +const routes = new Hono(); + +// ── Doc helpers ── + +function ensureConfigDoc(space: string): ScheduleConfigDoc { + const docId = scheduleConfigDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), "init rschedule config", (d) => { + const init = scheduleConfigSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.settings = { ...DEFAULT_SETTINGS }; + d.rules = {}; + d.overrides = {}; + d.googleAuth = { ...DEFAULT_GOOGLE_AUTH }; + d.googleEvents = {}; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureBookingsDoc(space: string): ScheduleBookingsDoc { + const docId = scheduleBookingsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), "init rschedule bookings", (d) => { + const init = scheduleBookingsSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.bookings = {}; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureInvitationsDoc(space: string): ScheduleInvitationsDoc { + const docId = scheduleInvitationsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), "init rschedule invitations", (d) => { + const init = scheduleInvitationsSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.invitations = {}; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +// ── Auth helper (EncryptID passkey JWT) ── + +async function requireAdmin(c: any): Promise<{ ok: true; did: string } | { ok: false; status: number; error: string }> { + const token = extractToken(c.req.raw.headers); + if (!token) return { ok: false, status: 401, error: "Auth required" }; + 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 }; +} + +// ── Public routes ── + +routes.get("/", (c) => { + const space = c.req.param("space"); + return c.html( + renderShell({ + title: `${space} β€” Book a time | rSpace`, + moduleId: "rschedule", + space, + enabledModules: getModuleInfoList().map((m) => m.id), + scripts: ``, + styles: ``, + body: ``, + }), + ); +}); + +routes.get("/cancel/:id", (c) => { + const space = c.req.param("space"); + const id = c.req.param("id"); + return c.html( + renderShell({ + title: `Cancel booking | rSpace`, + moduleId: "rschedule", + space, + enabledModules: getModuleInfoList().map((m) => m.id), + scripts: ``, + body: ``, + }), + ); +}); + +routes.get("/admin", (c) => { + const space = c.req.param("space"); + return c.html( + renderShell({ + title: `${space} β€” Schedule admin | rSpace`, + moduleId: "rschedule", + space, + enabledModules: getModuleInfoList().map((m) => m.id), + scripts: ``, + body: ``, + }), + ); +}); + +// ── Public API ── + +routes.get("/api/settings/public", (c) => { + const space = c.req.param("space"); + const doc = ensureConfigDoc(space); + const s = doc.settings; + return c.json({ + displayName: s.displayName || space, + bookingMessage: s.bookingMessage, + slotDurationMin: s.slotDurationMin, + maxAdvanceDays: s.maxAdvanceDays, + minNoticeHours: s.minNoticeHours, + timezone: s.timezone, + }); +}); + +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/bookings/:id", (c) => { + const space = c.req.param("space"); + const id = c.req.param("id"); + const token = c.req.query("token"); + const doc = ensureBookingsDoc(space); + const booking = doc.bookings[id]; + if (!booking) return c.json({ error: "Not found" }, 404); + if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403); + return c.json({ + id: booking.id, + startTime: booking.startTime, + endTime: booking.endTime, + timezone: booking.timezone, + status: booking.status, + guestName: booking.guestName, + guestEmail: booking.guestEmail, + host: booking.host, + }); +}); + +routes.post("/api/bookings", async (c) => { + const space = 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); + } + const id = `bk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const cancelToken = `ct-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`; + const now = Date.now(); + const host: EntityRef = { kind: "space", id: space }; + + const booking: Booking = { + id, + host, + guestName: String(body.guestName).slice(0, 200), + guestEmail: String(body.guestEmail).slice(0, 200), + guestNote: String(body.guestNote || "").slice(0, 2000), + attendees: {}, + startTime: Number(body.startTime), + endTime: Number(body.endTime), + timezone: String(body.timezone || "UTC"), + status: "confirmed", + meetingLink: null, + googleEventId: null, + cancelToken, + cancellationReason: null, + reminderSentAt: null, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(scheduleBookingsDocId(space), `create booking ${id}`, (d) => { + d.bookings[id] = booking as any; + }); + + const inviteeRefs: EntityRef[] = Array.isArray(body.invitees) ? body.invitees : []; + for (const ref of inviteeRefs) { + if (ref.kind !== "space") continue; // user DIDs handled once user-spaces formalized + const invId = `inv-${id}-${ref.id}`; + const inv: Invitation = { + id: invId, + bookingId: id, + host, + title: `Booking with ${host.label || host.id}`, + startTime: booking.startTime, + endTime: booking.endTime, + timezone: booking.timezone, + status: booking.status, + response: "invited", + meetingLink: null, + createdAt: now, + updatedAt: now, + }; + _syncServer!.changeDoc(scheduleInvitationsDocId(ref.id), `receive invite ${invId}`, (d) => { + d.invitations[invId] = inv as any; + }); + } + + return c.json({ id, cancelToken, status: "confirmed" }); +}); + +routes.post("/api/bookings/:id/cancel", async (c) => { + const space = c.req.param("space"); + const id = c.req.param("id"); + const body = await c.req.json().catch(() => ({})); + const token = body?.token || c.req.query("token"); + const docId = scheduleBookingsDocId(space); + const doc = _syncServer!.getDoc(docId); + const booking = doc?.bookings[id]; + if (!booking) return c.json({ error: "Not found" }, 404); + if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403); + if (booking.status === "cancelled") return c.json({ ok: true, alreadyCancelled: true }); + + _syncServer!.changeDoc(docId, `cancel booking ${id}`, (d) => { + d.bookings[id].status = "cancelled"; + d.bookings[id].cancellationReason = String(body?.reason || "").slice(0, 500); + d.bookings[id].updatedAt = Date.now(); + }); + + return c.json({ ok: true }); +}); + +// ── Admin API (passkey-gated) ── + +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 doc = ensureConfigDoc(space); + return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } }); +}); + +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 patch = await c.req.json().catch(() => ({})); + _syncServer!.changeDoc(scheduleConfigDocId(space), "update settings", (d) => { + Object.assign(d.settings, patch); + }); + const doc = ensureConfigDoc(space); + return c.json({ settings: doc.settings }); +}); + +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 doc = ensureConfigDoc(space); + return c.json({ rules: Object.values(doc.rules) }); +}); + +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 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) => { + d.rules[id] = { + id, + dayOfWeek: Number(body.dayOfWeek ?? 1), + startTime: String(body.startTime || "09:00"), + endTime: String(body.endTime || "17:00"), + isActive: body.isActive !== false, + createdAt: Date.now(), + }; + }); + return c.json({ id }); +}); + +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 patch = await c.req.json().catch(() => ({})); + _syncServer!.changeDoc(scheduleConfigDocId(space), `update rule ${id}`, (d) => { + if (!d.rules[id]) return; + Object.assign(d.rules[id], patch); + }); + return c.json({ ok: true }); +}); + +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"); + _syncServer!.changeDoc(scheduleConfigDocId(space), `delete rule ${id}`, (d) => { + delete d.rules[id]; + }); + return c.json({ ok: true }); +}); + +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 doc = ensureConfigDoc(space); + return c.json({ overrides: Object.values(doc.overrides) }); +}); + +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 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)}`; + _syncServer!.changeDoc(scheduleConfigDocId(space), `add override ${id}`, (d) => { + d.overrides[id] = { + id, + date: String(body.date), + isBlocked: body.isBlocked !== false, + startTime: body.startTime || null, + endTime: body.endTime || null, + reason: String(body.reason || ""), + createdAt: Date.now(), + }; + }); + return c.json({ id }); +}); + +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"); + _syncServer!.changeDoc(scheduleConfigDocId(space), `delete override ${id}`, (d) => { + delete d.overrides[id]; + }); + return c.json({ ok: true }); +}); + +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 doc = ensureBookingsDoc(space); + const rows = Object.values(doc.bookings) + .sort((a, b) => b.startTime - a.startTime) + .map((b) => ({ + id: b.id, + guestName: b.guestName, + guestEmail: b.guestEmail, + startTime: b.startTime, + endTime: b.endTime, + timezone: b.timezone, + status: b.status, + attendeeCount: Object.keys(b.attendees).length, + })); + return c.json({ bookings: rows }); +}); + +routes.get("/api/invitations", (c) => { + const space = c.req.param("space"); + const doc = ensureInvitationsDoc(space); + return c.json({ + invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime), + }); +}); + +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 doc = ensureConfigDoc(space); + return c.json(doc.googleAuth); +}); + +// ── Module export ── + +export const scheduleModule: RSpaceModule = { + id: "rschedule", + name: "rSchedule", + icon: "πŸ“†", + description: "Calendly-style booking pages for spaces and users, backed by rCal availability", + scoping: { defaultScope: "global", userConfigurable: false }, + docSchemas: [ + { pattern: "{space}:schedule:config", description: "Booking settings, weekly rules, overrides, gcal state", init: scheduleConfigSchema.init }, + { pattern: "{space}:schedule:bookings", description: "Bookings this space hosts", init: scheduleBookingsSchema.init }, + { pattern: "{space}:schedule:invitations", description: "Bookings this space is attendee on", init: scheduleInvitationsSchema.init }, + ], + routes, + standaloneDomain: "rschedule.online", + landingPage: renderLanding, + async onInit(ctx) { + _syncServer = ctx.syncServer; + ensureConfigDoc("demo"); + }, + feeds: [ + { id: "bookings", name: "Bookings", kind: "data", description: "Confirmed bookings with start/end times, attendees, and cancel tokens", filterable: true }, + { id: "invitations", name: "Invitations", kind: "data", description: "Bookings this space is invited to as an attendee" }, + ], + outputPaths: [ + { path: "admin", name: "Admin", icon: "βš™οΈ", description: "Configure availability, bookings, and Google Calendar" }, + { path: "admin/availability", name: "Availability", icon: "πŸ“…", description: "Weekly rules and date overrides" }, + { path: "admin/bookings", name: "Bookings", icon: "πŸ“‹", description: "All hosted bookings" }, + ], + onboardingActions: [ + { label: "Set Availability", icon: "πŸ“…", description: "Define your weekly bookable hours", type: "create", href: "/{space}/rschedule/admin/availability" }, + { label: "Connect Google Calendar", icon: "πŸ”„", description: "Block bookings against your gcal busy times", type: "link", href: "/{space}/rschedule/admin" }, + { label: "Share Booking Link", icon: "πŸ”—", description: "Send your public booking page to anyone", type: "link", href: "/{space}/rschedule" }, + ], +}; diff --git a/modules/rtasks/components/folk-tasks-activity.ts b/modules/rtasks/components/folk-tasks-activity.ts new file mode 100644 index 00000000..1833d969 --- /dev/null +++ b/modules/rtasks/components/folk-tasks-activity.ts @@ -0,0 +1,166 @@ +/** + * β€” Recent task activity widget for rTasks canvas. + * + * Derives a lightweight activity feed from task updated_at timestamps until + * the server activity endpoint is populated. Each entry shows what changed + * most recently (task title + relative time). + * + * Attribute: space β€” space slug + */ + +interface TaskRow { + id: string; + title: string; + status: string; + priority: string | null; + updated_at: string; + created_at: string; +} + +class FolkTasksActivity extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + private tasks: TaskRow[] = []; + private loading = true; + private error = ''; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.render(); + this.load(); + } + + static get observedAttributes() { return ['space']; } + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space' && val !== this.space) { + this.space = val; + this.load(); + } + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rtasks/); + return match ? match[0] : '/rtasks'; + } + + private async load() { + this.loading = true; + this.error = ''; + this.render(); + try { + const res = await fetch(`${this.getApiBase()}/api/spaces/${encodeURIComponent(this.space)}/tasks`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + this.tasks = await res.json(); + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } + this.loading = false; + this.render(); + } + + private recent(): TaskRow[] { + return [...this.tasks] + .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)) + .slice(0, 20); + } + + private relTime(iso: string): string { + const delta = Date.now() - Date.parse(iso); + const m = Math.floor(delta / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 7) return `${d}d ago`; + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + + private render() { + const items = this.recent(); + this.shadow.innerHTML = ` + +
+
+ ${this.loading ? '
Loading…
' : + this.error ? `
${this.esc(this.error)}
` : + items.length === 0 ? '
No activity yet.
' : + items.map(t => this.renderEntry(t)).join('')} +
+
`; + this.bindEvents(); + } + + private renderEntry(t: TaskRow): string { + const isNew = t.created_at === t.updated_at; + const verb = isNew ? 'created' : 'updated'; + const rel = this.relTime(t.updated_at); + const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`; + return `
+
+ ${verb} + ${this.esc(t.title)} +
+ +
`; + } + + private bindEvents() { + this.shadow.querySelectorAll('.entry').forEach(el => { + el.addEventListener('click', () => { + const id = (el as HTMLElement).dataset.taskId; + if (!id) return; + this.dispatchEvent(new CustomEvent('task-selected', { bubbles: true, composed: true, detail: { taskId: id } })); + }); + }); + } + + private getStyles(): string { + return ` + :host { display: block; height: 100%; } + .activity { display: flex; flex-direction: column; height: 100%; min-height: 0; } + .list { flex: 1; overflow-y: auto; min-height: 0; } + .state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; } + .state.err { color: #f87171; } + .entry { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--rs-border); + cursor: pointer; + } + .entry:hover { background: var(--rs-bg-hover); } + .entry-main { display: flex; gap: 0.375rem; align-items: baseline; } + .verb { font-size: 0.75rem; color: var(--rs-text-secondary); font-weight: 500; } + .title { font-size: 0.8125rem; color: var(--rs-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .entry-meta { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.25rem; } + .status { + font-size: 0.65rem; font-weight: 600; + padding: 0.0625rem 0.375rem; border-radius: 4px; + background: var(--rs-bg-hover); color: var(--rs-text-secondary); + text-transform: uppercase; letter-spacing: 0.03em; + } + .status-done { background: rgba(34,197,94,0.15); color: #16a34a; } + .status-in-progress { background: rgba(59,130,246,0.15); color: #2563eb; } + .status-review { background: rgba(168,85,247,0.15); color: #9333ea; } + .rel { font-size: 0.7rem; color: var(--rs-text-muted); } + `; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +if (!customElements.get('folk-tasks-activity')) customElements.define('folk-tasks-activity', FolkTasksActivity); + +export {}; diff --git a/modules/rtasks/components/folk-tasks-backlog.ts b/modules/rtasks/components/folk-tasks-backlog.ts new file mode 100644 index 00000000..a2d91726 --- /dev/null +++ b/modules/rtasks/components/folk-tasks-backlog.ts @@ -0,0 +1,216 @@ +/** + * β€” Flat backlog list widget for rTasks canvas. + * + * Shows all tasks as a unified, filterable list (cross-status), sorted by + * priority then due date. Complements (column view) by + * offering a single sorted stream. + * + * Attribute: space β€” space slug + */ + +interface TaskRow { + id: string; + title: string; + description: string; + status: string; + priority: string | null; + labels: string[]; + due_date: string | null; + created_at: string; + updated_at: string; +} + +class FolkTasksBacklog extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + private tasks: TaskRow[] = []; + private loading = true; + private error = ''; + private query = ''; + private priorityFilter: '' | 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = ''; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.render(); + this.load(); + } + + static get observedAttributes() { return ['space']; } + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space' && val !== this.space) { + this.space = val; + this.load(); + } + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rtasks/); + return match ? match[0] : '/rtasks'; + } + + private async load() { + this.loading = true; + this.error = ''; + this.render(); + try { + const res = await fetch(`${this.getApiBase()}/api/spaces/${encodeURIComponent(this.space)}/tasks`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + this.tasks = await res.json(); + } catch (e) { + this.error = e instanceof Error ? e.message : String(e); + } + this.loading = false; + this.render(); + } + + private filtered(): TaskRow[] { + const q = this.query.trim().toLowerCase(); + const prio = this.priorityFilter; + const priOrder: Record = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; + return this.tasks + .filter(t => !q || t.title.toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q)) + .filter(t => !prio || t.priority === prio) + .sort((a, b) => { + const pa = priOrder[a.priority || 'LOW'] ?? 4; + const pb = priOrder[b.priority || 'LOW'] ?? 4; + if (pa !== pb) return pa - pb; + const da = a.due_date ? Date.parse(a.due_date) : Number.MAX_SAFE_INTEGER; + const db = b.due_date ? Date.parse(b.due_date) : Number.MAX_SAFE_INTEGER; + return da - db; + }); + } + + private render() { + const items = this.filtered(); + this.shadow.innerHTML = ` + +
+
+ + + ${items.length} / ${this.tasks.length} +
+
+ ${this.loading ? '
Loading…
' : + this.error ? `
${this.esc(this.error)}
` : + items.length === 0 ? '
No matching tasks.
' : + items.map(t => this.renderRow(t)).join('')} +
+
`; + this.bindEvents(); + } + + private renderRow(t: TaskRow): string { + const due = t.due_date ? new Date(t.due_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; + const prioClass = `prio-${(t.priority || 'LOW').toLowerCase()}`; + const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`; + return `
+ +
+
${this.esc(t.title)}
+
+ ${this.esc(t.status)} + ${due ? `${this.esc(due)}` : ''} + ${t.labels.slice(0, 3).map(l => `${this.esc(l)}`).join('')} +
+
+
`; + } + + private bindEvents() { + this.shadow.querySelector('.search')?.addEventListener('input', (e) => { + this.query = (e.target as HTMLInputElement).value; + this.render(); + }); + this.shadow.querySelector('.prio')?.addEventListener('change', (e) => { + this.priorityFilter = (e.target as HTMLSelectElement).value as any; + this.render(); + }); + this.shadow.querySelectorAll('.row').forEach(el => { + el.addEventListener('click', () => { + const id = (el as HTMLElement).dataset.taskId; + if (!id) return; + this.dispatchEvent(new CustomEvent('task-selected', { bubbles: true, composed: true, detail: { taskId: id } })); + }); + }); + } + + private getStyles(): string { + return ` + :host { display: block; height: 100%; } + .backlog { display: flex; flex-direction: column; height: 100%; min-height: 0; } + .controls { + display: flex; gap: 0.5rem; align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--rs-border); + flex-shrink: 0; + } + .search, .prio { + padding: 0.375rem 0.5rem; + border: 1px solid var(--rs-border); + border-radius: 6px; + background: var(--rs-input-bg, var(--rs-bg-surface)); + color: var(--rs-text-primary); + font-size: 0.8125rem; + } + .search { flex: 1; min-width: 0; } + .count { font-size: 0.75rem; color: var(--rs-text-secondary); flex-shrink: 0; } + .list { flex: 1; overflow-y: auto; padding: 0.5rem 0; min-height: 0; } + .state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; } + .state.err { color: #f87171; } + .row { + display: flex; align-items: flex-start; gap: 0.625rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--rs-border); + cursor: pointer; + transition: background 0.1s; + } + .row:hover { background: var(--rs-bg-hover); } + .prio-dot { + width: 8px; height: 8px; border-radius: 50%; + margin-top: 0.375rem; flex-shrink: 0; + background: var(--rs-border); + } + .prio-urgent { background: #ef4444; } + .prio-high { background: #f97316; } + .prio-medium { background: #eab308; } + .prio-low { background: #60a5fa; } + .row-main { flex: 1; min-width: 0; } + .row-title { font-size: 0.875rem; color: var(--rs-text-primary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; } + .row-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.25rem; align-items: center; } + .status { + font-size: 0.6875rem; font-weight: 600; + padding: 0.0625rem 0.375rem; border-radius: 4px; + background: var(--rs-bg-hover); color: var(--rs-text-secondary); + text-transform: uppercase; letter-spacing: 0.03em; + } + .status-done { background: rgba(34,197,94,0.15); color: #16a34a; } + .status-in-progress { background: rgba(59,130,246,0.15); color: #2563eb; } + .status-review { background: rgba(168,85,247,0.15); color: #9333ea; } + .due { font-size: 0.75rem; color: var(--rs-text-muted); } + .label { font-size: 0.6875rem; color: var(--rs-text-secondary); background: var(--rs-bg-hover); padding: 0.0625rem 0.375rem; border-radius: 4px; } + `; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +if (!customElements.get('folk-tasks-backlog')) customElements.define('folk-tasks-backlog', FolkTasksBacklog); + +export {}; diff --git a/modules/rtasks/components/folk-tasks-canvas.ts b/modules/rtasks/components/folk-tasks-canvas.ts new file mode 100644 index 00000000..4df13539 --- /dev/null +++ b/modules/rtasks/components/folk-tasks-canvas.ts @@ -0,0 +1,12 @@ +/** + * rTasks canvas bundle entry β€” imports all widgets + canvas shell. + * + * Loaded by the /canvas route shell as a single script. + * Each import side-effect-registers a custom element. + */ + +import '../../../shared/components/folk-widget'; +import '../../../shared/components/folk-app-canvas'; +import './folk-tasks-board'; +import './folk-tasks-backlog'; +import './folk-tasks-activity'; diff --git a/modules/rtasks/mod.ts b/modules/rtasks/mod.ts index 660219a9..f04eacd9 100644 --- a/modules/rtasks/mod.ts +++ b/modules/rtasks/mod.ts @@ -869,6 +869,53 @@ routes.get("/", (c) => { })); }); +// ── Canvas prototype β€” widget-based rApp workspace ── +// Experimental replacement for the single-board view: hosts Board, Backlog, +// and Activity as togglable widgets on a shared grid canvas. +routes.get("/canvas", (c) => { + const space = c.req.param("space") || "demo"; + const body = ` + + +`; + return c.html(renderShell({ + title: `${space} β€” Tasks Canvas | rSpace`, + moduleId: "rtasks", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body, + scripts: ``, + styles: ` +`, + })); +}); + +function escapeAttr(s: string): string { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + export const tasksModule: RSpaceModule = { id: "rtasks", name: "rTasks", diff --git a/server/index.ts b/server/index.ts index 6457f13e..a49e1598 100644 --- a/server/index.ts +++ b/server/index.ts @@ -83,6 +83,7 @@ import { agentsModule } from "../modules/ragents/mod"; import { docsModule } from "../modules/rdocs/mod"; import { designModule } from "../modules/rdesign/mod"; import { mindersModule } from "../modules/rminders/mod"; +import { scheduleModule } from "../modules/rschedule/mod"; import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; @@ -168,6 +169,7 @@ registerModule(splatModule); registerModule(photosModule); registerModule(socialsModule); registerModule(mindersModule); +registerModule(scheduleModule); registerModule(meetsModule); registerModule(chatsModule); registerModule(agentsModule); diff --git a/vite.config.ts b/vite.config.ts index f1e729cd..d7bbf03e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -756,6 +756,26 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rtasks/tasks.css"), ); + // Build rTasks canvas bundle (canvas shell + widgets) + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rtasks/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rtasks"), + lib: { + entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-canvas.ts"), + formats: ["es"], + fileName: () => "folk-tasks-canvas.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-tasks-canvas.js", + }, + }, + }, + }); + // Build chats module component await wasmBuild({ configFile: false, @@ -1462,6 +1482,31 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rminders/automation-canvas.css"), ); + // Build rschedule booking components + mkdirSync(resolve(__dirname, "dist/modules/rschedule"), { recursive: true }); + for (const name of ["folk-schedule-booking", "folk-schedule-cancel", "folk-schedule-admin"]) { + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rschedule/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rschedule"), + lib: { + entry: resolve(__dirname, `modules/rschedule/components/${name}.ts`), + formats: ["es"], + fileName: () => `${name}.js`, + }, + rollupOptions: { + output: { entryFileNames: `${name}.js` }, + }, + }, + }); + } + copyFileSync( + resolve(__dirname, "modules/rschedule/components/booking.css"), + resolve(__dirname, "dist/modules/rschedule/booking.css"), + ); + // ── Demo infrastructure ── // Build demo-sync-vanilla library