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 {