feat(polls): "See who voted" — per-answer voter list

The poll card showed vote counts but never who voted, even though
computePollState already parses a sender for every response. Surface it:

- tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the
  same latest-response-per-sender loop as the counts, so voters can never disagree
  with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests.
- PollContent adds a "Show who voted" toggle, shown only when results are visible
  (disclosed live, or undisclosed after end — so a secret ballot stays secret).
  When on, each answer lists its voters' display names (getMemberName), rendered
  as a sibling of the answer button so the radiogroup keyboard model is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 21:56:50 -04:00
co-authored by Claude Opus 4.8
parent 5cce94edba
commit 1aaea09fc9
4 changed files with 91 additions and 5 deletions
+45
View File
@@ -70,6 +70,51 @@ test('tallyResponses: re-voting after an empty clear counts the newest non-empty
assert.equal(total, 1);
});
test('tallyResponses: voters list the senders per answer, consistent with counts', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@b:s', ts: 1, answerIds: ['1'] },
{ sender: '@c:s', ts: 1, answerIds: ['2'] },
];
const { counts, voters } = tallyResponses(responses, me);
assert.deepEqual([...(voters.get('1') ?? [])].sort(), ['@a:s', '@b:s']);
assert.deepEqual(voters.get('2'), ['@c:s']);
// voters must always agree with counts
for (const [id, list] of voters) {
assert.equal(list.length, counts.get(id));
}
});
test('tallyResponses: a re-vote moves the sender to the new answer in voters', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote
];
const { voters } = tallyResponses(responses, me);
assert.equal(voters.get('1'), undefined); // no longer under the old answer
assert.deepEqual(voters.get('2'), ['@a:s']);
});
test('tallyResponses: a cleared latest response drops the sender from every voter list', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1', '2'] },
{ sender: '@a:s', ts: 2, answerIds: [] }, // cleared
];
const { voters } = tallyResponses(responses, me);
assert.equal(voters.get('1'), undefined);
assert.equal(voters.get('2'), undefined);
});
test('tallyResponses: multi-select lists a sender under each chosen answer', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1', '3'] },
{ sender: '@b:s', ts: 1, answerIds: ['3'] },
];
const { voters } = tallyResponses(responses, me);
assert.deepEqual(voters.get('1'), ['@a:s']);
assert.deepEqual([...(voters.get('3') ?? [])].sort(), ['@a:s', '@b:s']);
});
test('resultsVisible: disclosed always, undisclosed only when ended', () => {
assert.equal(resultsVisible(false, false), true); // disclosed, open
assert.equal(resultsVisible(false, true), true); // disclosed, ended