/** * — 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; private choices: any[] = []; private loading = true; private space: string; /* Demo state */ private demoTab: "spider" | "ranking" | "voting" = "spider"; private hoveredPerson: string | null = null; private rankItems: { id: number; name: string; emoji: string }[] = []; private rankDragging: number | null = null; private voteOptions: { id: string; name: string; color: string; votes: number }[] = []; private voted = false; 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 = [ { target: '[data-tab="spider"]', title: "Spider Charts", message: "Compare multiple criteria on a radar chart. Each participant's scores overlay in real time.", advanceOnClick: true }, { target: '[data-tab="ranking"]', title: "Rankings", message: "Drag items to rank them. Rankings aggregate across all participants for a collective order.", advanceOnClick: true }, { target: '[data-tab="voting"]', title: "Voting", message: "Cast your vote and watch results update live with animated bars and totals.", advanceOnClick: true }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); this.space = this.getAttribute("space") || "demo"; this._tour = new TourEngine( this.shadow, FolkChoicesDashboard.TOUR_STEPS, "rchoices_tour_done", () => this.shadow.host as HTMLElement, ); } connectedCallback() { if (this.space === "demo") { this.loadDemoData(); } else { this.initMultiplayer(); } if (!localStorage.getItem("rchoices_tour_done")) { setTimeout(() => this._tour.start(), 1200); } } disconnectedCallback() { if (this.simTimer !== null) { 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 { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rchoices/); return match ? match[0] : "/rchoices"; } private async loadChoices() { this.loading = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/choices`); const data = await res.json(); this.choices = data.choices || []; } catch (e) { console.error("Failed to load choices:", e); } this.loading = false; this.render(); } private render() { const typeIcons: Record = { "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", }; const typeLabels: Record = { "folk-choice-vote": "Poll", "folk-choice-rank": "Ranking", "folk-choice-spider": "Spider Chart", }; const isLive = this.lfClient?.isConnected ?? false; this.shadow.innerHTML = `
Choices ${isLive ? `LIVE` : ''}
${this.lfClient ? `` : ''} + Canvas
${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 { return `

No choices in this space yet.

Open the canvas and use the Poll, Rank, or Spider buttons to create one.

`; } private renderGrid(icons: Record, labels: Record): string { return ``; } /* ===== Demo mode ===== */ private loadDemoData() { this.rankItems = [ { id: 1, name: "Thai Place", emoji: "🍜" }, { id: 2, name: "Pizza", emoji: "🍕" }, { id: 3, name: "Sushi Bar", emoji: "🍣" }, { id: 4, name: "Tacos", emoji: "🌮" }, { id: 5, name: "Burgers", emoji: "🍔" }, ]; this.voteOptions = [ { id: "action", name: "Action Movie", color: "#ef4444", votes: 2 }, { id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 }, { id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 }, { id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 }, ]; this.voted = false; this.votedId = null; this.startVoteSim(); this.renderDemo(); } private startVoteSim() { if (this.simTimer !== null) clearInterval(this.simTimer); const tick = () => { if (this.voted) return; const idx = Math.floor(Math.random() * this.voteOptions.length); this.voteOptions[idx].votes += 1; if (this.demoTab === "voting") this.renderDemo(); }; const scheduleNext = () => { const delay = 1200 + Math.random() * 2000; this.simTimer = window.setTimeout(() => { tick(); scheduleNext(); }, delay) as unknown as number; }; scheduleNext(); } private renderDemo() { const tabs: { key: "spider" | "ranking" | "voting"; label: string; icon: string }[] = [ { key: "spider", label: "Spider Chart", icon: "🕸" }, { key: "ranking", label: "Ranking", icon: "📊" }, { key: "voting", label: "Live Voting", icon: "☑" }, ]; let content = ""; if (this.demoTab === "spider") content = this.renderSpider(); else if (this.demoTab === "ranking") content = this.renderRanking(); else content = this.renderVoting(); this.shadow.innerHTML = `
Choices DEMO
${tabs.map((t) => ``).join("")}
${content}
`; this.bindDemoEvents(); this._tour.renderOverlay(); } startTour() { this._tour.start(); } /* -- Spider Chart -- */ private polarToXY(cx: number, cy: number, radius: number, angleDeg: number): { x: number; y: number } { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + radius * Math.cos(rad), y: cy + radius * Math.sin(rad) }; } private renderSpider(): string { const cx = 200, cy = 200, maxR = 150; const axes = ["Taste", "Price", "Speed", "Healthy", "Distance"]; const people: { name: string; color: string; values: number[] }[] = [ { name: "Alice", color: "#7c5bf5", values: [0.9, 0.6, 0.8, 0.4, 0.7] }, { name: "Bob", color: "#f59e0b", values: [0.5, 0.9, 0.6, 0.7, 0.8] }, { name: "Carol", color: "#10b981", values: [0.7, 0.4, 0.9, 0.8, 0.3] }, ]; const angleStep = 360 / axes.length; // Grid rings let gridLines = ""; for (let ring = 1; ring <= 5; ring++) { const r = (ring / 5) * maxR; const pts = axes.map((_, i) => { const p = this.polarToXY(cx, cy, r, i * angleStep); return `${p.x},${p.y}`; }).join(" "); gridLines += ``; } // Axis lines + labels let axisLines = ""; const labelOffset = 18; axes.forEach((label, i) => { const angle = i * angleStep; const tip = this.polarToXY(cx, cy, maxR, angle); axisLines += ``; const lp = this.polarToXY(cx, cy, maxR + labelOffset, angle); axisLines += `${this.esc(label)}`; }); // Data polygons let polygons = ""; people.forEach((person) => { const dimmed = this.hoveredPerson !== null && this.hoveredPerson !== person.name; const opacity = dimmed ? 0.12 : 0.25; const strokeOpacity = dimmed ? 0.2 : 1; const strokeWidth = dimmed ? 1 : 2; const pts = person.values.map((v, i) => { const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); return `${p.x},${p.y}`; }).join(" "); polygons += ``; // Dots at each vertex person.values.forEach((v, i) => { const p = this.polarToXY(cx, cy, v * maxR, i * angleStep); const dotOpacity = dimmed ? 0.2 : 1; polygons += ``; }); }); const legend = people.map((p) => `
${this.esc(p.name)}
` ).join(""); return `
${gridLines} ${axisLines} ${polygons}
${legend}
`; } /* -- Ranking -- */ private renderRanking(): string { const medalClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : "plain"; const items = this.rankItems.map((item, i) => `
  • ${i + 1} ${item.emoji} ${this.esc(item.name)}
  • ` ).join(""); return `
      ${items}
    `; } /* -- Live Voting -- */ private renderVoting(): string { const sorted = [...this.voteOptions].sort((a, b) => b.votes - a.votes); const total = sorted.reduce((s, o) => s + o.votes, 0); const maxVotes = Math.max(...sorted.map((o) => o.votes), 1); const items = sorted.map((opt) => { const pct = total > 0 ? (opt.votes / total) * 100 : 0; const isLeader = opt.votes === maxVotes && total > 4; return `
    ${this.esc(opt.name)}${isLeader ? `leading` : ""} ${opt.votes} ${pct.toFixed(0)}%
    `; }).join(""); const status = this.voted ? "Results are in!" : "Pick a movie \u2014 votes update live"; return `
    ${status}
    ${items} ${this.voted ? `
    ` : ""}
    `; } /* -- Demo event binding -- */ private bindDemoEvents() { this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); // Tab switching this.shadow.querySelectorAll(".demo-tab").forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab as "spider" | "ranking" | "voting"; if (tab && tab !== this.demoTab) { this.demoTab = tab; this.renderDemo(); } }); }); // Spider legend hover this.shadow.querySelectorAll(".spider-legend-item").forEach((el) => { el.addEventListener("pointerenter", () => { this.hoveredPerson = el.dataset.person || null; this.renderDemo(); }); el.addEventListener("pointerleave", () => { this.hoveredPerson = null; this.renderDemo(); }); el.addEventListener("click", () => { this.hoveredPerson = this.hoveredPerson === (el.dataset.person || null) ? null : (el.dataset.person || null); this.renderDemo(); }); }); // Ranking drag-and-drop (pointer events — works with touch, pen, mouse) const rankList = this.shadow.querySelector(".rank-list"); if (rankList) { const items = rankList.querySelectorAll(".rank-item"); items.forEach((li) => { li.removeAttribute("draggable"); li.addEventListener("pointerdown", (e: PointerEvent) => { if (e.button !== 0) return; const id = parseInt(li.dataset.rankId || "0", 10); this.rankDragging = id; li.classList.add("dragging"); li.setPointerCapture(e.pointerId); li.style.touchAction = "none"; }); li.addEventListener("pointermove", (e: PointerEvent) => { if (this.rankDragging === null) return; e.preventDefault(); // Find target item under pointer const allItems = rankList.querySelectorAll(".rank-item"); allItems.forEach(item => item.classList.remove("drag-over")); const target = this.shadow.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const targetLi = target?.closest?.(".rank-item") as HTMLLIElement | null; if (targetLi && targetLi !== li) targetLi.classList.add("drag-over"); }); li.addEventListener("pointerup", (e: PointerEvent) => { if (this.rankDragging === null) return; const allItems = rankList.querySelectorAll(".rank-item"); allItems.forEach(item => { item.classList.remove("drag-over"); item.classList.remove("dragging"); }); li.style.touchAction = ""; const target = this.shadow.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const targetLi = target?.closest?.(".rank-item") as HTMLLIElement | null; if (targetLi) { const targetId = parseInt(targetLi.dataset.rankId || "0", 10); if (this.rankDragging !== targetId) { const fromIdx = this.rankItems.findIndex((r) => r.id === this.rankDragging); const toIdx = this.rankItems.findIndex((r) => r.id === targetId); if (fromIdx !== -1 && toIdx !== -1) { const [moved] = this.rankItems.splice(fromIdx, 1); this.rankItems.splice(toIdx, 0, moved); } } } this.rankDragging = null; this.renderDemo(); }); li.addEventListener("pointercancel", () => { const allItems = rankList.querySelectorAll(".rank-item"); allItems.forEach(item => { item.classList.remove("drag-over"); item.classList.remove("dragging"); }); li.style.touchAction = ""; this.rankDragging = null; }); }); } // Voting click this.shadow.querySelectorAll(".vote-option").forEach((el) => { el.addEventListener("click", () => { if (this.voted) return; const id = el.dataset.voteId || ""; const opt = this.voteOptions.find((o) => o.id === id); if (opt) { opt.votes += 1; this.voted = true; this.votedId = id; this.renderDemo(); } }); }); // Vote reset const resetBtn = this.shadow.querySelector(".vote-reset"); if (resetBtn) { resetBtn.addEventListener("click", () => { this.voteOptions = [ { id: "action", name: "Action Movie", color: "#ef4444", votes: 2 }, { id: "comedy", name: "Comedy", color: "#f59e0b", votes: 3 }, { id: "horror", name: "Horror", color: "#8b5cf6", votes: 1 }, { id: "scifi", name: "Sci-Fi", color: "#06b6d4", votes: 2 }, ]; this.voted = false; this.votedId = null; this.startVoteSim(); this.renderDemo(); }); } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } } customElements.define("folk-choices-dashboard", FolkChoicesDashboard);