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:
parent
89688d605d
commit
5ddaad3da3
3 changed files with 69 additions and 24 deletions
|
|
@ -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 = ?'
|
||||
|
|
|
|||
|
|
@ -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);
|
|||
<input type="hidden" name="action" value="vote" />
|
||||
<input type="hidden" name="pulse_id" value={d.poll.id} />
|
||||
{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 (
|
||||
<button
|
||||
type="submit"
|
||||
name="option_index"
|
||||
value={i}
|
||||
class:list={['inline-poll-option', { chosen, locked }]}
|
||||
disabled={locked && !chosen}
|
||||
class:list={['inline-poll-option', { chosen, closed }]}
|
||||
disabled={closed && !chosen}
|
||||
aria-pressed={chosen}
|
||||
>
|
||||
<span class="inline-poll-letter">{letter}</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-fill" style={`width:${pct.toFixed(1)}%`}></span>
|
||||
</span>
|
||||
|
|
@ -304,15 +307,25 @@ const bodyHtml = renderMd(d.body);
|
|||
background var(--duration-fast) var(--ease-standard);
|
||||
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 {
|
||||
border-color: var(--pigment-terracotta);
|
||||
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;
|
||||
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-letter {
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
getUpcomingEvents, getEventBySlug, getEventAttendees,
|
||||
getUserRsvp, setEventRsvp, recordActivity,
|
||||
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
|
||||
getAllCabMembers, getPulseById, getUserVote, castVote,
|
||||
getAllCabMembers, getPulseById, castOrChangeVote,
|
||||
} from '../lib/db';
|
||||
import {
|
||||
timeOfDay, relativeTime,
|
||||
|
|
@ -41,11 +41,8 @@ if (Astro.request.method === 'POST') {
|
|||
const target = getPulseById(pulseId);
|
||||
if (target && target.status === 'open' && Number.isInteger(optionIndex)
|
||||
&& optionIndex >= 0 && optionIndex < target.options.length) {
|
||||
const existing = getUserVote(pulseId, user.id);
|
||||
if (existing === null) {
|
||||
castVote(pulseId, user.id, optionIndex);
|
||||
recordActivity(user.id, 'voted', 'pulse', pulseId);
|
||||
}
|
||||
const wasNew = castOrChangeVote(pulseId, user.id, optionIndex);
|
||||
if (wasNew) recordActivity(user.id, 'voted', 'pulse', pulseId);
|
||||
}
|
||||
return Astro.redirect('/pulse');
|
||||
}
|
||||
|
|
@ -184,30 +181,38 @@ const members = getAllCabMembers();
|
|||
|
||||
{featured.poll && (
|
||||
<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-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>
|
||||
|
||||
<form method="POST" class="pulse-options" novalidate>
|
||||
<input type="hidden" name="action" value="vote" />
|
||||
<input type="hidden" name="pulse_id" value={featured.poll.id} />
|
||||
{featured.poll.options.map((opt, i) => {
|
||||
const hasVoted = featured.poll!.my_vote !== null;
|
||||
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);
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
name="option_index"
|
||||
value={i}
|
||||
class:list={['pulse-option', { chosen, locked }]}
|
||||
disabled={locked && !chosen}
|
||||
class:list={['pulse-option', { chosen, closed }]}
|
||||
disabled={closed && !chosen}
|
||||
aria-pressed={chosen}
|
||||
>
|
||||
<span class="pulse-option-letter">{letter}</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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue