feat(rvote): Reddit-style vote column + priority trend chart

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 23:52:47 -07:00
parent 35dd1c3d77
commit 62a96c164a
1 changed files with 272 additions and 52 deletions

View File

@ -34,6 +34,12 @@ interface Proposal {
voting_ends_at: string | null; voting_ends_at: string | null;
} }
/** Score history entry for trend chart */
interface ScoreSnapshot {
time: number; // timestamp
scores: Record<string, number>; // proposalId → score
}
class FolkVoteDashboard extends HTMLElement { class FolkVoteDashboard extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private space = ""; private space = "";
@ -45,6 +51,8 @@ class FolkVoteDashboard extends HTMLElement {
private loading = false; private loading = false;
private error = ""; private error = "";
private showCreateForm = false; private showCreateForm = false;
private showTrendChart = true;
private scoreHistory: ScoreSnapshot[] = [];
private _offlineUnsubs: (() => void)[] = []; private _offlineUnsubs: (() => void)[] = [];
constructor() { constructor() {
@ -171,9 +179,42 @@ class FolkVoteDashboard extends HTMLElement {
voting_ends_at: new Date(now - 4 * 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(); 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 { private getApiBase(): string {
const path = window.location.pathname; const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rvote/); const match = path.match(/^(\/[^/]+)?\/rvote/);
@ -238,6 +279,7 @@ class FolkVoteDashboard extends HTMLElement {
} }
if (this.selectedProposal?.id === proposalId) this.selectedProposal = p; if (this.selectedProposal?.id === proposalId) this.selectedProposal = p;
} }
this.recordSnapshot();
this.render(); this.render();
return; return;
} }
@ -363,16 +405,48 @@ class FolkVoteDashboard extends HTMLElement {
.space-stat-value { font-size: 15px; font-weight: 600; color: #818cf8; } .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; } .space-stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
/* Proposal cards */ /* Proposal cards — Reddit layout */
.proposal { .proposal {
background: #0f172a; border: 1px solid #1e293b; border-radius: 12px; 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: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 { font-size: 15px; font-weight: 600; color: #e2e8f0; cursor: pointer; line-height: 1.4; }
.proposal-title:hover { color: #818cf8; } .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 */ /* Status badge */
.badge { .badge {
@ -471,6 +545,45 @@ class FolkVoteDashboard extends HTMLElement {
font-size: 13px; color: #94a3b8; font-size: 13px; color: #94a3b8;
} }
.demo-banner strong { color: #818cf8; } .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;
}
</style> </style>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""} ${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
@ -559,6 +672,8 @@ class FolkVoteDashboard extends HTMLElement {
${this.showCreateForm ? this.renderCreateForm() : ""} ${this.showCreateForm ? this.renderCreateForm() : ""}
${this.renderTrendChart()}
${voting.length > 0 ? ` ${voting.length > 0 ? `
<div style="font-size:12px;font-weight:600;color:#f59e0b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px"> <div style="font-size:12px;font-weight:600;color:#f59e0b;text-transform:uppercase;letter-spacing:0.05em;margin:16px 0 8px;display:flex;align-items:center;gap:6px">
<span>🗳</span> Final Voting (${voting.length}) <span>🗳</span> Final Voting (${voting.length})
@ -588,68 +703,167 @@ class FolkVoteDashboard extends HTMLElement {
const statusColor = this.getStatusColor(p.status); const statusColor = this.getStatusColor(p.status);
const pct = Math.min(100, (p.score / threshold) * 100); const pct = Math.min(100, (p.score / threshold) * 100);
const totalFinal = p.final_yes + p.final_no + p.final_abstain; 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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`;
const downChevron = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
return ` return `
<div class="proposal" data-pid="${p.id}"> <div class="proposal" data-pid="${p.id}">
<div class="proposal-top">
<span class="proposal-title" data-proposal="${p.id}">${this.esc(p.title)}</span>
<span class="badge" style="background:${statusColor}18;color:${statusColor}">${this.getStatusIcon(p.status)} ${p.status}</span>
</div>
<div class="proposal-desc">${this.esc(p.description || "")}</div>
${p.status === "RANKING" ? ` ${p.status === "RANKING" ? `
<div class="score-row"> <div class="vote-col">
<span class="score-label">Score: <span class="score-value" style="color:#3b82f6">${Math.round(p.score)}</span> / ${threshold}</span> <button class="vote-chevron up" data-vote-weight="1" data-vote-id="${p.id}" title="Upvote (+1 credit)">${upChevron}</button>
<div class="score-bar"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#3b82f6,#818cf8)"></div></div> <span class="vote-score ${scoreClass}">${Math.round(p.score)}</span>
</div> <button class="vote-chevron down" data-vote-weight="-1" data-vote-id="${p.id}" title="Downvote (-1 credit)">${downChevron}</button>
<div class="vote-row"> <div class="qv-cost">x&sup2; cost</div>
${[1, 2, 3, 5].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}">+${w} <span style="font-size:11px;color:#64748b">(${w * w}cr)</span></button>`).join("")}
<span class="vote-sep">|</span>
${[-1, -2].map(w => `<button class="vote-btn" data-vote-weight="${w}" data-vote-id="${p.id}" style="border-color:rgba(239,68,68,0.3);color:#ef4444">${w}</button>`).join("")}
</div> </div>
` : ""} ` : ""}
<div class="proposal-body">
${p.status === "VOTING" ? ` <div class="proposal-top">
<div class="tally"> <span class="proposal-title" data-proposal="${p.id}">${this.esc(p.title)}</span>
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div> <span class="badge" style="background:${statusColor}18;color:${statusColor}">${this.getStatusIcon(p.status)} ${p.status}</span>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div> </div>
${totalFinal > 0 ? `<div class="tally-bar"> <div class="proposal-desc">${this.esc(p.description || "")}</div>
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
<div class="vote-row" style="margin-top:10px">
<button class="vote-btn yes" data-final-vote="YES" data-vote-id="${p.id}">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO" data-vote-id="${p.id}">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN" data-vote-id="${p.id}">Abstain</button>
</div>
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? ` ${p.status === "RANKING" ? `
<div class="tally"> <div class="score-row">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div> <span class="score-label"><span class="score-value" style="color:#3b82f6">${Math.round(p.score)}</span> / ${threshold}</span>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div> <div class="score-bar"><div class="score-fill" style="width:${pct}%;background:linear-gradient(90deg,#f97316,#818cf8)"></div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div> </div>
</div> <div class="qv-weights">
${totalFinal > 0 ? `<div class="tally-bar"> ${[2, 3, 5].map(w => `<button class="qv-weight" data-vote-weight="${w}" data-vote-id="${p.id}">+${w} (${w*w}cr)</button>`).join("")}
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div> <button class="qv-weight down" data-vote-weight="-2" data-vote-id="${p.id}">-2</button>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div> </div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div> ` : ""}
</div>` : ""}
` : ""}
<div class="meta"> ${p.status === "VOTING" ? `
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span> <div class="tally">
<span>${this.relativeTime(p.created_at)}</span> <div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""} <div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""} <div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
<div class="vote-row" style="margin-top:10px">
<button class="vote-btn yes" data-final-vote="YES" data-vote-id="${p.id}">Vote Yes</button>
<button class="vote-btn no" data-final-vote="NO" data-vote-id="${p.id}">Vote No</button>
<button class="vote-btn abstain" data-final-vote="ABSTAIN" data-vote-id="${p.id}">Abstain</button>
</div>
` : ""}
${p.status === "PASSED" || p.status === "FAILED" ? `
<div class="tally">
<div class="tally-item"><div class="tally-value" style="color:#22c55e">${p.final_yes}</div><div class="tally-label">Yes</div></div>
<div class="tally-item"><div class="tally-value" style="color:#ef4444">${p.final_no}</div><div class="tally-label">No</div></div>
<div class="tally-item"><div class="tally-value" style="color:#f59e0b">${p.final_abstain}</div><div class="tally-label">Abstain</div></div>
</div>
${totalFinal > 0 ? `<div class="tally-bar">
<div class="tally-bar-yes" style="width:${(p.final_yes/totalFinal)*100}%"></div>
<div class="tally-bar-no" style="width:${(p.final_no/totalFinal)*100}%"></div>
<div class="tally-bar-abstain" style="width:${(p.final_abstain/totalFinal)*100}%"></div>
</div>` : ""}
` : ""}
<div class="meta">
<span>${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
<span>${this.relativeTime(p.created_at)}</span>
${p.status === "VOTING" && p.voting_ends_at ? `<span style="color:#f59e0b">${this.daysLeft(p.voting_ends_at)}</span>` : ""}
${p.status === "RANKING" ? `<span style="color:#3b82f6">${Math.round(threshold - p.score)} to advance</span>` : ""}
</div>
</div> </div>
</div> </div>
`; `;
} }
/** 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(`<line x1="${PAD_L}" y1="${y}" x2="${W - PAD_R}" y2="${y}" stroke="#1e293b" stroke-width="1"/>`);
gridLines.push(`<text x="${PAD_L - 6}" y="${y + 4}" text-anchor="end" fill="#475569" font-size="10">${val}</text>`);
}
// 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(`<text x="${xScale(i)}" y="${H - 4}" text-anchor="middle" fill="#475569" font-size="10">${label}</text>`);
}
// 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(`<polyline points="${points.join(" ")}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>`);
// 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(`<circle cx="${lastX}" cy="${lastY}" r="4" fill="${color}" stroke="#0f172a" stroke-width="2"/>`);
// Legend
const shortTitle = p.title.length > 30 ? p.title.slice(0, 28) + '...' : p.title;
legend.push(`<span class="trend-legend-item"><span class="trend-legend-dot" style="background:${color}"></span>${this.esc(shortTitle)}</span>`);
});
return `
<div class="trend-section">
<div class="trend-header">
<span class="trend-title">Priority Trends</span>
<button class="trend-toggle" data-toggle-trend>${this.showTrendChart ? 'Hide' : 'Show'}</button>
</div>
${this.showTrendChart ? `
<div class="trend-chart">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
${gridLines.join("")}
${timeLabels.join("")}
${lines.join("")}
</svg>
</div>
<div class="trend-legend">${legend.join("")}</div>
` : ""}
</div>
`;
}
private renderCreateForm(): string { private renderCreateForm(): string {
return ` return `
<div class="create-form"> <div class="create-form">
@ -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 // Toggle create form
this.shadow.querySelector("[data-toggle-create]")?.addEventListener("click", () => { this.shadow.querySelector("[data-toggle-create]")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm; this.showCreateForm = !this.showCreateForm;