From 4d68392ada188001941237867605de9ba6624a77 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 6 Apr 2026 16:34:44 -0400 Subject: [PATCH] feat: add award winner resolver with tiebreak logic and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/services/award-winner-resolver.ts | 37 +++++++++++++++ tests/unit/award-master.test.ts | 47 ++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/server/services/award-winner-resolver.ts create mode 100644 tests/unit/award-master.test.ts diff --git a/src/server/services/award-winner-resolver.ts b/src/server/services/award-winner-resolver.ts new file mode 100644 index 0000000..bea28b5 --- /dev/null +++ b/src/server/services/award-winner-resolver.ts @@ -0,0 +1,37 @@ +/** + * Resolve the winner of a PICK_WINNER award given all votes and the chair's userId. + * Logic: count votes per project. If one project has the most, it wins. + * If tied, the chair's pick wins among the tied projects. + */ +export function resolveAwardWinner( + votes: Array<{ userId: string; projectId: string }>, + chairUserId: string +): string { + if (votes.length === 0) { + throw new Error('Cannot resolve winner with no votes') + } + + // Tally votes per project + const tally = new Map() + for (const v of votes) { + tally.set(v.projectId, (tally.get(v.projectId) || 0) + 1) + } + + const maxVotes = Math.max(...tally.values()) + const topProjects = [...tally.entries()] + .filter(([, count]) => count === maxVotes) + .map(([pid]) => pid) + + if (topProjects.length === 1) { + return topProjects[0] + } + + // Tie: chair's pick wins if among tied projects + const chairVote = votes.find((v) => v.userId === chairUserId) + if (chairVote && topProjects.includes(chairVote.projectId)) { + return chairVote.projectId + } + + // Chair voted for a non-tied project — pick alphabetically for stability + return topProjects.sort()[0] +} diff --git a/tests/unit/award-master.test.ts b/tests/unit/award-master.test.ts new file mode 100644 index 0000000..d26aa24 --- /dev/null +++ b/tests/unit/award-master.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { resolveAwardWinner } from '@/server/services/award-winner-resolver' + +describe('resolveAwardWinner', () => { + it('returns the sole voted project when all agree', () => { + const votes = [ + { userId: 'u1', projectId: 'p1' }, + { userId: 'u2', projectId: 'p1' }, + ] + expect(resolveAwardWinner(votes, 'u1')).toBe('p1') + }) + + it('returns the majority winner when no tie', () => { + const votes = [ + { userId: 'u1', projectId: 'p1' }, + { userId: 'u2', projectId: 'p2' }, + { userId: 'u3', projectId: 'p1' }, + ] + expect(resolveAwardWinner(votes, 'u1')).toBe('p1') + }) + + it('uses chair vote as tiebreaker', () => { + const votes = [ + { userId: 'chair', projectId: 'p2' }, + { userId: 'u2', projectId: 'p1' }, + ] + expect(resolveAwardWinner(votes, 'chair')).toBe('p2') + }) + + it('returns chair pick when tied and chair voted for one of the tied projects', () => { + const votes = [ + { userId: 'chair', projectId: 'p3' }, + { userId: 'u2', projectId: 'p1' }, + { userId: 'u3', projectId: 'p2' }, + ] + expect(resolveAwardWinner(votes, 'chair')).toBe('p3') + }) + + it('returns sole vote for solo sponsor', () => { + const votes = [{ userId: 'sponsor', projectId: 'p1' }] + expect(resolveAwardWinner(votes, 'sponsor')).toBe('p1') + }) + + it('throws if no votes', () => { + expect(() => resolveAwardWinner([], 'chair')).toThrow() + }) +})