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 ( {letter} {opt} - {locked && ( + {hasVoted && ( + {pct.toFixed(0)}% + )} + {hasVoted && ( @@ -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; diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index 679d7c9..25c43c3 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -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 && (