/** * — conviction voting dashboard. * * Browse spaces, create/view proposals, cast votes (ranking + final). * Enhanced: inline voting, create proposal form, richer UI. */ import { proposalSchema, type ProposalDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; interface VoteSpace { slug: string; name: string; description: string; visibility: string; promotion_threshold: number; voting_period_days: number; credits_per_day: number; max_credits: number; starting_credits: number; } interface Proposal { id: string; title: string; description: string; status: string; score: number; vote_count: string; final_yes: number; final_no: number; final_abstain: number; created_at: string; voting_ends_at: string | null; } /** Score history entry for trend chart */ interface ScoreSnapshot { time: number; // timestamp scores: Record; // proposalId → score } class FolkVoteDashboard extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: "spaces" | "proposals" | "proposal" = "spaces"; private spaces: VoteSpace[] = []; private selectedSpace: VoteSpace | null = null; private proposals: Proposal[] = []; private selectedProposal: Proposal | null = null; private loading = false; private error = ""; private showCreateForm = false; private showTrendChart = true; private scoreHistory: ScoreSnapshot[] = []; private _offlineUnsubs: (() => void)[] = []; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.subscribeOffline(); this.loadSpaces(); } disconnectedCallback() { for (const unsub of this._offlineUnsubs) unsub(); this._offlineUnsubs = []; } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docs = await runtime.subscribeModule('vote', 'proposals', proposalSchema); if (docs.size > 0 && this.proposals.length === 0) { const fromDocs: Proposal[] = []; for (const [docId, doc] of docs) { const d = doc as ProposalDoc; if (!d?.proposal) continue; fromDocs.push({ id: d.proposal.id, title: d.proposal.title, description: d.proposal.description, status: d.proposal.status, score: d.proposal.score, vote_count: String(Object.keys(d.votes || {}).length), final_yes: d.proposal.finalYes, final_no: d.proposal.finalNo, final_abstain: d.proposal.finalAbstain, created_at: new Date(d.proposal.createdAt).toISOString(), voting_ends_at: d.proposal.votingEndsAt ? new Date(d.proposal.votingEndsAt).toISOString() : null, }); this._offlineUnsubs.push(runtime.onChange(docId, () => {})); } if (fromDocs.length > 0) { this.proposals = fromDocs; this.view = "proposals"; this.render(); } } } catch { /* runtime unavailable */ } } private loadDemoData() { this.spaces = [ { slug: "community", name: "Community Governance", description: "Proposals for the rSpace ecosystem", visibility: "public", promotion_threshold: 100, voting_period_days: 7, credits_per_day: 10, max_credits: 500, starting_credits: 50, }, ]; this.selectedSpace = this.spaces[0]; this.view = "proposals"; const now = Date.now(); const day = 86400000; this.proposals = [ { id: "p1", title: "Implement real-time collaboration in rNotes", description: "Use Automerge CRDTs (already in the stack) to enable simultaneous editing of notes, similar to how rSpace canvas works.", status: "RANKING", score: 72, vote_count: "9", final_yes: 0, final_no: 0, final_abstain: 0, created_at: new Date(now - 3 * day).toISOString(), voting_ends_at: null, }, { id: "p2", title: "Add dark mode across all r* modules", description: "Implement a consistent dark theme with a toggle in shell.css. Use CSS custom properties for theming so each module inherits automatically.", status: "RANKING", score: 45, vote_count: "6", final_yes: 0, final_no: 0, final_abstain: 0, created_at: new Date(now - 5 * day).toISOString(), voting_ends_at: null, }, { id: "p3", title: "Adopt cosmolocal print-on-demand for all merch", description: "Route all merchandise orders through the provider registry to find the closest printer. Reduces shipping emissions and supports local economies.", status: "VOTING", score: 105, vote_count: "14", final_yes: 5, final_no: 2, final_abstain: 0, created_at: new Date(now - 10 * day).toISOString(), voting_ends_at: new Date(now + 5 * day).toISOString(), }, { id: "p4", title: "Use EncryptID passkeys for all authentication", description: "Standardize on WebAuthn passkeys via EncryptID across the entire r* ecosystem. One passkey, all apps.", status: "PASSED", score: 150, vote_count: "17", final_yes: 12, final_no: 3, final_abstain: 2, created_at: new Date(now - 21 * day).toISOString(), voting_ends_at: new Date(now - 7 * day).toISOString(), }, { id: "p5", title: "Switch from PostgreSQL to SQLite for simpler deployment", description: "Evaluate replacing PostgreSQL with SQLite for modules that don't need concurrent writes.", status: "FAILED", score: 30, vote_count: "11", final_yes: 2, final_no: 8, final_abstain: 1, created_at: new Date(now - 18 * day).toISOString(), voting_ends_at: new Date(now - 4 * day).toISOString(), }, ]; this.recordSnapshot(); // Seed some historical snapshots for the demo trend chart this.seedDemoHistory(); this.render(); } /** Seed fake historical snapshots so the trend chart isn't empty */ private seedDemoHistory() { const now = Date.now(); const day = 86400000; const rankings = this.proposals.filter(p => p.status === "RANKING"); // Simulate 7 days of score evolution const history: ScoreSnapshot[] = []; for (let d = 6; d >= 0; d--) { const snap: ScoreSnapshot = { time: now - d * day, scores: {} }; for (const p of rankings) { // Work backwards: current score minus some random drift const drift = Math.round((Math.random() - 0.3) * 8 * (d + 1)); snap.scores[p.id] = Math.max(0, p.score - drift); } history.push(snap); } this.scoreHistory = history; } /** Record current scores as a snapshot */ private recordSnapshot() { const snap: ScoreSnapshot = { time: Date.now(), scores: {} }; for (const p of this.proposals) { if (p.status === "RANKING") snap.scores[p.id] = p.score; } this.scoreHistory.push(snap); // Keep last 50 snapshots if (this.scoreHistory.length > 50) this.scoreHistory.shift(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rvote/); return match ? match[0] : ""; } private async loadSpaces() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/spaces`); const data = await res.json(); this.spaces = data.spaces || []; } catch { this.error = "Failed to load spaces"; } this.loading = false; this.render(); } private async loadProposals(slug: string) { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/proposals?space_slug=${slug}`); const data = await res.json(); this.proposals = data.proposals || []; } catch { this.error = "Failed to load proposals"; } this.loading = false; this.render(); } private async loadProposal(id: string) { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/proposals/${id}`); this.selectedProposal = await res.json(); } catch { this.error = "Failed to load proposal"; } this.loading = false; this.render(); } private async castVote(proposalId: string, weight: number) { if (this.space === "demo") { const p = this.proposals.find(p => p.id === proposalId); if (p) { p.score += weight; p.vote_count = String(parseInt(p.vote_count) + 1); const threshold = this.selectedSpace?.promotion_threshold || 100; if (p.score >= threshold && p.status === "RANKING") { p.status = "VOTING"; const votingDays = this.selectedSpace?.voting_period_days || 7; p.voting_ends_at = new Date(Date.now() + votingDays * 86400000).toISOString(); } if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; } this.recordSnapshot(); this.render(); return; } try { const base = this.getApiBase(); await fetch(`${base}/api/proposals/${proposalId}/vote`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ weight }), }); if (this.view === "proposal") await this.loadProposal(proposalId); else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug); } catch { this.error = "Failed to cast vote"; this.render(); } } private async castFinalVote(proposalId: string, vote: string) { if (this.space === "demo") { const p = this.proposals.find(p => p.id === proposalId); if (p) { if (vote === "YES") p.final_yes++; else if (vote === "NO") p.final_no++; else if (vote === "ABSTAIN") p.final_abstain++; if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; } this.render(); return; } try { const base = this.getApiBase(); await fetch(`${base}/api/proposals/${proposalId}/final-vote`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ vote }), }); if (this.view === "proposal") await this.loadProposal(proposalId); else if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug); } catch { this.error = "Failed to cast vote"; this.render(); } } private async createProposal(title: string, description: string) { if (this.space === "demo") { const now = Date.now(); this.proposals.unshift({ id: `p${now}`, title, description, status: "RANKING", score: 0, vote_count: "0", final_yes: 0, final_no: 0, final_abstain: 0, created_at: new Date(now).toISOString(), voting_ends_at: null, }); this.showCreateForm = false; this.render(); return; } try { const base = this.getApiBase(); await fetch(`${base}/api/proposals`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ space_slug: this.selectedSpace?.slug, title, description }), }); this.showCreateForm = false; if (this.selectedSpace) await this.loadProposals(this.selectedSpace.slug); } catch { this.error = "Failed to create proposal"; this.render(); } } private getStatusColor(status: string): string { switch (status) { case "RANKING": return "#3b82f6"; case "VOTING": return "#f59e0b"; case "PASSED": return "#22c55e"; case "FAILED": return "#ef4444"; case "ARCHIVED": return "#6b7280"; default: return "#888"; } } private getStatusIcon(status: string): string { switch (status) { case "RANKING": return "📊"; case "VOTING": return "🗳"; case "PASSED": return "✅"; case "FAILED": return "❌"; case "ARCHIVED": return "📦"; default: return "📄"; } } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading...
' : ""} ${!this.loading ? this.renderView() : ""} `; this.attachListeners(); } private renderView(): string { if (this.view === "proposal" && this.selectedProposal) { return this.renderProposal(); } if (this.view === "proposals" && this.selectedSpace) { return this.renderProposals(); } return this.renderSpaces(); } private renderSpaces(): string { return `
Voting Spaces
${this.spaces.length === 0 ? '
🗳
No voting spaces yet. Create one to get started.
' : ""} ${this.spaces.map((s) => `
${this.esc(s.name)}
${s.visibility === "public" ? "Public" : s.visibility === "permissioned" ? "Permissioned" : "Private"}
${this.esc(s.description || "")}
${s.promotion_threshold}Threshold
${s.voting_period_days}dVote Period
${s.credits_per_day}/dayCredits
${s.starting_credits}Starting
${s.max_credits}Max
`).join("")} `; } private relativeTime(dateStr: string): string { const daysAgo = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000); if (daysAgo === 0) return "today"; if (daysAgo === 1) return "yesterday"; return `${daysAgo}d ago`; } private daysLeft(dateStr: string | null): string { if (!dateStr) return ""; const diff = new Date(dateStr).getTime() - Date.now(); if (diff <= 0) return "ended"; const days = Math.ceil(diff / 86400000); return `${days}d left`; } private renderProposals(): string { const s = this.selectedSpace!; const threshold = s.promotion_threshold || 100; // Group proposals by status const ranking = this.proposals.filter(p => p.status === "RANKING"); const voting = this.proposals.filter(p => p.status === "VOTING"); const decided = this.proposals.filter(p => p.status === "PASSED" || p.status === "FAILED"); return `
${this.spaces.length > 1 ? `` : ""} ${this.esc(s.name)} ${this.proposals.length} proposal${this.proposals.length !== 1 ? "s" : ""}
${this.space === "demo" ? `
Demo mode — votes modify local state only. Create a space for real governance.
` : ""} ${this.showCreateForm ? this.renderCreateForm() : ""} ${this.renderTrendChart()} ${voting.length > 0 ? `
🗳 Final Voting (${voting.length})
${voting.map(p => this.renderProposalCard(p, threshold)).join("")} ` : ""} ${ranking.length > 0 ? `
📊 Ranking (${ranking.length})
${ranking.map(p => this.renderProposalCard(p, threshold)).join("")} ` : ""} ${decided.length > 0 ? `
📋 Decided (${decided.length})
${decided.map(p => this.renderProposalCard(p, threshold)).join("")} ` : ""} ${this.proposals.length === 0 && !this.showCreateForm ? '
📝
No proposals yet. Create one to get started.
' : ""} `; } private renderProposalCard(p: Proposal, threshold: number): string { const statusColor = this.getStatusColor(p.status); const pct = Math.min(100, (p.score / threshold) * 100); const totalFinal = p.final_yes + p.final_no + p.final_abstain; const scoreClass = p.score > 0 ? 'positive' : p.score < 0 ? 'negative' : ''; // SVG chevrons for Reddit-style voting const upChevron = ``; const downChevron = ``; return `
${p.status === "RANKING" ? `
${Math.round(p.score)}
x² cost
` : ""}
${this.esc(p.title)} ${this.getStatusIcon(p.status)} ${p.status}
${this.esc(p.description || "")}
${p.status === "RANKING" ? `
${Math.round(p.score)} / ${threshold}
${[2, 3, 5].map(w => ``).join("")}
` : ""} ${p.status === "VOTING" ? `
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${totalFinal > 0 ? `
` : ""}
` : ""} ${p.status === "PASSED" || p.status === "FAILED" ? `
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${totalFinal > 0 ? `
` : ""} ` : ""}
${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""} ${this.relativeTime(p.created_at)} ${p.status === "VOTING" && p.voting_ends_at ? `${this.daysLeft(p.voting_ends_at)}` : ""} ${p.status === "RANKING" ? `${Math.round(threshold - p.score)} to advance` : ""}
`; } /** Render an SVG line chart showing score trends over time */ private renderTrendChart(): string { const ranking = this.proposals.filter(p => p.status === "RANKING"); if (ranking.length === 0 || this.scoreHistory.length < 2) return ""; const COLORS = ["#f97316", "#3b82f6", "#a855f7", "#22c55e", "#f43f5e", "#06b6d4", "#eab308"]; const W = 500, H = 160, PAD_L = 36, PAD_R = 12, PAD_T = 8, PAD_B = 24; const plotW = W - PAD_L - PAD_R; const plotH = H - PAD_T - PAD_B; // Get all scores across history for scale let maxScore = 10; for (const snap of this.scoreHistory) { for (const s of Object.values(snap.scores)) { if (s > maxScore) maxScore = s; } } maxScore = Math.ceil(maxScore / 10) * 10; // round up to nearest 10 const xScale = (i: number) => PAD_L + (i / (this.scoreHistory.length - 1)) * plotW; const yScale = (v: number) => PAD_T + plotH - (v / maxScore) * plotH; // Grid lines const gridLines: string[] = []; const gridSteps = 4; for (let i = 0; i <= gridSteps; i++) { const val = Math.round((maxScore / gridSteps) * i); const y = yScale(val); gridLines.push(``); gridLines.push(`${val}`); } // Time labels const timeLabels: string[] = []; const firstTime = this.scoreHistory[0].time; const lastTime = this.scoreHistory[this.scoreHistory.length - 1].time; const dayDiff = Math.max(1, Math.round((lastTime - firstTime) / 86400000)); const labelStep = Math.max(1, Math.floor(this.scoreHistory.length / 5)); for (let i = 0; i < this.scoreHistory.length; i += labelStep) { const d = new Date(this.scoreHistory[i].time); const label = dayDiff > 1 ? `${d.getMonth() + 1}/${d.getDate()}` : `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`; timeLabels.push(`${label}`); } // Lines for each proposal const lines: string[] = []; const legend: string[] = []; ranking.forEach((p, pIdx) => { const color = COLORS[pIdx % COLORS.length]; const points: string[] = []; for (let i = 0; i < this.scoreHistory.length; i++) { const score = this.scoreHistory[i].scores[p.id] ?? 0; points.push(`${xScale(i)},${yScale(score)}`); } lines.push(``); // End dot const lastScore = this.scoreHistory[this.scoreHistory.length - 1].scores[p.id] ?? 0; const lastX = xScale(this.scoreHistory.length - 1); const lastY = yScale(lastScore); lines.push(``); // Legend const shortTitle = p.title.length > 30 ? p.title.slice(0, 28) + '...' : p.title; legend.push(`${this.esc(shortTitle)}`); }); return `
Priority Trends
${this.showTrendChart ? `
${gridLines.join("")} ${timeLabels.join("")} ${lines.join("")}
${legend.join("")}
` : ""}
`; } private renderCreateForm(): string { return `

New Proposal

`; } private renderProposal(): string { const p = this.selectedProposal!; const threshold = this.selectedSpace?.promotion_threshold || 100; const pct = Math.min(100, (p.score / threshold) * 100); const totalFinal = p.final_yes + p.final_no + p.final_abstain; return `
${this.esc(p.title)} ${this.getStatusIcon(p.status)} ${p.status}
${this.esc(p.description || "No description provided.")}
${p.vote_count} votes Created ${this.relativeTime(p.created_at)} ${p.voting_ends_at ? `Voting ${this.daysLeft(p.voting_ends_at)}` : ""}
${p.status === "RANKING" ? `
Conviction Score
${Math.round(p.score)} / ${threshold}
Needs ${Math.max(0, Math.round(threshold - p.score))} more to advance to final vote. Cost: weight² credits.
${[1, 2, 3, 5].map(w => ``).join("")} | ${[-1, -2].map(w => ``).join("")}
` : ""} ${p.status === "VOTING" ? `
Final Vote — ${this.selectedSpace?.voting_period_days || 7}-day period
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${totalFinal > 0 ? `
` : ""}
` : ""} ${p.status === "PASSED" || p.status === "FAILED" ? `
Result: ${p.status}
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${totalFinal > 0 ? `
` : ""}
Conviction score was ${Math.round(p.score)}
` : ""}
`; } private attachListeners() { // Space cards this.shadow.querySelectorAll("[data-space]").forEach((el) => { el.addEventListener("click", () => { const slug = (el as HTMLElement).dataset.space!; this.selectedSpace = this.spaces.find((s) => s.slug === slug) || null; this.view = "proposals"; if (this.space === "demo") this.render(); else this.loadProposals(slug); }); }); // Proposal title click → detail view this.shadow.querySelectorAll("[data-proposal]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const id = (el as HTMLElement).dataset.proposal!; this.view = "proposal"; if (this.space === "demo") { this.selectedProposal = this.proposals.find((p) => p.id === id) || null; this.render(); } else { this.loadProposal(id); } }); }); // Back buttons this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; if (target === "spaces") { this.view = "spaces"; this.render(); } else if (target === "proposals") { this.view = "proposals"; this.render(); } }); }); // Inline conviction vote buttons (on proposal cards) this.shadow.querySelectorAll("[data-vote-weight]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const weight = parseInt((el as HTMLElement).dataset.voteWeight!); const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id; if (id) this.castVote(id, weight); }); }); // Final vote buttons this.shadow.querySelectorAll("[data-final-vote]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const vote = (el as HTMLElement).dataset.finalVote!; const id = (el as HTMLElement).dataset.voteId || this.selectedProposal?.id; if (id) this.castFinalVote(id, vote); }); }); // Toggle trend chart this.shadow.querySelector("[data-toggle-trend]")?.addEventListener("click", () => { this.showTrendChart = !this.showTrendChart; this.render(); }); // Toggle create form this.shadow.querySelector("[data-toggle-create]")?.addEventListener("click", () => { this.showCreateForm = !this.showCreateForm; this.render(); }); // Cancel create this.shadow.querySelector("[data-cancel-create]")?.addEventListener("click", () => { this.showCreateForm = false; this.render(); }); // Submit create this.shadow.querySelector("[data-submit-create]")?.addEventListener("click", () => { const titleInput = this.shadow.getElementById("create-title") as HTMLInputElement; const descInput = this.shadow.getElementById("create-desc") as HTMLTextAreaElement; const title = titleInput?.value?.trim(); const desc = descInput?.value?.trim(); if (!title) return; this.createProposal(title, desc || ""); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-vote-dashboard", FolkVoteDashboard);