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);
|
).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 = ?'
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue