Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/src/api/v2-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
110 changes: 82 additions & 28 deletions apps/mobile/src/api/v2-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -78,6 +91,26 @@ export class V2ApiClient {
this.fetchImpl = fetchImpl;
}

async getResults(key: string, signal?: AbortSignal): Promise<ElectionResults> {
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<SubmitVoteResult> {
let response: Response;
try {
Expand All @@ -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;
Expand All @@ -141,4 +148,51 @@ export class V2ApiClient {
replayed: data.replayed,
};
}

private async parseEnvelope(response: Response): Promise<Record<string, unknown>> {
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'),
)
);
}
2 changes: 2 additions & 0 deletions apps/mobile/src/app/ballot/[key]/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -128,6 +129,7 @@ export default function BallotScreen() {
) : null}

<VoteSubmission ballot={ballot} onAccepted={() => setVoteAccepted(true)} ranking={ranking} />
{voteAccepted ? <ElectionResults ballotKey={ballot.key} /> : null}
</View>
</ScrollView>
);
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/components/election-results.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ElectionResultsView result={result} voteCount={4} />);

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');
});
});
Loading
Loading