From 8042e8681530540664fe9084d2e4fa8b7d7ec242 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 17:26:45 -0400 Subject: [PATCH] wip(rschedule): schemas + folk-app-canvas/folk-widget shared components Schema for the new Calendly-style rSchedule port plus two new shared canvas components (folk-app-canvas, folk-widget) that replace per-rApp tab-based nav with a unified widget-on-canvas surface. --- modules/rschedule/schemas.ts | 260 +++++++++++++++++++++++++++ shared/components/folk-app-canvas.ts | 254 ++++++++++++++++++++++++++ shared/components/folk-widget.ts | 159 ++++++++++++++++ 3 files changed, 673 insertions(+) create mode 100644 modules/rschedule/schemas.ts create mode 100644 shared/components/folk-app-canvas.ts create mode 100644 shared/components/folk-widget.ts diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts new file mode 100644 index 00000000..139290d9 --- /dev/null +++ b/modules/rschedule/schemas.ts @@ -0,0 +1,260 @@ +/** + * rSchedule Automerge document schemas — Calendly-style booking. + * + * Three docs per space (or per user-space): + * {space}:schedule:config — settings + availability rules + overrides + gcal state + * {space}:schedule:bookings — bookings this space HOSTS (it is the bookee) + * {space}:schedule:invitations — bookings this space is INVITED to (as attendee) + * + * On booking create, server writes to host's `bookings` doc AND each invitee's + * `invitations` doc so every party sees the booking in their own schedule view + * without cross-space reads. + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Entity reference (space slug or user DID) ── + +export type EntityKind = 'space' | 'user'; + +export interface EntityRef { + kind: EntityKind; + /** For `space`: the space slug. For `user`: the did:key:... */ + id: string; + /** Display label — denormalized for UI, refreshed on render. */ + label?: string; +} + +// ── Config doc ── + +export interface ScheduleSettings { + displayName: string; + email: string; + bookingMessage: string; + /** Minutes per slot (15, 30, 45, 60, 90, 120). */ + slotDurationMin: number; + bufferBeforeMin: number; + bufferAfterMin: number; + minNoticeHours: number; + maxAdvanceDays: number; + timezone: string; + autoShiftTimezone: boolean; +} + +export interface AvailabilityRule { + id: string; + /** 0 = Sunday, 6 = Saturday (JS Date convention) */ + dayOfWeek: number; + /** "HH:MM" 24h format, in `settings.timezone` */ + startTime: string; + endTime: string; + isActive: boolean; + createdAt: number; +} + +export interface AvailabilityOverride { + id: string; + /** ISO date "YYYY-MM-DD" in `settings.timezone` */ + date: string; + /** If `isBlocked=true`, the whole day is unavailable. Otherwise uses startTime/endTime. */ + isBlocked: boolean; + startTime: string | null; + endTime: string | null; + reason: string; + createdAt: number; +} + +export interface GoogleAuthMeta { + connected: boolean; + connectedAt: number | null; + email: string | null; + calendarIds: string[]; + /** Opaque sync token used for incremental list calls. */ + syncToken: string | null; + lastSyncAt: number | null; + lastSyncStatus: 'ok' | 'error' | null; + lastSyncError: string | null; +} + +/** Cached Google Calendar busy events (opaque + non-cancelled). */ +export interface CachedGcalEvent { + id: string; + googleEventId: string; + calendarId: string; + title: string; + startTime: number; + endTime: number; + allDay: boolean; + status: 'confirmed' | 'tentative' | 'cancelled'; + transparency: 'opaque' | 'transparent'; +} + +export interface ScheduleConfigDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + settings: ScheduleSettings; + rules: Record; + overrides: Record; + googleAuth: GoogleAuthMeta; + googleEvents: Record; +} + +// ── Booking doc (host side) ── + +export type BookingStatus = 'confirmed' | 'cancelled' | 'completed'; + +export interface BookingAttendee { + id: string; + entity: EntityRef | null; + name: string; + email: string; + status: 'invited' | 'accepted' | 'declined'; + invitedAt: number; + respondedAt: number | null; +} + +export interface Booking { + id: string; + host: EntityRef; + guestName: string; + guestEmail: string; + guestNote: string; + attendees: Record; + startTime: number; + endTime: number; + timezone: string; + status: BookingStatus; + meetingLink: string | null; + googleEventId: string | null; + cancelToken: string; + cancellationReason: string | null; + reminderSentAt: number | null; + createdAt: number; + updatedAt: number; +} + +export interface ScheduleBookingsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + bookings: Record; +} + +// ── Invitations doc (attendee side) ── + +export interface Invitation { + id: string; + bookingId: string; + host: EntityRef; + title: string; + startTime: number; + endTime: number; + timezone: string; + status: BookingStatus; + response: 'invited' | 'accepted' | 'declined'; + meetingLink: string | null; + createdAt: number; + updatedAt: number; +} + +export interface ScheduleInvitationsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + invitations: Record; +} + +// ── Defaults ── + +export const DEFAULT_SETTINGS: ScheduleSettings = { + displayName: '', + email: '', + bookingMessage: 'Book a time to chat.', + slotDurationMin: 30, + bufferBeforeMin: 0, + bufferAfterMin: 0, + minNoticeHours: 2, + maxAdvanceDays: 60, + timezone: 'America/Vancouver', + autoShiftTimezone: false, +}; + +export const DEFAULT_GOOGLE_AUTH: GoogleAuthMeta = { + connected: false, + connectedAt: null, + email: null, + calendarIds: [], + syncToken: null, + lastSyncAt: null, + lastSyncStatus: null, + lastSyncError: null, +}; + +// ── Schema registrations ── + +export const scheduleConfigSchema: DocSchema = { + module: 'schedule', + collection: 'config', + version: 1, + init: (): ScheduleConfigDoc => ({ + meta: { module: 'schedule', collection: 'config', version: 1, spaceSlug: '', createdAt: Date.now() }, + settings: { ...DEFAULT_SETTINGS }, + rules: {}, + overrides: {}, + googleAuth: { ...DEFAULT_GOOGLE_AUTH }, + googleEvents: {}, + }), +}; + +export const scheduleBookingsSchema: DocSchema = { + module: 'schedule', + collection: 'bookings', + version: 1, + init: (): ScheduleBookingsDoc => ({ + meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() }, + bookings: {}, + }), +}; + +export const scheduleInvitationsSchema: DocSchema = { + module: 'schedule', + collection: 'invitations', + version: 1, + init: (): ScheduleInvitationsDoc => ({ + meta: { module: 'schedule', collection: 'invitations', version: 1, spaceSlug: '', createdAt: Date.now() }, + invitations: {}, + }), +}; + +// ── DocId helpers ── + +export function scheduleConfigDocId(space: string) { + return `${space}:schedule:config` as const; +} + +export function scheduleBookingsDocId(space: string) { + return `${space}:schedule:bookings` as const; +} + +export function scheduleInvitationsDocId(space: string) { + return `${space}:schedule:invitations` as const; +} + +// ── Limits ── + +export const MAX_RULES_PER_SPACE = 50; +export const MAX_OVERRIDES_PER_SPACE = 500; +export const MAX_GCAL_EVENTS_CACHED = 2000; diff --git a/shared/components/folk-app-canvas.ts b/shared/components/folk-app-canvas.ts new file mode 100644 index 00000000..6fc96d0f --- /dev/null +++ b/shared/components/folk-app-canvas.ts @@ -0,0 +1,254 @@ +/** + * — Canvas host for an rApp's feature widgets. + * + * Replaces per-rApp tab-based navigation with a unified canvas surface where + * each feature is a togglable widget. Supports grid mode (default — snap to + * CSS grid template) and free mode (optional — absolute positioning; future). + * + * Attributes: + * app-id — rApp identifier (used for layout persistence key, e.g. "rtasks") + * space — current space slug + * layout — JSON string of { id, gridArea, minimized }[] (optional; in-memory default) + * + * Child structure: + * + * + * + * + * Each
  • in the registry describes a widget definition. On canvas mount, + * widgets marked data-default="1" are added to the visible grid. Toolbar + * buttons toggle additional widgets. + * + * Grid template: responsive. Two-column on desktop, single-column on mobile. + * Consumers can override via `grid-template` attribute. + */ + +import './folk-widget'; + +interface WidgetDef { + id: string; + title: string; + icon: string; + gridArea: string; + element: HTMLElement; + default: boolean; +} + +class FolkAppCanvas extends HTMLElement { + private shadow: ShadowRoot; + private widgets = new Map(); + private visibleIds = new Set(); + private _rendered = false; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.collectWidgetRegistry(); + this.loadInitialVisibility(); + this.render(); + this._rendered = true; + } + + private collectWidgetRegistry() { + const tpl = this.querySelector('template[slot="widget-registry"]') as HTMLTemplateElement | null; + if (!tpl) { + console.warn('[folk-app-canvas] no widget-registry template found'); + return; + } + const nodes = tpl.content.querySelectorAll('li[data-widget-id]'); + nodes.forEach((li) => { + const id = (li as HTMLElement).dataset.widgetId!; + const title = (li as HTMLElement).dataset.title || id; + const icon = (li as HTMLElement).dataset.icon || ''; + const gridArea = (li as HTMLElement).dataset.gridArea || id; + const isDefault = (li as HTMLElement).dataset.default === '1'; + // Take the first element child as the widget content + const content = (li as HTMLElement).firstElementChild as HTMLElement | null; + if (!content) return; + this.widgets.set(id, { id, title, icon, gridArea, element: content.cloneNode(true) as HTMLElement, default: isDefault }); + }); + } + + private loadInitialVisibility() { + // URL override: ?widgets=board,activity + const params = new URLSearchParams(window.location.search); + const widgetsParam = params.get('widgets'); + if (widgetsParam) { + widgetsParam.split(',').forEach(id => { + if (this.widgets.has(id.trim())) this.visibleIds.add(id.trim()); + }); + return; + } + // Otherwise use defaults + this.widgets.forEach((w, id) => { + if (w.default) this.visibleIds.add(id); + }); + } + + private render() { + this.shadow.innerHTML = ` + +
    + +
    + ${this.renderGridSlots()} +
    +
    `; + this.bindEvents(); + this.mountVisibleWidgets(); + } + + private renderToolbarBtn(w: WidgetDef): string { + const active = this.visibleIds.has(w.id); + return ``; + } + + private renderGridSlots(): string { + // Empty grid cells — widgets will be mounted into the grid via JS with grid-area + return `
    `; + } + + private mountVisibleWidgets() { + const slots = this.shadow.getElementById('grid-slots'); + if (!slots) return; + slots.innerHTML = ''; + this.visibleIds.forEach(id => { + const def = this.widgets.get(id); + if (!def) return; + const wrapper = document.createElement('folk-widget'); + wrapper.setAttribute('widget-id', id); + wrapper.setAttribute('title', def.title); + wrapper.setAttribute('icon', def.icon); + wrapper.setAttribute('grid-area', def.gridArea); + wrapper.appendChild(def.element.cloneNode(true)); + wrapper.addEventListener('widget-close', () => { + this.visibleIds.delete(id); + this.render(); + }); + slots.appendChild(wrapper); + }); + } + + private bindEvents() { + this.shadow.querySelectorAll('[data-action="toggle"]').forEach(btn => { + btn.addEventListener('click', () => { + const id = (btn as HTMLElement).dataset.widgetId!; + if (this.visibleIds.has(id)) this.visibleIds.delete(id); + else this.visibleIds.add(id); + this.render(); + }); + }); + this.shadow.querySelector('[data-action="reset"]')?.addEventListener('click', () => { + this.visibleIds.clear(); + this.widgets.forEach((w, id) => { if (w.default) this.visibleIds.add(id); }); + this.render(); + }); + } + + private getStyles(): string { + // Default grid template: designed for up-to-4 widgets in a 2x2 arrangement. + // Consuming rApps should override via ::part(grid) { grid-template: ... } + return ` + :host { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: var(--rs-bg-base); } + .canvas-root { display: flex; flex-direction: column; height: 100%; min-height: 0; } + .toolbar { + display: flex; align-items: center; gap: 1rem; + padding: 0.5rem 1rem; + background: var(--rs-bg-surface); + border-bottom: 1px solid var(--rs-border); + flex-shrink: 0; + overflow-x: auto; + } + .toolbar-title { display: flex; align-items: center; flex-shrink: 0; } + .toolbar-label { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .toolbar-buttons { display: flex; gap: 0.375rem; flex: 1; flex-wrap: wrap; } + .toolbar-meta { display: flex; gap: 0.375rem; flex-shrink: 0; } + .tb-btn { + display: inline-flex; align-items: center; gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--rs-border); + border-radius: 999px; + background: transparent; + color: var(--rs-text-secondary); + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + transition: all 0.12s; + white-space: nowrap; + } + .tb-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); } + .tb-btn.active { + background: rgba(99,102,241,0.12); + border-color: var(--rs-primary, #6366f1); + color: var(--rs-text-primary); + } + .tb-icon { font-size: 0.95rem; line-height: 1; } + .meta-btn { + padding: 0.375rem 0.75rem; + border: 1px solid var(--rs-border); + border-radius: 6px; + background: transparent; + color: var(--rs-text-secondary); + cursor: pointer; + font-size: 0.75rem; + } + .meta-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + + .grid { flex: 1; min-height: 0; overflow: hidden; padding: 1rem; } + .grid-slots { + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: 1fr 1fr; + grid-auto-flow: dense; + gap: 1rem; + width: 100%; + height: 100%; + min-height: 0; + } + /* Named grid areas — rApps can reference via widget grid-area="board" etc. + If an area isn't defined in the template, the widget falls back to auto-placement. */ + + @media (max-width: 900px) { + .grid-slots { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-auto-rows: minmax(260px, auto); + } + .toolbar { padding: 0.5rem 0.75rem; } + .tb-label { display: none; } + .tb-btn { padding: 0.375rem 0.5rem; } + } + `; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +if (!customElements.get('folk-app-canvas')) customElements.define('folk-app-canvas', FolkAppCanvas); + +export {}; diff --git a/shared/components/folk-widget.ts b/shared/components/folk-widget.ts new file mode 100644 index 00000000..0ad6b348 --- /dev/null +++ b/shared/components/folk-widget.ts @@ -0,0 +1,159 @@ +/** + * — Widget shell for rApp canvas surfaces. + * + * Provides a consistent title bar (icon + label + controls) and a slotted body. + * Used inside to wrap feature views as togglable overlays. + * + * Attributes: + * widget-id — unique id within the canvas (used for layout + toolbar state) + * title — display label + * icon — emoji/glyph shown in title bar + * grid-area — optional CSS grid-area (for grid-mode canvas) + * minimized — boolean; collapses body + * no-close — hides the close button + * + * Events: + * widget-close — user clicked × + * widget-minimize — user toggled – + * widget-focus — user clicked header (for z-stacking / detail panels) + */ + +class FolkWidget extends HTMLElement { + static get observedAttributes() { + return ['title', 'icon', 'minimized', 'grid-area']; + } + + private shadow: ShadowRoot; + private _rendered = false; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + if (!this._rendered) { + this.render(); + this._rendered = true; + } + this.applyGridArea(); + } + + attributeChangedCallback(name: string) { + if (!this._rendered) return; + if (name === 'title' || name === 'icon') { + this.updateHeader(); + } else if (name === 'minimized') { + this.updateMinimized(); + } else if (name === 'grid-area') { + this.applyGridArea(); + } + } + + private applyGridArea() { + const area = this.getAttribute('grid-area'); + if (area) this.style.gridArea = area; + } + + private render() { + this.shadow.innerHTML = ` + +
    +
    +
    + ${this.esc(this.getAttribute('icon') || '')} + ${this.esc(this.getAttribute('title') || 'Widget')} +
    +
    + + ${this.hasAttribute('no-close') ? '' : ``} +
    +
    +
    + +
    +
    `; + this.bindEvents(); + this.updateMinimized(); + } + + private updateHeader() { + const icon = this.shadow.querySelector('.icon'); + const label = this.shadow.querySelector('.label'); + if (icon) icon.textContent = this.getAttribute('icon') || ''; + if (label) label.textContent = this.getAttribute('title') || 'Widget'; + } + + private updateMinimized() { + const w = this.shadow.querySelector('.widget'); + if (!w) return; + w.classList.toggle('minimized', this.hasAttribute('minimized')); + } + + private bindEvents() { + const header = this.shadow.querySelector('.header'); + header?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.closest('.controls')) return; + this.dispatchEvent(new CustomEvent('widget-focus', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } })); + }); + this.shadow.querySelector('[data-action="minimize"]')?.addEventListener('click', (e) => { + e.stopPropagation(); + const mini = this.hasAttribute('minimized'); + if (mini) this.removeAttribute('minimized'); else this.setAttribute('minimized', ''); + this.dispatchEvent(new CustomEvent('widget-minimize', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id'), minimized: !mini } })); + }); + this.shadow.querySelector('[data-action="close"]')?.addEventListener('click', (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('widget-close', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } })); + }); + } + + private getStyles(): string { + return ` + :host { display: block; min-width: 0; min-height: 0; } + .widget { + display: flex; flex-direction: column; + height: 100%; width: 100%; + background: var(--rs-bg-surface); + border: 1px solid var(--rs-border); + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + overflow: hidden; + } + .header { + display: flex; align-items: center; justify-content: space-between; + padding: 0.5rem 0.75rem; + background: var(--rs-bg-surface-raised, var(--rs-bg-hover)); + border-bottom: 1px solid var(--rs-border); + cursor: pointer; + user-select: none; + flex-shrink: 0; + } + .title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } + .icon { font-size: 1rem; flex-shrink: 0; } + .label { font-size: 0.8125rem; font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .controls { display: flex; gap: 0.125rem; flex-shrink: 0; } + .ctl { + background: transparent; border: none; color: var(--rs-text-secondary); + cursor: pointer; padding: 0.125rem 0.5rem; border-radius: 4px; + font-size: 1rem; line-height: 1; font-weight: 500; + } + .ctl:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); } + .body { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; } + .body > ::slotted(*) { flex: 1; min-height: 0; } + .widget.minimized .body { display: none; } + .widget.minimized { height: auto !important; } + `; + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +if (!customElements.get('folk-widget')) customElements.define('folk-widget', FolkWidget); + +export {};