/** * rVote demo — client-side WebSocket controller. * * Connects via DemoSync, renders poll cards into #rd-polls-container, * and handles vote +/- button clicks via event delegation. */ import { DemoSync } from "@lib/demo-sync-vanilla"; import type { DemoShape } from "@lib/demo-sync-vanilla"; // ── Types ── interface PollOption { label: string; votes: number; } interface DemoPoll extends DemoShape { question: string; options: PollOption[]; totalVoters: number; status: "active" | "closed"; endsAt: string; } function isPoll(shape: DemoShape): shape is DemoPoll { return shape.type === "demo-poll" && Array.isArray((shape as DemoPoll).options); } // ── Helpers ── function formatDeadline(dateStr: string): string { const diff = new Date(dateStr).getTime() - Date.now(); if (diff <= 0) return "Voting closed"; const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); return `${days} day${days !== 1 ? "s" : ""} left`; } function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // ── DOM refs ── const connBadge = document.getElementById("rd-conn-badge") as HTMLElement; const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement; const loadingEl = document.getElementById("rd-loading") as HTMLElement; const emptyEl = document.getElementById("rd-empty") as HTMLElement; const container = document.getElementById("rd-polls-container") as HTMLElement; // ── DemoSync ── const sync = new DemoSync({ filter: ["demo-poll"] }); // Show loading spinner immediately loadingEl.style.display = ""; // ── Connection events ── sync.addEventListener("connected", () => { connBadge.className = "rd-status rd-status--connected"; connBadge.textContent = "Connected"; resetBtn.disabled = false; }); sync.addEventListener("disconnected", () => { connBadge.className = "rd-status rd-status--disconnected"; connBadge.textContent = "Disconnected"; resetBtn.disabled = true; }); // ── Snapshot → render ── sync.addEventListener("snapshot", ((e: CustomEvent) => { const shapes: Record = e.detail.shapes; const polls = Object.values(shapes).filter(isPoll); // Hide loading loadingEl.style.display = "none"; // Show/hide empty state if (polls.length === 0) { emptyEl.style.display = ""; container.innerHTML = ""; return; } emptyEl.style.display = "none"; // Render poll cards container.innerHTML = polls.map((poll) => renderPollCard(poll)).join(""); }) as EventListener); // ── Render a single poll card ── function renderPollCard(poll: DemoPoll): string { const total = poll.options.reduce((sum, opt) => sum + opt.votes, 0); const maxVotes = Math.max(...poll.options.map((o) => o.votes), 1); const statusBadge = poll.status === "active" ? `Active` : `Closed`; const optionsHTML = poll.options .map((opt, idx) => { const pct = total > 0 ? (opt.votes / total) * 100 : 0; const barPct = maxVotes > 0 ? (opt.votes / maxVotes) * 100 : 0; return `
${opt.votes}
${esc(opt.label)} ${Math.round(pct)}%
`; }) .join(""); return `
🗳 ${esc(poll.question)}
${statusBadge}
👥 ${poll.totalVoters} voter${poll.totalVoters !== 1 ? "s" : ""} ⏱ ${formatDeadline(poll.endsAt)} ${total} total vote${total !== 1 ? "s" : ""}
${optionsHTML}
`; } // ── Event delegation for vote buttons ── container.addEventListener("click", (e) => { const btn = (e.target as HTMLElement).closest("button[data-vote]"); if (!btn || btn.disabled) return; const pollId = btn.dataset.poll!; const optIdx = parseInt(btn.dataset.opt!, 10); const delta = parseInt(btn.dataset.vote!, 10); const shape = sync.shapes[pollId]; if (!shape || !isPoll(shape)) return; const updatedOptions = shape.options.map((opt, i) => { if (i !== optIdx) return opt; return { ...opt, votes: Math.max(0, opt.votes + delta) }; }); sync.updateShape(pollId, { options: updatedOptions }); }); // ── Reset button ── resetBtn.addEventListener("click", async () => { resetBtn.disabled = true; try { await sync.resetDemo(); } catch (err) { console.error("Reset failed:", err); } finally { if (sync.connected) resetBtn.disabled = false; } }); // ── Connect ── sync.connect(); // ═══════════════════════════════════════════════════════════ // Conviction Voting Simulator (local-only, no sync) // ═══════════════════════════════════════════════════════════ interface SimProposal { id: string; title: string; description: string; baseScore: number; myVotes: number; // how many times user has upvoted (0..5) } const MAX_CREDITS = 50; let simCredits = MAX_CREDITS; let simProposals: SimProposal[] = getInitialProposals(); function getInitialProposals(): SimProposal[] { return [ { id: "s1", title: "Add dark mode across all r* modules", description: "CSS custom properties for consistent theming", baseScore: 45, myVotes: 0 }, { id: "s2", title: "Real-time collaboration in rNotes", description: "Automerge CRDT simultaneous editing", baseScore: 72, myVotes: 0 }, { id: "s3", title: "Cosmolocal print-on-demand for merch", description: "Route orders to closest provider", baseScore: 38, myVotes: 0 }, { id: "s4", title: "EncryptID passkeys for all auth", description: "One WebAuthn passkey, all apps", baseScore: 61, myVotes: 0 }, { id: "s5", title: "Federated moderation tools", description: "Community-driven content policies", baseScore: 29, myVotes: 0 }, ]; } function quadraticCost(votes: number): number { return votes * votes; } function totalSpent(): number { return simProposals.reduce((sum, p) => sum + quadraticCost(p.myVotes), 0); } function effectiveScore(p: SimProposal): number { return p.baseScore + p.myVotes; } function costOfNextVote(p: SimProposal): number { return quadraticCost(p.myVotes + 1) - quadraticCost(p.myVotes); } function canAffordNextVote(p: SimProposal): boolean { return costOfNextVote(p) <= simCredits && p.myVotes < 5; } function renderSimulator(): void { const simContainer = document.getElementById("rd-sim-proposals"); const creditsEl = document.getElementById("rd-credits"); if (!simContainer || !creditsEl) return; simCredits = MAX_CREDITS - totalSpent(); creditsEl.textContent = String(simCredits); creditsEl.style.color = simCredits < 10 ? "#ef4444" : simCredits < 20 ? "#f97316" : "#22c55e"; // Sort by effective score descending const sorted = [...simProposals].sort((a, b) => effectiveScore(b) - effectiveScore(a)); simContainer.innerHTML = sorted.map((p, rank) => { const score = effectiveScore(p); const mySpent = quadraticCost(p.myVotes); const nextCost = costOfNextVote(p); const canVote = canAffordNextVote(p); const canUnvote = p.myVotes > 0; const promotionPct = Math.min(100, (score / 100) * 100); return `
${score}
#${rank + 1} ${esc(p.title)} ${p.myVotes > 0 ? `You: ${p.myVotes} vote${p.myVotes > 1 ? 's' : ''} (${mySpent} credit${mySpent > 1 ? 's' : ''})` : ''}

${esc(p.description)}

${score}/100 ${score >= 100 ? 'PROMOTED' : ''}
${canVote ? `
Next vote costs ${nextCost} credit${nextCost > 1 ? 's' : ''}
` : ''}
`; }).join(""); } // Event delegation for simulator votes document.getElementById("rd-sim-proposals")?.addEventListener("click", (e) => { const btn = (e.target as HTMLElement).closest("button.rd-sim-vote"); if (!btn || btn.disabled) return; const id = btn.dataset.simId!; const dir = btn.dataset.simDir!; const proposal = simProposals.find((p) => p.id === id); if (!proposal) return; if (dir === "up" && canAffordNextVote(proposal)) { proposal.myVotes++; } else if (dir === "down" && proposal.myVotes > 0) { proposal.myVotes--; } renderSimulator(); }); // Reset simulator document.getElementById("rd-sim-reset")?.addEventListener("click", () => { simProposals = getInitialProposals(); simCredits = MAX_CREDITS; renderSimulator(); }); // Initial render renderSimulator();