/** * 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();