From 5ddaad3da3d402cbdb8a435c350680f0216d0d76 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 11:20:16 +0200 Subject: [PATCH] feat(vote): show percentages after voting + allow change-of-mind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pulse vote widget on /pulse and on /dispatches/[slug] now behaves the way the v5 spec asks: - Eyebrow shortens: 'This week's pulse' → 'This week's'. - Status line copy changes shape depending on whether the viewer has voted yet. Pre-vote: 'Vote to see how the council weighs in · Closes TUESDAY' — sets expectations that the percentages reveal after a vote. Post-vote: '2 of 7 voted · Closes TUESDAY · Click to change' — tells the viewer they can change their mind. - Each option now renders a right-aligned tabular-nums percentage badge, but only after the viewer has voted. Pre-vote there's no percentage on screen at all — voting is a commitment, not a peek. - Options stay clickable after voting (no `disabled`). Re-clicking a different option changes the vote. DB: new helper castOrChangeVote(pulseId, userId, optionIndex) does an UPSERT — INSERT on first vote, UPDATE option_index + voted_at on subsequent. Returns true if this was the brand-new vote, so the caller can write the 'voted' activity row exactly once and not double-count changes-of-mind in the feed. castVote(...) (INSERT OR IGNORE) stays in db.ts for callers that explicitly want first-vote-wins semantics. Status-class rename: .locked → .closed on both pulse-option and inline-poll-option. The class now reflects what it actually represents (the pulse is closed) rather than the false invariant 'the user has voted and can't change'. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/db.ts | 15 +++++++++++ src/pages/dispatches/[slug].astro | 35 +++++++++++++++++-------- src/pages/pulse.astro | 43 +++++++++++++++++++++---------- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index b9b9a71..0ef8e12 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -634,6 +634,21 @@ export function castVote(pulseId: number, userId: number, optionIndex: number): ).run(pulseId, userId, optionIndex); } +/** UPSERT — first vote inserts, subsequent ones update option_index + voted_at. + * Use this when the UI allows members to change their pick while the pulse is + * still open. Returns true if this was a brand-new vote (so callers can + * record activity once), false if it changed an existing vote. */ +export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean { + const existing = getUserVote(pulseId, userId); + db.prepare(` + INSERT INTO votes (pulse_id, user_id, option_index, voted_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(pulse_id, user_id) DO UPDATE + SET option_index = excluded.option_index, voted_at = excluded.voted_at + `).run(pulseId, userId, optionIndex); + return existing === null; +} + export function getUserVote(pulseId: number, userId: number): number | null { const r = db.prepare( 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' diff --git a/src/pages/dispatches/[slug].astro b/src/pages/dispatches/[slug].astro index 41a3abb..c6c6675 100644 --- a/src/pages/dispatches/[slug].astro +++ b/src/pages/dispatches/[slug].astro @@ -3,7 +3,7 @@ import AppLayout from '../../layouts/AppLayout.astro'; import Avatar from '../../components/Avatar.astro'; import { getDispatchWithPoll, getAdjacentDispatches, - getPulseById, getUserVote, castVote, recordActivity, countCabMembers, + getPulseById, castOrChangeVote, recordActivity, countCabMembers, } from '../../lib/db'; import { parseDispatchSlug, dispatchSlug, dispatchKindLabel, @@ -26,10 +26,9 @@ if (Astro.request.method === 'POST') { const optionIndex = Number(data.get('option_index')); const target = getPulseById(pulseId); if (target && target.status === 'open' && Number.isInteger(optionIndex) - && optionIndex >= 0 && optionIndex < target.options.length - && getUserVote(pulseId, user.id) === null) { - castVote(pulseId, user.id, optionIndex); - recordActivity(user.id, 'voted', 'pulse', pulseId); + && optionIndex >= 0 && optionIndex < target.options.length) { + const wasNew = castOrChangeVote(pulseId, user.id, optionIndex); + if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId); } return Astro.redirect(Astro.url.pathname); } @@ -97,23 +96,27 @@ const bodyHtml = renderMd(d.body); {d.poll.options.map((opt, i) => { + const hasVoted = d.poll!.my_vote !== null; const chosen = d.poll!.my_vote === i; + const closed = d.poll!.status !== 'open'; const count = d.poll!.votes_by_option[i] ?? 0; const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 0; - const locked = d.poll!.my_vote !== null || d.poll!.status !== 'open'; const letter = String.fromCharCode(65 + i); return ( ); })} @@ -577,12 +582,12 @@ const members = getAllCabMembers(); transition: border-color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); } - .pulse-option:hover:not(.locked) { border-left-color: rgba(0, 0, 0, 0.25); } + .pulse-option:hover:not(.closed) { border-left-color: rgba(0, 0, 0, 0.25); } .pulse-option.chosen { border-left-color: var(--pigment-terracotta); background: rgba(185, 107, 88, 0.05); } - .pulse-option.locked:not(.chosen) { + .pulse-option.closed:not(.chosen) { cursor: default; color: var(--on-surface-variant); } @@ -599,6 +604,18 @@ const members = getAllCabMembers(); } .pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); } .pulse-option-text { flex: 1; } + /* Percentage badge — only rendered once the viewer has voted. Pulled to + the right via margin-left:auto so option-text can still flex. */ + .pulse-option-pct { + margin-left: auto; + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + letter-spacing: var(--tracking-wider); + color: var(--on-surface-variant); + font-variant-numeric: tabular-nums; + } + .pulse-option.chosen .pulse-option-pct { color: var(--pigment-terracotta); } /* ── Council — section framing matches the roadmap carousel ──── */ .council-section {