From 62a96c164aa93b785875272a888c8e222f678c9a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 23:52:47 -0700 Subject: [PATCH] feat(rvote): Reddit-style vote column + priority trend chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reddit-style vote column: prominent up/down chevrons flanking the score on each ranking proposal card, with x² cost indicator - Quadratic weight picker: compact inline buttons for +2/+3/+5 and -2 below the proposal description (supplements chevron ±1) - Priority Trends chart: SVG line chart showing how proposal scores evolve over time, with color-coded lines per proposal, end dots, grid lines, time labels, and a toggleable legend - Score history tracking: records snapshots on each vote, seeds 7 days of simulated history for demo mode - Orange for upvotes, blue for downvotes (matching rvote.online palette) Co-Authored-By: Claude Opus 4.6 --- .../rvote/components/folk-vote-dashboard.ts | 324 +++++++++++++++--- 1 file changed, 272 insertions(+), 52 deletions(-) diff --git a/modules/rvote/components/folk-vote-dashboard.ts b/modules/rvote/components/folk-vote-dashboard.ts index e4b48cd..ce15434 100644 --- a/modules/rvote/components/folk-vote-dashboard.ts +++ b/modules/rvote/components/folk-vote-dashboard.ts @@ -34,6 +34,12 @@ interface Proposal { 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 = ""; @@ -45,6 +51,8 @@ class FolkVoteDashboard extends HTMLElement { private loading = false; private error = ""; private showCreateForm = false; + private showTrendChart = true; + private scoreHistory: ScoreSnapshot[] = []; private _offlineUnsubs: (() => void)[] = []; constructor() { @@ -171,9 +179,42 @@ class FolkVoteDashboard extends HTMLElement { 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/); @@ -238,6 +279,7 @@ class FolkVoteDashboard extends HTMLElement { } if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; } + this.recordSnapshot(); this.render(); return; } @@ -363,16 +405,48 @@ class FolkVoteDashboard extends HTMLElement { .space-stat-value { font-size: 15px; font-weight: 600; color: #818cf8; } .space-stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; } - /* Proposal cards */ + /* Proposal cards — Reddit layout */ .proposal { background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; - padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.2s; + margin-bottom: 10px; transition: border-color 0.2s; + display: flex; overflow: hidden; } .proposal:hover { border-color: #334155; } - .proposal-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 6px; } + + /* Reddit-style vote column */ + .vote-col { + display: flex; flex-direction: column; align-items: center; + padding: 12px 6px; gap: 2px; flex-shrink: 0; width: 56px; + background: rgba(30,41,59,0.5); border-right: 1px solid #1e293b; + } + .vote-chevron { + display: flex; align-items: center; justify-content: center; + width: 32px; height: 28px; border: none; border-radius: 6px; + background: transparent; cursor: pointer; color: #475569; + transition: all 0.15s; padding: 0; + } + .vote-chevron:hover { background: rgba(129,140,248,0.1); } + .vote-chevron.up:hover { color: #f97316; } + .vote-chevron.down:hover { color: #3b82f6; } + .vote-chevron:active { transform: scale(0.9); } + .vote-score { + font-size: 16px; font-weight: 800; color: #e2e8f0; + font-variant-numeric: tabular-nums; line-height: 1; + padding: 2px 0; + } + .vote-score.positive { color: #f97316; } + .vote-score.negative { color: #3b82f6; } + .qv-cost { + font-size: 9px; color: #475569; text-align: center; + white-space: nowrap; margin-top: 2px; + } + + /* Right content area */ + .proposal-body { flex: 1; padding: 14px 16px; min-width: 0; } + .proposal-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 4px; } .proposal-title { font-size: 15px; font-weight: 600; color: #e2e8f0; cursor: pointer; line-height: 1.4; } .proposal-title:hover { color: #818cf8; } - .proposal-desc { font-size: 13px; color: #64748b; margin-bottom: 10px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .proposal-desc { font-size: 13px; color: #64748b; margin-bottom: 8px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } /* Status badge */ .badge { @@ -471,6 +545,45 @@ class FolkVoteDashboard extends HTMLElement { font-size: 13px; color: #94a3b8; } .demo-banner strong { color: #818cf8; } + + /* Quadratic weight picker (compact inline) */ + .qv-weights { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } + .qv-weight { + padding: 3px 8px; border-radius: 6px; border: 1px solid #334155; + background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 11px; + font-weight: 600; transition: all 0.12s; white-space: nowrap; + } + .qv-weight:hover { border-color: #f97316; color: #fb923c; background: rgba(249,115,22,0.08); } + .qv-weight:active { transform: scale(0.95); } + .qv-weight.down { border-color: rgba(59,130,246,0.3); } + .qv-weight.down:hover { border-color: #3b82f6; color: #60a5fa; background: rgba(59,130,246,0.08); } + + /* Trend chart */ + .trend-section { + background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; + padding: 16px 20px; margin-bottom: 16px; + } + .trend-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; + } + .trend-title { font-size: 13px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.04em; } + .trend-toggle { + font-size: 11px; color: #64748b; cursor: pointer; background: none; + border: 1px solid #334155; border-radius: 6px; padding: 3px 8px; + } + .trend-toggle:hover { border-color: #818cf8; color: #818cf8; } + .trend-chart { position: relative; } + .trend-chart svg { display: block; width: 100%; } + .trend-legend { + display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; + } + .trend-legend-item { + display: flex; align-items: center; gap: 5px; font-size: 11px; color: #94a3b8; + } + .trend-legend-dot { + width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; + } ${this.error ? `
${this.esc(this.error)}
` : ""} @@ -559,6 +672,8 @@ class FolkVoteDashboard extends HTMLElement { ${this.showCreateForm ? this.renderCreateForm() : ""} + ${this.renderTrendChart()} + ${voting.length > 0 ? `
🗳 Final Voting (${voting.length}) @@ -588,68 +703,167 @@ class FolkVoteDashboard extends HTMLElement { 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 `
-
- ${this.esc(p.title)} - ${this.getStatusIcon(p.status)} ${p.status} -
-
${this.esc(p.description || "")}
- ${p.status === "RANKING" ? ` -
- Score: ${Math.round(p.score)} / ${threshold} -
-
-
- ${[1, 2, 3, 5].map(w => ``).join("")} - | - ${[-1, -2].map(w => ``).join("")} +
+ + ${Math.round(p.score)} + +
x² cost
` : ""} - - ${p.status === "VOTING" ? ` -
-
${p.final_yes}
Yes
-
${p.final_no}
No
-
${p.final_abstain}
Abstain
+
+
+ ${this.esc(p.title)} + ${this.getStatusIcon(p.status)} ${p.status}
- ${totalFinal > 0 ? `
-
-
-
-
` : ""} -
- - - -
- ` : ""} +
${this.esc(p.description || "")}
- ${p.status === "PASSED" || p.status === "FAILED" ? ` -
-
${p.final_yes}
Yes
-
${p.final_no}
No
-
${p.final_abstain}
Abstain
-
- ${totalFinal > 0 ? `
-
-
-
-
` : ""} - ` : ""} + ${p.status === "RANKING" ? ` +
+ ${Math.round(p.score)} / ${threshold} +
+
+
+ ${[2, 3, 5].map(w => ``).join("")} + +
+ ` : ""} -
- ${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` : ""} + ${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 `
@@ -808,6 +1022,12 @@ class FolkVoteDashboard extends HTMLElement { }); }); + // 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;