diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 33d470b..76e114b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -3,7 +3,9 @@ Phase 1 of the Ranked Choices Expo migration. This app currently provides a shortcode lookup, ballot preview, and local candidate-ranking controls backed by the existing PHP API. Anonymous ballots can be submitted through the typed, -idempotent v2 vote endpoint. The app does not authenticate users yet. +idempotent v2 vote endpoint. Released votes are loaded through the public v2 +results contract and calculated locally by the pure `packages/rcv-core` +TypeScript module. The app does not authenticate users yet. ## Get started @@ -68,6 +70,7 @@ connectivity will be designed alongside the later web deployment decision. - move-up, move-down, remove, and reset controls - idempotent anonymous vote submission with loading, retry, duplicate-device, cutoff, and accepted states +- local winner and round-by-round result rendering after an accepted vote - loading, closed, not-found, malformed-response, and network-error handling Name-required ballots, secure-code entry, grouping questions, authentication, diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 160156e..6ba61bf 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -8,6 +8,7 @@ "name": "@rankedchoices/mobile", "version": "1.0.0", "dependencies": { + "@rankedchoices/rcv-core": "file:../../packages/rcv-core", "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", @@ -32,6 +33,10 @@ "vitest": "^4.1.10" } }, + "../../packages/rcv-core": { + "name": "@rankedchoices/rcv-core", + "version": "0.1.0" + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -2527,6 +2532,10 @@ } } }, + "node_modules/@rankedchoices/rcv-core": { + "resolved": "../../packages/rcv-core", + "link": true + }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9efb316..59d061f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -4,6 +4,7 @@ "version": "1.0.0", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@rankedchoices/rcv-core": "file:../../packages/rcv-core", "expo": "~57.0.11", "expo-constants": "~57.0.9", "expo-crypto": "~57.0.1", diff --git a/apps/mobile/src/api/v2-api.test.ts b/apps/mobile/src/api/v2-api.test.ts index 5d6a94b..8558a9e 100644 --- a/apps/mobile/src/api/v2-api.test.ts +++ b/apps/mobile/src/api/v2-api.test.ts @@ -94,3 +94,42 @@ describe('V2ApiClient.submitVote', () => { await expect(client.submitVote(request)).rejects.toMatchObject({ code: 'malformed_response' }); }); }); + +describe('V2ApiClient.getResults', () => { + it('loads typed anonymous election data', async () => { + const payload = { + ballot: { key: 'pizza night', name: 'Pizza', positions: 1, tieBreak: 'weighted' }, + candidates: [{ id: 3, name: 'Mushroom' }], + votes: [[3]], + }; + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ data: payload, error: null })), + ); + const client = new V2ApiClient({ baseUrl: 'https://example.test/api/', fetchImpl }); + + await expect(client.getResults(' pizza night ')).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://example.test/api/v2/results.php?key=pizza%20night', + { signal: undefined }, + ); + }); + + it('preserves the unreleased-results state', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: null, + error: { code: 'results_not_released', message: 'Not released.' }, + }), + { status: 403 }, + ), + }); + + await expect(client.getResults('private')).rejects.toMatchObject({ + code: 'results_not_released', + retryable: false, + }); + }); +}); diff --git a/apps/mobile/src/api/v2-api.ts b/apps/mobile/src/api/v2-api.ts index 2e0b2b1..dfd6e84 100644 --- a/apps/mobile/src/api/v2-api.ts +++ b/apps/mobile/src/api/v2-api.ts @@ -11,9 +11,21 @@ export type SubmitVoteResult = { replayed: boolean; }; +export type ElectionResults = { + ballot: { + key: string; + name: string; + positions: number; + tieBreak: 'weighted' | 'random'; + }; + candidates: { id: number; name: string }[]; + votes: number[][]; +}; + export type V2ApiErrorCode = | 'validation_failed' | 'ballot_not_found' + | 'results_not_released' | 'idempotency_conflict' | 'voting_closed' | 'voter_name_required' @@ -56,6 +68,7 @@ function isKnownErrorCode(value: unknown): value is V2ApiErrorCode { [ 'validation_failed', 'ballot_not_found', + 'results_not_released', 'idempotency_conflict', 'voting_closed', 'voter_name_required', @@ -78,6 +91,26 @@ export class V2ApiClient { this.fetchImpl = fetchImpl; } + async getResults(key: string, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await this.fetchImpl( + `${this.baseUrl}/v2/results.php?key=${encodeURIComponent(key.trim())}`, + { signal }, + ); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error; + throw new V2ApiError('network', 'The results server could not be reached.', true); + } + + const envelope = await this.parseEnvelope(response); + if (envelope.error !== null) throw this.normalizeError(envelope.error, response.status); + if (!response.ok || !isElectionResults(envelope.data)) { + throw new V2ApiError('malformed_response', 'The results server returned invalid data.'); + } + return envelope.data; + } + async submitVote(request: SubmitVoteRequest, signal?: AbortSignal): Promise { let response: Response; try { @@ -92,36 +125,10 @@ export class V2ApiClient { throw new V2ApiError('network', 'The vote server could not be reached.', true); } - let envelope: unknown; - try { - envelope = JSON.parse(await response.text()); - } catch { - if (!response.ok) { - throw new V2ApiError( - 'http', - 'The vote server returned an error.', - response.status >= 500, - response.status, - ); - } - throw new V2ApiError('malformed_response', 'The vote server returned invalid data.'); - } - - if (!isRecord(envelope)) { - throw new V2ApiError('malformed_response', 'The vote server returned invalid data.'); - } + const envelope = await this.parseEnvelope(response); if (envelope.error !== null) { - const error = envelope.error; - if (!isRecord(error) || !isKnownErrorCode(error.code) || typeof error.message !== 'string') { - throw new V2ApiError('malformed_response', 'The vote server returned invalid error data.'); - } - throw new V2ApiError( - error.code, - error.message, - error.code === 'server_error' || response.status >= 500, - response.status, - ); + throw this.normalizeError(envelope.error, response.status); } const data = envelope.data; @@ -141,4 +148,51 @@ export class V2ApiClient { replayed: data.replayed, }; } + + private async parseEnvelope(response: Response): Promise> { + let envelope: unknown; + try { + envelope = JSON.parse(await response.text()); + } catch { + if (!response.ok) { + throw new V2ApiError('http', 'The server returned an error.', response.status >= 500, response.status); + } + throw new V2ApiError('malformed_response', 'The server returned invalid data.'); + } + if (!isRecord(envelope) || !('error' in envelope) || !('data' in envelope)) { + throw new V2ApiError('malformed_response', 'The server returned invalid data.'); + } + return envelope; + } + + private normalizeError(error: unknown, status: number): V2ApiError { + if (!isRecord(error) || !isKnownErrorCode(error.code) || typeof error.message !== 'string') { + return new V2ApiError('malformed_response', 'The server returned invalid error data.'); + } + return new V2ApiError( + error.code, + error.message, + error.code === 'server_error' || status >= 500, + status, + ); + } +} + +function isElectionResults(value: unknown): value is ElectionResults { + if (!isRecord(value) || !isRecord(value.ballot) || !Array.isArray(value.candidates) || !Array.isArray(value.votes)) { + return false; + } + const ballot = value.ballot; + return ( + typeof ballot.key === 'string' && + typeof ballot.name === 'string' && + typeof ballot.positions === 'number' && + (ballot.tieBreak === 'weighted' || ballot.tieBreak === 'random') && + value.candidates.every( + (candidate) => isRecord(candidate) && typeof candidate.id === 'number' && typeof candidate.name === 'string', + ) && + value.votes.every( + (vote) => Array.isArray(vote) && vote.every((candidateId) => typeof candidateId === 'number'), + ) + ); } diff --git a/apps/mobile/src/app/ballot/[key]/index.tsx b/apps/mobile/src/app/ballot/[key]/index.tsx index 506a9b4..5648c09 100644 --- a/apps/mobile/src/app/ballot/[key]/index.tsx +++ b/apps/mobile/src/app/ballot/[key]/index.tsx @@ -1,6 +1,7 @@ import { createLegacyApiClient } from '@/api/client'; import { LegacyApiError, type BallotDetail, type Candidate } from '@/api/legacy-api'; import { CandidateRanking } from '@/components/candidate-ranking'; +import { ElectionResults } from '@/components/election-results'; import { VoteSubmission } from '@/components/vote-submission'; import { createRanking } from '@/features/ranking'; import { useLocalSearchParams } from 'expo-router'; @@ -128,6 +129,7 @@ export default function BallotScreen() { ) : null} setVoteAccepted(true)} ranking={ranking} /> + {voteAccepted ? : null} ); diff --git a/apps/mobile/src/components/election-results.test.tsx b/apps/mobile/src/components/election-results.test.tsx new file mode 100644 index 0000000..3e2ace9 --- /dev/null +++ b/apps/mobile/src/components/election-results.test.tsx @@ -0,0 +1,25 @@ +import { calculateElection } from '@rankedchoices/rcv-core'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { ElectionResultsView } from './election-results'; + +describe('ElectionResultsView', () => { + it('renders winners and every local tally round', () => { + const result = calculateElection({ + candidates: [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Grace' }, + { id: 3, name: 'Katherine' }, + ], + ballots: [[1], [1, 2], [2, 1], [3, 2]], + }); + const html = renderToStaticMarkup(); + + expect(html).toContain('Current results'); + expect(html).toContain('Calculated on this device from 4 votes.'); + expect(html).toContain('Round 1'); + expect(html).toContain(result.winners[0].name); + expect(html).toContain('Eliminated'); + }); +}); diff --git a/apps/mobile/src/components/election-results.tsx b/apps/mobile/src/components/election-results.tsx new file mode 100644 index 0000000..55944b9 --- /dev/null +++ b/apps/mobile/src/components/election-results.tsx @@ -0,0 +1,162 @@ +import { createV2ApiClient } from '@/api/client'; +import { V2ApiError } from '@/api/v2-api'; +import { calculateElection, type ElectionResult } from '@rankedchoices/rcv-core'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; + +type ResultState = + | { status: 'loading' } + | { status: 'loaded'; result: ElectionResult; voteCount: number } + | { status: 'error'; error: V2ApiError }; + +export function ElectionResults({ ballotKey }: { ballotKey: string }) { + const client = useMemo(() => createV2ApiClient(), []); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + const controller = new AbortController(); + client.getResults(ballotKey, controller.signal).then( + (data) => { + setState({ + status: 'loaded', + voteCount: data.votes.length, + result: calculateElection({ + candidates: data.candidates, + ballots: data.votes, + seats: data.ballot.positions, + tieBreak: data.ballot.tieBreak, + }), + }); + }, + (error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return; + setState({ + status: 'error', + error: + error instanceof V2ApiError + ? error + : new V2ApiError('network', 'Results could not be loaded.', true), + }); + }, + ); + return () => controller.abort(); + }, [attempt, ballotKey, client]); + + if (state.status === 'loading') { + return ( + + + Calculating results on this device… + + ); + } + + if (state.status === 'error') { + const unreleased = state.error.code === 'results_not_released'; + return ( + + + {unreleased ? 'Results are private' : 'Results unavailable'} + + + {unreleased ? 'This ballot’s results have not been released yet.' : state.error.message} + + {state.error.retryable ? ( + { + setState({ status: 'loading' }); + setAttempt((value) => value + 1); + }} + style={({ pressed }) => [styles.retryButton, pressed && styles.buttonPressed]}> + Try again + + ) : null} + + ); + } + + return ; +} + +export function ElectionResultsView({ + result, + voteCount, +}: { + result: ElectionResult; + voteCount: number; +}) { + return ( + + Current results + + Calculated on this device from {voteCount} {voteCount === 1 ? 'vote' : 'votes'}. + + + + {result.winners.length === 1 ? 'Winner' : 'Winners'} + + {result.winners.length > 0 + ? result.winners.map((candidate) => candidate.name).join(', ') + : 'No winner yet'} + + + + {result.rounds.map((round) => ( + + Round {round.number} + {Object.entries(round.tally) + .sort((left, right) => right[1] - left[1]) + .map(([candidateId, votes]) => ( + + {candidateName(result, Number(candidateId))} + {formatVotes(votes)} + + ))} + + {round.outcome.type === 'elected' ? 'Elected' : 'Eliminated'}:{' '} + {round.outcome.candidateName} + + + ))} + + ); +} + +function candidateName(result: ElectionResult, id: number): string { + return result.candidates.find((candidate) => candidate.id === id)?.name ?? `Choice ${id}`; +} + +function formatVotes(votes: number): string { + return Number.isInteger(votes) ? String(votes) : votes.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +const styles = StyleSheet.create({ + results: { marginTop: 28 }, + sectionTitle: { color: '#1f3143', fontSize: 22, fontWeight: '800' }, + summary: { color: '#52697f', fontSize: 14, lineHeight: 20, marginTop: 5 }, + statusCard: { backgroundColor: '#ffffff', borderRadius: 14, marginTop: 18, padding: 18 }, + statusText: { color: '#52697f', fontSize: 14, lineHeight: 20, marginTop: 8 }, + winnerCard: { backgroundColor: '#e8f2ed', borderRadius: 14, marginTop: 14, padding: 16 }, + winnerLabel: { color: '#436251', fontSize: 12, fontWeight: '800', textTransform: 'uppercase' }, + winnerNames: { color: '#125435', fontSize: 21, fontWeight: '800', marginTop: 4 }, + roundCard: { backgroundColor: '#ffffff', borderRadius: 14, marginTop: 12, padding: 16 }, + roundTitle: { color: '#12355b', fontSize: 18, fontWeight: '800', marginBottom: 8 }, + tallyRow: { flexDirection: 'row', gap: 12, justifyContent: 'space-between', paddingVertical: 4 }, + tallyName: { color: '#344a5f', flex: 1, fontSize: 14 }, + tallyVotes: { color: '#1f3143', fontSize: 14, fontWeight: '700' }, + outcomeText: { color: '#6b4600', fontSize: 13, fontWeight: '700', marginTop: 10 }, + retryButton: { + alignSelf: 'flex-start', + backgroundColor: '#146c43', + borderRadius: 8, + marginTop: 12, + minHeight: 42, + justifyContent: 'center', + paddingHorizontal: 14, + }, + retryText: { color: '#ffffff', fontSize: 14, fontWeight: '800' }, + buttonPressed: { opacity: 0.75 }, +}); diff --git a/apps/mobile/src/features/rcv-core.test.ts b/apps/mobile/src/features/rcv-core.test.ts new file mode 100644 index 0000000..a1755d6 --- /dev/null +++ b/apps/mobile/src/features/rcv-core.test.ts @@ -0,0 +1,83 @@ +import { calculateElection } from '@rankedchoices/rcv-core'; +import { describe, expect, it } from 'vitest'; + +const candidates = ['A', 'B', 'C', 'D'].map((name, index) => ({ id: index + 1, name })); +const ids = Object.fromEntries(candidates.map((candidate) => [candidate.name, candidate.id])); +const ballots = (rankings: string[][]) => rankings.map((ranking) => ranking.map((name) => ids[name])); + +describe('calculateElection legacy parity fixtures', () => { + it('elects a clear single-seat majority', () => { + const result = calculateElection({ + candidates: candidates.slice(0, 3), + ballots: ballots([ + ['A', 'B'], + ['A', 'C'], + ['A', 'B'], + ['B', 'C'], + ['C', 'B'], + ]), + }); + + expect(result.winners.map((candidate) => candidate.name)).toEqual(['A']); + expect(result.rounds[0]).toMatchObject({ number: 1, outcome: { type: 'elected' } }); + }); + + it('eliminates and redistributes lower choices', () => { + const result = calculateElection({ + candidates: candidates.slice(0, 3), + ballots: ballots([ + ['A', 'B'], + ['A', 'C'], + ['B', 'A'], + ['B', 'C'], + ['C', 'B'], + ]), + }); + + expect(result.rounds[0].outcome).toMatchObject({ type: 'eliminated', candidateName: 'C' }); + expect(result.winners.map((candidate) => candidate.name)).toEqual(['B']); + }); + + it('uses weighted downstream support to break elimination ties', () => { + const result = calculateElection({ + candidates: candidates.slice(0, 3), + ballots: ballots([ + ['A', 'B'], + ['A', 'B'], + ['B', 'A'], + ['C'], + ]), + tieBreak: 'weighted', + }); + + expect(result.rounds[0].outcome).toMatchObject({ type: 'eliminated', candidateName: 'C' }); + expect(result.winners.map((candidate) => candidate.name)).toEqual(['A']); + }); + + it('transfers a multi-seat surplus using the legacy quota', () => { + const result = calculateElection({ + candidates: candidates.slice(0, 2), + ballots: ballots([ + ['A', 'B'], + ['A', 'B'], + ['A', 'B'], + ['A', 'B'], + ]), + seats: 2, + }); + + expect(result.winners.map((candidate) => candidate.name)).toEqual(['A', 'B']); + expect(result.rounds[0].quota).toBe(1.33); + expect(result.rounds[1].tally[ids.B]).toBeCloseTo(2.67, 2); + }); + + it('is deterministic for random tie breaks and ignores invalid IDs', () => { + const input = { + candidates: candidates.slice(0, 3), + ballots: [...ballots([['A'], ['A'], ['B'], ['B'], ['C']]), [999, ids.A]], + tieBreak: 'random' as const, + }; + + expect(calculateElection(input)).toEqual(calculateElection(input)); + }); +}); diff --git a/apps/mobile/vitest.config.mts b/apps/mobile/vitest.config.mts index 413dcf8..8a04faf 100644 --- a/apps/mobile/vitest.config.mts +++ b/apps/mobile/vitest.config.mts @@ -6,6 +6,9 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + '@rankedchoices/rcv-core': fileURLToPath( + new URL('../../packages/rcv-core/src/index.ts', import.meta.url), + ), 'react-native': 'react-native-web', }, }, diff --git a/packages/rcv-core/README.md b/packages/rcv-core/README.md new file mode 100644 index 0000000..aa72561 --- /dev/null +++ b/packages/rcv-core/README.md @@ -0,0 +1,6 @@ +# `@rankedchoices/rcv-core` + +Pure TypeScript ranked-choice election calculation shared by native clients. The package has no +AngularJS, DOM, network, or storage dependencies. It intentionally preserves the legacy +RankedChoices quota, surplus-transfer, and deterministic tie-break behavior so parity fixtures can +be run while the clients migrate. diff --git a/packages/rcv-core/package.json b/packages/rcv-core/package.json new file mode 100644 index 0000000..c512481 --- /dev/null +++ b/packages/rcv-core/package.json @@ -0,0 +1,7 @@ +{ + "name": "@rankedchoices/rcv-core", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": "./src/index.ts" +} diff --git a/packages/rcv-core/src/index.ts b/packages/rcv-core/src/index.ts new file mode 100644 index 0000000..972260e --- /dev/null +++ b/packages/rcv-core/src/index.ts @@ -0,0 +1,187 @@ +export type CandidateId = number; + +export type RcvCandidate = { + id: CandidateId; + name: string; +}; + +export type TieBreak = 'weighted' | 'random'; + +export type ElectionInput = { + candidates: readonly RcvCandidate[]; + ballots: readonly (readonly CandidateId[])[]; + seats?: number; + tieBreak?: TieBreak; +}; + +export type RoundOutcome = + | { type: 'elected'; candidateId: CandidateId; candidateName: string } + | { type: 'eliminated'; candidateId: CandidateId; candidateName: string }; + +export type ElectionRound = { + number: number; + quota: number; + tally: Record; + exhaustedVotes: number; + outcome: RoundOutcome; +}; + +export type ElectionResult = { + candidates: RcvCandidate[]; + winners: RcvCandidate[]; + rounds: ElectionRound[]; + seats: number; +}; + +function round(value: number, precision: number): number { + const factor = 10 ** precision; + return Math.round((value + Number.EPSILON) * factor) / factor; +} + +function deterministicScore(voteCount: number, name: string, index: number, roundNumber: number) { + const input = `${voteCount}${`${name.slice(0, 12)}${index}`.replace(/\W/g, '')}${roundNumber}`; + const parsed = Number.parseInt(input, 36); + const firstTenDigits = Number(String(parsed).slice(0, 10)); + return (firstTenDigits * 9301 + 49297) % 233280; +} + +function chooseTiedCandidate( + ids: readonly CandidateId[], + names: ReadonlyMap, + tied: readonly CandidateId[], + ballots: readonly CandidateId[][], + weights: readonly number[], + tieBreak: TieBreak, + electing: boolean, + roundNumber: number, +): CandidateId { + if (tieBreak === 'random') { + return [...tied] + .map((id) => ({ + id, + score: deterministicScore(ballots.length, names.get(id) ?? String(id), ids.indexOf(id), roundNumber), + })) + .sort((left, right) => right.score - left.score || ids.indexOf(left.id) - ids.indexOf(right.id))[0].id; + } + + const values = new Map(tied.map((id) => [id, 0])); + const longestBallot = ballots.reduce((length, ballot) => Math.max(length, ballot.length), 0); + for (let rank = 1; rank < longestBallot; rank += 1) { + ballots.forEach((ballot, ballotIndex) => { + const id = ballot[rank]; + if (values.has(id)) { + values.set(id, (values.get(id) ?? 0) + weights[ballotIndex] / 10 ** rank); + } + }); + } + + return [...tied].sort((left, right) => { + const difference = (values.get(right) ?? 0) - (values.get(left) ?? 0); + return (electing ? difference : -difference) || ids.indexOf(left) - ids.indexOf(right); + })[0]; +} + +export function calculateElection(input: ElectionInput): ElectionResult { + const candidates = input.candidates.filter( + (candidate, index, all) => all.findIndex((item) => item.id === candidate.id) === index, + ); + if (candidates.length === 0) return { candidates: [], winners: [], rounds: [], seats: 0 }; + + const ids = candidates.map((candidate) => candidate.id); + const validIds = new Set(ids); + const names = new Map(candidates.map((candidate) => [candidate.id, candidate.name])); + const seats = Math.max(1, Math.min(Math.trunc(input.seats ?? 1), candidates.length)); + const tieBreak = input.tieBreak ?? 'weighted'; + const ballots = input.ballots.map((ballot) => + ballot.filter( + (id, index, ranking) => validIds.has(id) && ranking.indexOf(id) === index, + ), + ); + const weights = ballots.map(() => 1); + const active = new Set(ids); + const winners: RcvCandidate[] = []; + const rounds: ElectionRound[] = []; + const maxRounds = candidates.length * 2 + seats; + + while (winners.length < seats && rounds.length < maxRounds && active.size > 0) { + const remainingSeats = seats - winners.length; + const voteValue = weights.reduce((total, weight) => total + weight, 0); + let quota = round(voteValue / (remainingSeats + 1), 2); + const tally = Object.fromEntries(ids.map((id) => [id, 0])) as Record; + let exhaustedVotes = 0; + + ballots.forEach((ballot, index) => { + const firstChoice = ballot.find((id) => active.has(id)); + if (firstChoice === undefined) exhaustedVotes += weights[index]; + else tally[firstChoice] += weights[index]; + }); + ids.forEach((id) => { + tally[id] = round(tally[id], 4); + }); + + const activeWithVotes = [...active].filter((id) => tally[id] > 0); + const exceedsQuota = activeWithVotes.filter((id) => tally[id] > quota); + const electing = exceedsQuota.length > 0 || activeWithVotes.length === 1 || active.size <= remainingSeats; + const pool = electing + ? exceedsQuota.length > 0 + ? exceedsQuota + : activeWithVotes.length > 0 + ? activeWithVotes + : [...active] + : activeWithVotes; + const targetValue = electing + ? Math.max(...pool.map((id) => tally[id])) + : Math.min(...pool.map((id) => tally[id])); + const tied = pool.filter((id) => tally[id] === targetValue); + const chosen = + tied.length === 1 + ? tied[0] + : chooseTiedCandidate( + ids, + names, + tied, + ballots, + weights, + tieBreak, + electing, + rounds.length + 1, + ); + + if (electing && activeWithVotes.length === 1) quota = Math.min(quota, tally[chosen]); + const candidate = candidates.find((item) => item.id === chosen)!; + const outcome: RoundOutcome = { + type: electing ? 'elected' : 'eliminated', + candidateId: chosen, + candidateName: candidate.name, + }; + rounds.push({ + number: rounds.length + 1, + quota, + tally, + exhaustedVotes: round(exhaustedVotes, 4), + outcome, + }); + + if (electing) { + winners.push(candidate); + const chosenTally = tally[chosen]; + if (chosenTally > 0) { + ballots.forEach((ballot, index) => { + if (ballot.find((id) => active.has(id)) === chosen) { + weights[index] *= 1 - quota / chosenTally; + } + }); + } + } + active.delete(chosen); + + if (!electing) { + [...active].filter((id) => tally[id] === 0).forEach((id) => active.delete(id)); + } + ballots.forEach((ballot, index) => { + if (!ballot.some((id) => active.has(id))) weights[index] = 0; + }); + } + + return { candidates, winners, rounds, seats }; +} diff --git a/src/api/v2/README.md b/src/api/v2/README.md index 62d2431..53b174b 100644 --- a/src/api/v2/README.md +++ b/src/api/v2/README.md @@ -39,3 +39,9 @@ it with a different ranking returns `idempotency_conflict`. This endpoint intentionally stops at the Phase 1 anonymous flow. Ballots that require a voter name, voter code, or grouping answers return typed states for the client; collecting those values remains Phase 2 work. + +## `GET /api/v2/results.php?key=ballot-shortcode` + +Returns the ballot's candidate IDs and anonymous ranked votes for local RCV +calculation. The endpoint enforces `resultsRelease` before returning any vote +data and responds with `results_not_released` while results are private. diff --git a/src/api/v2/results.php b/src/api/v2/results.php new file mode 100644 index 0000000..3459d22 --- /dev/null +++ b/src/api/v2/results.php @@ -0,0 +1,80 @@ + $data, 'error' => $error]); + exit; +} + +function resultsFail(int $status, string $code, string $message): void +{ + resultsRespond($status, null, ['code' => $code, 'message' => $message]); +} + +if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] !== 'GET') { + resultsFail(405, 'method_not_allowed', 'Use GET to load election results.'); +} + +$key = isset($_GET['key']) && is_string($_GET['key']) ? trim($_GET['key']) : ''; +if ($key === '') { + resultsFail(422, 'validation_failed', 'A ballot shortcode is required.'); +} + +$ballotStatement = $dbh->prepare( + 'SELECT id, name, positions, tieBreak, resultsRelease FROM ballots WHERE `key` = :key LIMIT 1' +); +$ballotStatement->bindValue(':key', $key, PDO::PARAM_STR); +$ballotStatement->execute(); +$ballot = $ballotStatement->fetch(PDO::FETCH_ASSOC); + +if (!$ballot) { + resultsFail(404, 'ballot_not_found', 'The ballot could not be found.'); +} + +if ($ballot['resultsRelease'] !== null && $ballot['resultsRelease'] > gmdate('Y-m-d H:i:s')) { + resultsFail(403, 'results_not_released', 'Results have not been released for this ballot.'); +} + +$ballotId = (int) $ballot['id']; +$entryStatement = $dbh->prepare( + 'SELECT entry_id, name FROM entries WHERE ballotId = :ballotId ORDER BY entry_id ASC' +); +$entryStatement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT); +$entryStatement->execute(); +$entries = array_map( + fn (array $entry): array => ['id' => (int) $entry['entry_id'], 'name' => (string) $entry['name']], + $entryStatement->fetchAll(PDO::FETCH_ASSOC) +); + +$voteStatement = $dbh->prepare( + 'SELECT voteIds FROM votes WHERE ballotId = :ballotId ORDER BY vote_id ASC' +); +$voteStatement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT); +$voteStatement->execute(); +$validIds = array_fill_keys(array_column($entries, 'id'), true); +$votes = []; +foreach ($voteStatement->fetchAll(PDO::FETCH_COLUMN) as $voteIds) { + $ranking = array_values(array_filter( + array_map('intval', explode(',', (string) $voteIds)), + fn (int $id): bool => isset($validIds[$id]) + )); + if ($ranking !== []) { + $votes[] = $ranking; + } +} + +resultsRespond(200, [ + 'ballot' => [ + 'key' => $key, + 'name' => (string) $ballot['name'], + 'positions' => (int) $ballot['positions'], + 'tieBreak' => $ballot['tieBreak'] === 'random' ? 'random' : 'weighted', + ], + 'candidates' => $entries, + 'votes' => $votes, +], null); diff --git a/test/php/V2ResultsTest.php b/test/php/V2ResultsTest.php new file mode 100644 index 0000000..9306c0a --- /dev/null +++ b/test/php/V2ResultsTest.php @@ -0,0 +1,51 @@ +seedBallot([ + 'key' => $key, + 'name' => 'Favorite fruit', + 'positions' => 2, + 'tieBreak' => 'weighted', + 'resultsRelease' => '2000-01-01 00:00:00', + ]); + $entryIds = $this->seedEntries($ballotId, ['Apple', 'Pear']); + $this->seedVote($ballotId, '["Pear","Apple"]', implode(',', array_reverse($entryIds))); + + $result = $this->callApi('v2/results.php', [], ['key' => $key]); + + $this->assertNull($result['body']['error']); + $this->assertSame('Favorite fruit', $result['body']['data']['ballot']['name']); + $this->assertSame(2, $result['body']['data']['ballot']['positions']); + $this->assertSame('weighted', $result['body']['data']['ballot']['tieBreak']); + $this->assertSame($entryIds, array_column($result['body']['data']['candidates'], 'id')); + $this->assertSame([array_reverse($entryIds)], $result['body']['data']['votes']); + } + + public function testDoesNotExposeUnreleasedResults(): void + { + $key = 'hidden-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key, 'resultsRelease' => '2099-01-01 00:00:00']); + $entryIds = $this->seedEntries($ballotId, ['Private candidate']); + $this->seedVote($ballotId, '["Private candidate"]', implode(',', $entryIds)); + + $result = $this->callApi('v2/results.php', [], ['key' => $key]); + + $this->assertSame('results_not_released', $result['body']['error']['code']); + $this->assertNull($result['body']['data']); + } + + public function testReturnsTypedValidationAndNotFoundErrors(): void + { + $missingKey = $this->callApi('v2/results.php'); + $unknownBallot = $this->callApi('v2/results.php', [], ['key' => 'missing']); + + $this->assertSame('validation_failed', $missingKey['body']['error']['code']); + $this->assertSame('ballot_not_found', $unknownBallot['body']['error']['code']); + } +}