From b67f30ac0a0c7e3e7ae1985a91e6c9347fe63c84 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 17:28:42 -0700 Subject: [PATCH] feat(rchoices): add multiplayer voting sessions via Automerge CRDT Create local-first-client.ts and schemas.ts for real-time collaborative voting. Dashboard now shows live polls with session cards, vote tallies, and owner controls (close/delete). Votes sync across tabs via WebSocket. Co-Authored-By: Claude Opus 4.6 --- ...shared-folk-applet-catalog.ts-component.md | 19 +- ...-to-all-12-existing-multiplayer-modules.md | 15 +- ...ync-to-rchoices-voting-ranking-sessions.md | 3 +- ...k-applet-catalog.ts-and-wire-into-shell.md | 25 ++ .../components/folk-choices-dashboard.ts | 327 +++++++++++++++++- modules/rchoices/local-first-client.ts | 131 +++++++ modules/rchoices/schemas.ts | 82 +++++ 7 files changed, 580 insertions(+), 22 deletions(-) create mode 100644 backlog/tasks/task-119 - Implement-folk-applet-catalog.ts-and-wire-into-shell.md create mode 100644 modules/rchoices/local-first-client.ts create mode 100644 modules/rchoices/schemas.ts diff --git a/backlog/tasks/task-118.1 - Build-shared-folk-applet-catalog.ts-component.md b/backlog/tasks/task-118.1 - Build-shared-folk-applet-catalog.ts-component.md index 4d2d6bc..653bda6 100644 --- a/backlog/tasks/task-118.1 - Build-shared-folk-applet-catalog.ts-component.md +++ b/backlog/tasks/task-118.1 - Build-shared-folk-applet-catalog.ts-component.md @@ -1,9 +1,10 @@ --- id: TASK-118.1 title: Build shared folk-applet-catalog.ts component -status: To Do +status: Done assignee: [] created_date: '2026-03-16 00:05' +updated_date: '2026-03-16 00:21' labels: - multiplayer - ui @@ -39,9 +40,15 @@ Create a reusable web component that renders the "Pull rApplet to rSpace" catalo ## Acceptance Criteria -- [ ] #1 Catalog modal shows all registered modules with icon, name, description -- [ ] #2 Space owners can toggle modules on/off with immediate effect -- [ ] #3 Non-owners see read-only view of enabled modules -- [ ] #4 App switcher updates when modules are toggled -- [ ] #5 Works in demo mode with local-only toggle (no API call) +- [x] #1 Catalog modal shows all registered modules with icon, name, description +- [x] #2 Space owners can toggle modules on/off with immediate effect +- [x] #3 Non-owners see read-only view of enabled modules +- [x] #4 App switcher updates when modules are toggled +- [x] #5 Works in demo mode with local-only toggle (no API call) + +## Final Summary + + +Built "Manage rApps" panel into the existing app switcher sidebar. Extends `rstack-app-switcher` with expandable catalog showing all modules (enabled + disabled). Space owners can toggle modules via + / − buttons calling `PATCH /api/spaces/:slug/modules`. Shell passes full module list via `setAllModules()`. Demo mode has local-only fallback. + diff --git a/backlog/tasks/task-118.14 - Add-Pull-to-rSpace-button-to-all-12-existing-multiplayer-modules.md b/backlog/tasks/task-118.14 - Add-Pull-to-rSpace-button-to-all-12-existing-multiplayer-modules.md index 125e5cc..2e62e97 100644 --- a/backlog/tasks/task-118.14 - Add-Pull-to-rSpace-button-to-all-12-existing-multiplayer-modules.md +++ b/backlog/tasks/task-118.14 - Add-Pull-to-rSpace-button-to-all-12-existing-multiplayer-modules.md @@ -1,9 +1,10 @@ --- id: TASK-118.14 title: Add "Pull to rSpace" button to all 12 existing multiplayer modules -status: To Do +status: Done assignee: [] created_date: '2026-03-16 00:07' +updated_date: '2026-03-16 00:21' labels: - multiplayer - tier-1 @@ -29,7 +30,13 @@ This task depends on TASK-118.1 (the catalog component) being built first. ## Acceptance Criteria -- [ ] #1 All 12 modules show 'not enabled' state when disabled for a space -- [ ] #2 All 12 modules appear correctly in the applet catalog -- [ ] #3 Enabling/disabling a module immediately updates the app switcher +- [x] #1 All 12 modules show 'not enabled' state when disabled for a space +- [x] #2 All 12 modules appear correctly in the applet catalog +- [x] #3 Enabling/disabling a module immediately updates the app switcher + +## Final Summary + + +No per-module changes needed. The existing middleware in index.ts:1667 already returns 404 for disabled modules. The "Manage rApps" catalog in TASK-118.1 handles discovery and toggling. The shell's visibleModules filtering (shell.ts:101-103) already hides disabled modules from the app switcher. All 12 multiplayer modules work with the catalog out of the box. + diff --git a/backlog/tasks/task-118.2 - Add-multiplayer-sync-to-rchoices-voting-ranking-sessions.md b/backlog/tasks/task-118.2 - Add-multiplayer-sync-to-rchoices-voting-ranking-sessions.md index b3dcc0c..55c4792 100644 --- a/backlog/tasks/task-118.2 - Add-multiplayer-sync-to-rchoices-voting-ranking-sessions.md +++ b/backlog/tasks/task-118.2 - Add-multiplayer-sync-to-rchoices-voting-ranking-sessions.md @@ -1,9 +1,10 @@ --- id: TASK-118.2 title: Add multiplayer sync to rchoices (voting/ranking sessions) -status: To Do +status: In Progress assignee: [] created_date: '2026-03-16 00:05' +updated_date: '2026-03-16 00:21' labels: - multiplayer - tier-2 diff --git a/backlog/tasks/task-119 - Implement-folk-applet-catalog.ts-and-wire-into-shell.md b/backlog/tasks/task-119 - Implement-folk-applet-catalog.ts-and-wire-into-shell.md new file mode 100644 index 0000000..fcc852d --- /dev/null +++ b/backlog/tasks/task-119 - Implement-folk-applet-catalog.ts-and-wire-into-shell.md @@ -0,0 +1,25 @@ +--- +id: TASK-119 +title: Implement folk-applet-catalog.ts and wire into shell +status: Done +assignee: [] +created_date: '2026-03-16 00:14' +updated_date: '2026-03-16 00:21' +labels: + - multiplayer + - in-progress +dependencies: [] +priority: high +--- + +## Description + + +Starting implementation of TASK-118.1 + + +## Final Summary + + +Completed as part of TASK-118.1 + diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index ffc2696..bc6b340 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -1,9 +1,28 @@ /** * — lists choice shapes (polls, rankings, spider charts) * from the current space and links to the canvas to create/interact with them. + * + * Multiplayer: uses ChoicesLocalFirstClient for real-time voting session sync. */ import { TourEngine } from "../../../shared/tour-engine"; +import { ChoicesLocalFirstClient } from "../local-first-client"; +import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; + +// ── Auth helpers ── +function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { + try { + const raw = localStorage.getItem("encryptid_session"); + if (!raw) return null; + const s = JSON.parse(raw); + return s?.accessToken ? s : null; + } catch { return null; } +} +function getMyDid(): string | null { + const s = getSession(); + if (!s) return null; + return (s.claims as any).did || s.claims.sub; +} class FolkChoicesDashboard extends HTMLElement { private shadow: ShadowRoot; @@ -21,6 +40,13 @@ class FolkChoicesDashboard extends HTMLElement { private votedId: string | null = null; private simTimer: number | null = null; + /* Multiplayer state */ + private lfClient: ChoicesLocalFirstClient | null = null; + private _lfcUnsub: (() => void) | null = null; + private sessions: ChoiceSession[] = []; + private activeSessionId: string | null = null; + private sessionVotes: Map = new Map(); + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -45,8 +71,7 @@ class FolkChoicesDashboard extends HTMLElement { if (this.space === "demo") { this.loadDemoData(); } else { - this.render(); - this.loadChoices(); + this.initMultiplayer(); } if (!localStorage.getItem("rchoices_tour_done")) { setTimeout(() => this._tour.start(), 1200); @@ -58,6 +83,86 @@ class FolkChoicesDashboard extends HTMLElement { clearInterval(this.simTimer); this.simTimer = null; } + this._lfcUnsub?.(); + this._lfcUnsub = null; + this.lfClient?.disconnect(); + } + + private async initMultiplayer() { + this.loading = true; + this.render(); + + try { + this.lfClient = new ChoicesLocalFirstClient(this.space); + await this.lfClient.init(); + await this.lfClient.subscribe(); + + this._lfcUnsub = this.lfClient.onChange((doc) => { + this.extractSessions(doc); + this.render(); + this.bindLiveEvents(); + }); + + const doc = this.lfClient.getDoc(); + if (doc) this.extractSessions(doc); + } catch (err) { + console.warn('[rChoices] Local-first init failed, falling back to API:', err); + } + + // Also load canvas-based choices + await this.loadChoices(); + + this.loading = false; + this.render(); + this.bindLiveEvents(); + } + + private extractSessions(doc: ChoicesDoc) { + this.sessions = doc.sessions ? Object.values(doc.sessions).sort((a, b) => b.createdAt - a.createdAt) : []; + // Pre-compute votes per session + this.sessionVotes.clear(); + if (doc.votes) { + for (const [key, vote] of Object.entries(doc.votes)) { + const sessionId = key.split(':')[0]; + if (!this.sessionVotes.has(sessionId)) this.sessionVotes.set(sessionId, []); + this.sessionVotes.get(sessionId)!.push(vote); + } + } + } + + private createSession() { + const title = prompt('Poll title:'); + if (!title || !this.lfClient) return; + + const optionsRaw = prompt('Options (comma-separated):'); + if (!optionsRaw) return; + + const colors = ['#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316']; + const options = optionsRaw.split(',').map((label, i) => ({ + id: crypto.randomUUID(), + label: label.trim(), + color: colors[i % colors.length], + })).filter(o => o.label); + + const session: ChoiceSession = { + id: crypto.randomUUID(), + title, + type: 'vote', + mode: 'single', + options, + createdBy: getMyDid(), + createdAt: Date.now(), + closed: false, + }; + + this.lfClient.createSession(session); + this.activeSessionId = session.id; + } + + private castVoteOnSession(sessionId: string, optionId: string) { + const myDid = getMyDid(); + if (!myDid || !this.lfClient) return; + this.lfClient.castVote(sessionId, myDid, { [optionId]: 1 }); } private getApiBase(): string { @@ -92,6 +197,8 @@ class FolkChoicesDashboard extends HTMLElement { "folk-choice-spider": "Spider Chart", }; + const isLive = this.lfClient?.isConnected ?? false; + this.shadow.innerHTML = `
Choices + ${isLive ? `LIVE` : ''}
- ➕ New on Canvas + ${this.lfClient ? `` : ''} + + Canvas
-
- Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas. - Create them there and they'll appear here for quick access. -
- - ${this.loading ? `
⏳ Loading choices...
` : - this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)} + ${this.loading ? `
Loading...
` : this.renderLiveContent(typeIcons, typeLabels)} `; + + this.bindLiveEvents(); + } + + private renderLiveContent(typeIcons: Record, typeLabels: Record): string { + let html = ''; + + // Active session view + if (this.activeSessionId) { + const session = this.sessions.find(s => s.id === this.activeSessionId); + if (session) { + html += this.renderActiveSession(session); + return html; + } + this.activeSessionId = null; + } + + // Session cards + if (this.sessions.length > 0) { + html += ``; + html += this.renderSessionsList(); + } + + // Canvas choices + if (this.choices.length > 0) { + if (this.sessions.length > 0) html += `
`; + html += ``; + html += this.renderGrid(typeIcons, typeLabels); + } + + if (this.sessions.length === 0 && this.choices.length === 0) { + html += this.renderEmpty(); + } + + return html; + } + + private renderSessionsList(): string { + const cards = this.sessions.map(session => { + const votes = this.sessionVotes.get(session.id) || []; + const status = session.closed ? 'closed' : 'open'; + const timeAgo = this.timeAgo(session.createdAt); + return `
+
+ ${status} + ${this.esc(session.title)} +
+
+ ${session.options.length} options + ${votes.length} vote${votes.length !== 1 ? 's' : ''} + ${timeAgo} +
+
`; + }).join(''); + return `
${cards}
`; + } + + private renderActiveSession(session: ChoiceSession): string { + const myDid = getMyDid(); + const votes = this.sessionVotes.get(session.id) || []; + const myVote = myDid ? this.lfClient?.getMyVote(session.id, myDid) ?? null : null; + const totalVotes = votes.length; + + // Tally votes per option + const tally: Record = {}; + for (const opt of session.options) tally[opt.id] = 0; + for (const vote of votes) { + for (const [optId, val] of Object.entries(vote.choices)) { + if (val > 0) tally[optId] = (tally[optId] || 0) + 1; + } + } + const bars = session.options.map(opt => { + const count = tally[opt.id] || 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + const isMyVote = myVote?.choices?.[opt.id] && myVote.choices[opt.id] > 0; + return `
+
+ + ${this.esc(opt.label)} + ${isMyVote ? `` : ''} + ${count} + ${pct.toFixed(0)}% +
`; + }).join(''); + + const isOwner = myDid && session.createdBy === myDid; + + return `
+
+ + ${this.esc(session.title)} + ${session.closed ? `closed` : `open`} +
+ ${!session.closed && !myVote ? `
Tap an option to vote
` : ''} + ${bars} +
${totalVotes} vote${totalVotes !== 1 ? 's' : ''} total
+ ${isOwner ? `
+ ${!session.closed ? `` : ''} + +
` : ''} +
`; + } + + private bindLiveEvents() { + // New session button + this.shadow.querySelector('[data-action="new-session"]')?.addEventListener('click', () => this.createSession()); + + // Session card clicks + this.shadow.querySelectorAll('.session-card').forEach(el => { + el.addEventListener('click', () => { + const sid = el.dataset.sessionId; + if (sid) { this.activeSessionId = sid; this.render(); } + }); + }); + + // Back to list + this.shadow.querySelector('[data-action="back-to-list"]')?.addEventListener('click', () => { + this.activeSessionId = null; + this.render(); + }); + + // Vote option clicks + this.shadow.querySelectorAll('.vote-bar-row').forEach(el => { + el.addEventListener('click', () => { + const sessionId = el.dataset.voteSession; + const optionId = el.dataset.voteOption; + if (!sessionId || !optionId) return; + const session = this.sessions.find(s => s.id === sessionId); + if (session?.closed) return; + const myDid = getMyDid(); + if (!myDid) return; + const existing = this.lfClient?.getMyVote(sessionId, myDid); + if (existing) return; // Already voted + this.castVoteOnSession(sessionId, optionId); + }); + }); + + // Close session + this.shadow.querySelector('[data-action="close-session"]')?.addEventListener('click', (e) => { + const sid = (e.currentTarget as HTMLElement).dataset.sessionId; + if (sid && this.lfClient) this.lfClient.closeSession(sid); + }); + + // Delete session + this.shadow.querySelector('[data-action="delete-session"]')?.addEventListener('click', (e) => { + const sid = (e.currentTarget as HTMLElement).dataset.sessionId; + if (sid && this.lfClient && confirm('Delete this poll?')) { + this.lfClient.deleteSession(sid); + this.activeSessionId = null; + } + }); + } + + private timeAgo(ts: number): string { + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; } private renderEmpty(): string { diff --git a/modules/rchoices/local-first-client.ts b/modules/rchoices/local-first-client.ts new file mode 100644 index 0000000..7ccb04c --- /dev/null +++ b/modules/rchoices/local-first-client.ts @@ -0,0 +1,131 @@ +/** + * rChoices Local-First Client + * + * Wraps the shared local-first stack for collaborative voting sessions. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { choicesSchema, choicesDocId } from './schemas'; +import type { ChoicesDoc, ChoiceSession, ChoiceVote, ChoiceOption } from './schemas'; + +export class ChoicesLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(choicesSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('choices', 'sessions'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, choicesSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[ChoicesClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = choicesDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, choicesSchema, binary) + : this.#documents.open(docId, choicesSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): ChoicesDoc | undefined { + return this.#documents.get(choicesDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: ChoicesDoc) => void): () => void { + return this.#sync.onChange(choicesDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Session CRUD ── + + createSession(session: ChoiceSession): void { + const docId = choicesDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Create session ${session.title}`, (d) => { + d.sessions[session.id] = session; + }); + } + + closeSession(sessionId: string): void { + const docId = choicesDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Close session`, (d) => { + if (d.sessions[sessionId]) d.sessions[sessionId].closed = true; + }); + } + + deleteSession(sessionId: string): void { + const docId = choicesDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete session`, (d) => { + delete d.sessions[sessionId]; + // Clean up votes for this session + for (const key of Object.keys(d.votes)) { + if (key.startsWith(`${sessionId}:`)) delete d.votes[key]; + } + }); + } + + // ── Voting ── + + castVote(sessionId: string, participantDid: string, choices: Record): void { + const docId = choicesDocId(this.#space) as DocumentId; + const voteKey = `${sessionId}:${participantDid}`; + this.#sync.change(docId, `Cast vote`, (d) => { + d.votes[voteKey] = { + participantDid, + choices, + updatedAt: Date.now(), + }; + }); + } + + getVotesForSession(sessionId: string): ChoiceVote[] { + const doc = this.getDoc(); + if (!doc?.votes) return []; + return Object.entries(doc.votes) + .filter(([key]) => key.startsWith(`${sessionId}:`)) + .map(([, vote]) => vote); + } + + getMyVote(sessionId: string, myDid: string): ChoiceVote | null { + const doc = this.getDoc(); + return doc?.votes?.[`${sessionId}:${myDid}`] ?? null; + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rchoices/schemas.ts b/modules/rchoices/schemas.ts new file mode 100644 index 0000000..386104c --- /dev/null +++ b/modules/rchoices/schemas.ts @@ -0,0 +1,82 @@ +/** + * rChoices Automerge document schemas. + * + * Stores collaborative voting sessions with real-time vote sync. + * Canvas-based choice shapes remain in community-store; this doc + * handles standalone multiplayer sessions via the dashboard. + * + * DocId format: {space}:choices:sessions + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface ChoiceOption { + id: string; + label: string; + color: string; +} + +export interface ChoiceSession { + id: string; + title: string; + type: 'vote' | 'rank' | 'score'; + /** 'vote' mode: plurality, approval, or single */ + mode?: 'plurality' | 'approval' | 'single'; + options: ChoiceOption[]; + createdBy: string | null; + createdAt: number; + closed: boolean; +} + +export interface ChoiceVote { + participantDid: string; + /** For 'vote': optionId → 1 (selected). For 'rank': optionId → rank position. For 'score': optionId → 0-100. */ + choices: Record; + updatedAt: number; +} + +export interface ChoicesDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + sessions: Record; + /** Keyed by `${sessionId}:${participantDid}` */ + votes: Record; +} + +// ── Schema registration ── + +export const choicesSchema: DocSchema = { + module: 'choices', + collection: 'sessions', + version: 1, + init: (): ChoicesDoc => ({ + meta: { + module: 'choices', + collection: 'sessions', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + sessions: {}, + votes: {}, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.sessions) doc.sessions = {}; + if (!doc.votes) doc.votes = {}; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function choicesDocId(space: string) { + return `${space}:choices:sessions` as const; +}