/** * — conviction voting dashboard. * * Browse spaces, create/view proposals, cast votes (ranking + final). */ 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; } 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 = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadSpaces(); } private loadDemoData() { this.spaces = [ { slug: "community", name: "Community Governance", description: "Proposals for the rSpace ecosystem", visibility: "public_read", 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.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/vote/); return match ? `/${match[1]}/vote` : ""; } 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); // Auto-promote if score reaches threshold (matches server-side behavior) 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(); } this.selectedProposal = p; } 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 }), }); await this.loadProposal(proposalId); } 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++; 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 }), }); await this.loadProposal(proposalId); } catch { this.error = "Failed to cast vote"; 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 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_read" ? "Public" : s.visibility}
${this.esc(s.description || "")}
Threshold: ${s.promotion_threshold} Voting: ${s.voting_period_days}d ${s.credits_per_day} credits/day Max: ${s.max_credits} Start: ${s.starting_credits}
`).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 "1 day ago"; return `${daysAgo} days ago`; } private renderProposals(): string { const s = this.selectedSpace!; return `
${this.esc(s.name)} — Proposals
${this.proposals.length === 0 ? '
No proposals yet.
' : ""} ${this.proposals.map((p) => `
${this.esc(p.title)}
${p.status}
${this.esc(p.description || "")}
${p.status === "RANKING" ? `
Score: ${Math.round(p.score)} / ${s.promotion_threshold} ${p.vote_count} votes ${this.relativeTime(p.created_at)}
` : ""} ${p.status === "VOTING" ? `
Yes: ${p.final_yes} No: ${p.final_no} Abstain: ${p.final_abstain} ${p.voting_ends_at ? `Ends: ${new Date(p.voting_ends_at).toLocaleDateString()}` : ""}
` : ""} ${p.status === "PASSED" || p.status === "FAILED" ? `
Yes: ${p.final_yes} No: ${p.final_no} Abstain: ${p.final_abstain} ${this.relativeTime(p.created_at)}
` : ""}
`).join("")} `; } private renderProposal(): string { const p = this.selectedProposal!; const threshold = this.selectedSpace?.promotion_threshold || 100; const created = new Date(p.created_at); const daysAgo = Math.floor((Date.now() - created.getTime()) / 86400000); const ageText = daysAgo === 0 ? "today" : daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`; return `
${this.esc(p.title)} ${p.status}
${this.esc(p.description || "No description")}
${p.vote_count} votes Created ${ageText}
${p.status === "RANKING" ? `
Conviction score: ${Math.round(p.score)} / ${threshold}
Needs ${threshold} to advance to voting (quadratic cost: weight² credits)
${[1, 2, 3, 5].map((w) => ` `).join("")}
` : ""} ${p.status === "VOTING" ? `
Promoted with conviction score ${Math.round(p.score)} — now in ${this.selectedSpace?.voting_period_days || 7}-day final vote
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
${p.voting_ends_at ? `
Voting ends: ${new Date(p.voting_ends_at).toLocaleDateString()}
` : ""} ` : ""} ${p.status === "PASSED" || p.status === "FAILED" ? `
Final result: ${p.status} (conviction score was ${Math.round(p.score)})
${p.final_yes}
Yes
${p.final_no}
No
${p.final_abstain}
Abstain
` : ""}
`; } 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 cards this.shadow.querySelectorAll("[data-proposal]").forEach((el) => { el.addEventListener("click", () => { 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(); } }); }); // Conviction vote buttons this.shadow.querySelectorAll("[data-vote-weight]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const weight = parseInt((el as HTMLElement).dataset.voteWeight!); if (this.selectedProposal) this.castVote(this.selectedProposal.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!; if (this.selectedProposal) this.castFinalVote(this.selectedProposal.id, vote); }); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-vote-dashboard", FolkVoteDashboard);