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.
This commit is contained in:
Jeff Emmett 2026-04-16 17:26:45 -04:00
parent 3a614e2866
commit 8042e86815
3 changed files with 673 additions and 0 deletions

View File

@ -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<string, AvailabilityRule>;
overrides: Record<string, AvailabilityOverride>;
googleAuth: GoogleAuthMeta;
googleEvents: Record<string, CachedGcalEvent>;
}
// ── 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<string, BookingAttendee>;
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<string, Booking>;
}
// ── 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<string, Invitation>;
}
// ── 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<ScheduleConfigDoc> = {
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<ScheduleBookingsDoc> = {
module: 'schedule',
collection: 'bookings',
version: 1,
init: (): ScheduleBookingsDoc => ({
meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() },
bookings: {},
}),
};
export const scheduleInvitationsSchema: DocSchema<ScheduleInvitationsDoc> = {
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;

View File

@ -0,0 +1,254 @@
/**
* <folk-app-canvas> 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:
* <folk-app-canvas>
* <template slot="widget-registry"> declarative widget menu (see below)
* <li data-widget-id="board" data-title="Board" data-icon="📋" data-grid-area="board" data-default="1">
* <folk-tasks-board></folk-tasks-board>
* </li>
* ...
* </template>
* </folk-app-canvas>
*
* Each <li> 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<string, WidgetDef>();
private visibleIds = new Set<string>();
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 = `
<style>${this.getStyles()}</style>
<div class="canvas-root">
<nav class="toolbar" part="toolbar">
<div class="toolbar-title">
<span class="toolbar-label">Widgets</span>
</div>
<div class="toolbar-buttons">
${Array.from(this.widgets.values()).map(w => this.renderToolbarBtn(w)).join('')}
</div>
<div class="toolbar-meta">
<button class="meta-btn" data-action="reset" title="Reset to default layout">Reset</button>
</div>
</nav>
<div class="grid" part="grid">
${this.renderGridSlots()}
</div>
</div>`;
this.bindEvents();
this.mountVisibleWidgets();
}
private renderToolbarBtn(w: WidgetDef): string {
const active = this.visibleIds.has(w.id);
return `<button class="tb-btn ${active ? 'active' : ''}" data-action="toggle" data-widget-id="${this.esc(w.id)}" title="${this.esc(w.title)}">
<span class="tb-icon">${this.esc(w.icon)}</span>
<span class="tb-label">${this.esc(w.title)}</span>
</button>`;
}
private renderGridSlots(): string {
// Empty grid cells — widgets will be mounted into the grid via JS with grid-area
return `<div class="grid-slots" id="grid-slots"></div>`;
}
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 {};

View File

@ -0,0 +1,159 @@
/**
* <folk-widget> Widget shell for rApp canvas surfaces.
*
* Provides a consistent title bar (icon + label + controls) and a slotted body.
* Used inside <folk-app-canvas> 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 = `
<style>${this.getStyles()}</style>
<div class="widget" part="widget">
<header class="header" part="header">
<div class="title">
<span class="icon" part="icon">${this.esc(this.getAttribute('icon') || '')}</span>
<span class="label" part="label">${this.esc(this.getAttribute('title') || 'Widget')}</span>
</div>
<div class="controls">
<button class="ctl" data-action="minimize" title="Minimize" aria-label="Minimize"></button>
${this.hasAttribute('no-close') ? '' : `<button class="ctl" data-action="close" title="Close" aria-label="Close">×</button>`}
</div>
</header>
<div class="body" part="body">
<slot></slot>
</div>
</div>`;
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 {};