feat(vote): show percentages after voting + allow change-of-mind

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:20:16 +02:00
parent 89688d605d
commit 5ddaad3da3
3 changed files with 69 additions and 24 deletions

View file

@ -634,6 +634,21 @@ export function castVote(pulseId: number, userId: number, optionIndex: number):
).run(pulseId, userId, optionIndex); ).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 { export function getUserVote(pulseId: number, userId: number): number | null {
const r = db.prepare( const r = db.prepare(
'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?'

View file

@ -3,7 +3,7 @@ import AppLayout from '../../layouts/AppLayout.astro';
import Avatar from '../../components/Avatar.astro'; import Avatar from '../../components/Avatar.astro';
import { import {
getDispatchWithPoll, getAdjacentDispatches, getDispatchWithPoll, getAdjacentDispatches,
getPulseById, getUserVote, castVote, recordActivity, countCabMembers, getPulseById, castOrChangeVote, recordActivity, countCabMembers,
} from '../../lib/db'; } from '../../lib/db';
import { import {
parseDispatchSlug, dispatchSlug, dispatchKindLabel, parseDispatchSlug, dispatchSlug, dispatchKindLabel,
@ -26,10 +26,9 @@ if (Astro.request.method === 'POST') {
const optionIndex = Number(data.get('option_index')); const optionIndex = Number(data.get('option_index'));
const target = getPulseById(pulseId); const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex) if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length && optionIndex >= 0 && optionIndex < target.options.length) {
&& getUserVote(pulseId, user.id) === null) { const wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
castVote(pulseId, user.id, optionIndex); if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
recordActivity(user.id, 'voted', 'pulse', pulseId);
} }
return Astro.redirect(Astro.url.pathname); return Astro.redirect(Astro.url.pathname);
} }
@ -97,23 +96,27 @@ const bodyHtml = renderMd(d.body);
<input type="hidden" name="action" value="vote" /> <input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={d.poll.id} /> <input type="hidden" name="pulse_id" value={d.poll.id} />
{d.poll.options.map((opt, i) => { {d.poll.options.map((opt, i) => {
const hasVoted = d.poll!.my_vote !== null;
const chosen = d.poll!.my_vote === i; const chosen = d.poll!.my_vote === i;
const closed = d.poll!.status !== 'open';
const count = d.poll!.votes_by_option[i] ?? 0; const count = d.poll!.votes_by_option[i] ?? 0;
const pct = d.poll!.votes_total > 0 ? (count / d.poll!.votes_total) * 100 : 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); const letter = String.fromCharCode(65 + i);
return ( return (
<button <button
type="submit" type="submit"
name="option_index" name="option_index"
value={i} value={i}
class:list={['inline-poll-option', { chosen, locked }]} class:list={['inline-poll-option', { chosen, closed }]}
disabled={locked && !chosen} disabled={closed && !chosen}
aria-pressed={chosen} aria-pressed={chosen}
> >
<span class="inline-poll-letter">{letter}</span> <span class="inline-poll-letter">{letter}</span>
<span class="inline-poll-text">{opt}</span> <span class="inline-poll-text">{opt}</span>
{locked && ( {hasVoted && (
<span class="inline-poll-pct">{pct.toFixed(0)}%</span>
)}
{hasVoted && (
<span class="inline-poll-bar" aria-hidden="true"> <span class="inline-poll-bar" aria-hidden="true">
<span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span> <span class="inline-poll-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span> </span>
@ -304,15 +307,25 @@ const bodyHtml = renderMd(d.body);
background var(--duration-fast) var(--ease-standard); background var(--duration-fast) var(--ease-standard);
overflow: hidden; overflow: hidden;
} }
.inline-poll-option:hover:not(.locked) { border-color: var(--outline); } .inline-poll-option:hover:not(.closed) { border-color: var(--outline); }
.inline-poll-option.chosen { .inline-poll-option.chosen {
border-color: var(--pigment-terracotta); border-color: var(--pigment-terracotta);
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card)); background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
} }
.inline-poll-option.locked:not(.chosen) { .inline-poll-option.closed:not(.chosen) {
cursor: default; cursor: default;
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.inline-poll-pct {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
font-weight: 600;
letter-spacing: var(--tracking-wider);
color: var(--on-surface-variant);
font-variant-numeric: tabular-nums;
}
.inline-poll-option.chosen .inline-poll-pct { color: var(--pigment-terracotta); }
.inline-poll-option:disabled { opacity: 0.85; } .inline-poll-option:disabled { opacity: 0.85; }
.inline-poll-letter { .inline-poll-letter {
font-weight: 600; font-weight: 600;

View file

@ -7,7 +7,7 @@ import {
getUpcomingEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll, getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, getUserVote, castVote, getAllCabMembers, getPulseById, castOrChangeVote,
} from '../lib/db'; } from '../lib/db';
import { import {
timeOfDay, relativeTime, timeOfDay, relativeTime,
@ -41,11 +41,8 @@ if (Astro.request.method === 'POST') {
const target = getPulseById(pulseId); const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex) if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length) { && optionIndex >= 0 && optionIndex < target.options.length) {
const existing = getUserVote(pulseId, user.id); const wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
if (existing === null) { if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
castVote(pulseId, user.id, optionIndex);
recordActivity(user.id, 'voted', 'pulse', pulseId);
}
} }
return Astro.redirect('/pulse'); return Astro.redirect('/pulse');
} }
@ -184,30 +181,38 @@ const members = getAllCabMembers();
{featured.poll && ( {featured.poll && (
<aside class="pulse-col" aria-label="This week's pulse"> <aside class="pulse-col" aria-label="This week's pulse">
<p class="pulse-eyebrow"><span class="pulse-eyebrow-dot" aria-hidden="true"></span>This week's pulse</p> <p class="pulse-eyebrow"><span class="pulse-eyebrow-dot" aria-hidden="true"></span>This week's</p>
<p class="pulse-question">{featured.poll.question}</p> <p class="pulse-question">{featured.poll.question}</p>
<p class="pulse-status"> <p class="pulse-status">
Closes {closeDayLabel(featured.poll.closes_at).toUpperCase()} · {featured.poll.votes_total} of {members.length} voted {featured.poll.my_vote === null
? <>Vote to see how the council weighs in · Closes {closeDayLabel(featured.poll.closes_at).toUpperCase()}</>
: <>{featured.poll.votes_total} of {members.length} voted · Closes {closeDayLabel(featured.poll.closes_at).toUpperCase()} · Click to change</>}
</p> </p>
<form method="POST" class="pulse-options" novalidate> <form method="POST" class="pulse-options" novalidate>
<input type="hidden" name="action" value="vote" /> <input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={featured.poll.id} /> <input type="hidden" name="pulse_id" value={featured.poll.id} />
{featured.poll.options.map((opt, i) => { {featured.poll.options.map((opt, i) => {
const hasVoted = featured.poll!.my_vote !== null;
const chosen = featured.poll!.my_vote === i; const chosen = featured.poll!.my_vote === i;
const locked = featured.poll!.my_vote !== null || featured.poll!.status !== 'open'; const closed = featured.poll!.status !== 'open';
const count = featured.poll!.votes_by_option[i] ?? 0;
const pct = featured.poll!.votes_total > 0 ? (count / featured.poll!.votes_total) * 100 : 0;
const letter = String.fromCharCode(65 + i); const letter = String.fromCharCode(65 + i);
return ( return (
<button <button
type="submit" type="submit"
name="option_index" name="option_index"
value={i} value={i}
class:list={['pulse-option', { chosen, locked }]} class:list={['pulse-option', { chosen, closed }]}
disabled={locked && !chosen} disabled={closed && !chosen}
aria-pressed={chosen} aria-pressed={chosen}
> >
<span class="pulse-option-letter">{letter}</span> <span class="pulse-option-letter">{letter}</span>
<span class="pulse-option-text">{opt}</span> <span class="pulse-option-text">{opt}</span>
{hasVoted && (
<span class="pulse-option-pct" aria-label={`${pct.toFixed(0)} percent`}>{pct.toFixed(0)}%</span>
)}
</button> </button>
); );
})} })}
@ -577,12 +582,12 @@ const members = getAllCabMembers();
transition: border-color var(--duration-fast) var(--ease-standard), transition: border-color var(--duration-fast) var(--ease-standard),
background 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 { .pulse-option.chosen {
border-left-color: var(--pigment-terracotta); border-left-color: var(--pigment-terracotta);
background: rgba(185, 107, 88, 0.05); background: rgba(185, 107, 88, 0.05);
} }
.pulse-option.locked:not(.chosen) { .pulse-option.closed:not(.chosen) {
cursor: default; cursor: default;
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
@ -599,6 +604,18 @@ const members = getAllCabMembers();
} }
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); } .pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
.pulse-option-text { flex: 1; } .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 framing matches the roadmap carousel ──── */
.council-section { .council-section {