diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 9f22cf4..33d470b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -2,7 +2,8 @@ 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. It does not submit votes or authenticate users yet. +by the existing PHP API. Anonymous ballots can be submitted through the typed, +idempotent v2 vote endpoint. The app does not authenticate users yet. ## Get started @@ -65,10 +66,15 @@ connectivity will be designed alongside the later web deployment decision. - typed normalization of the legacy `get-candidates.php` response - ballot lookup and accessible local candidate ranking - move-up, move-down, remove, and reset controls +- idempotent anonymous vote submission with loading, retry, duplicate-device, + cutoff, and accepted states - loading, closed, not-found, malformed-response, and network-error handling -Vote submission, authentication, production deployment, and domain association -files are intentionally deferred to later slices. +Name-required ballots, secure-code entry, grouping questions, authentication, +production deployment, and domain association files remain unavailable. The +first three belong to the later secure-voting/ballot-creation phase; this Phase +1 client surfaces them as explicit unsupported states instead of submitting an +incomplete vote. ## Expo resources diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index e20cac7..160156e 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -8,8 +8,10 @@ "name": "@rankedchoices/mobile", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", + "expo-crypto": "~57.0.1", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", "expo-splash-screen": "~57.0.5", @@ -2525,6 +2527,18 @@ } } }, + "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", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -6296,6 +6310,15 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz", + "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.2.tgz", @@ -7855,6 +7878,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -8771,6 +8803,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 920c8f7..9efb316 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,8 +3,10 @@ "main": "expo-router/entry", "version": "1.0.0", "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", + "expo-crypto": "~57.0.1", "expo-linking": "~57.0.5", "expo-router": "~57.0.11", "expo-splash-screen": "~57.0.5", diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts index 522dd3a..e6b86a6 100644 --- a/apps/mobile/src/api/client.ts +++ b/apps/mobile/src/api/client.ts @@ -1,7 +1,12 @@ import { getApiBaseUrl } from '@/config/api'; import { LegacyApiClient } from './legacy-api'; +import { V2ApiClient } from './v2-api'; export function createLegacyApiClient(): LegacyApiClient { return new LegacyApiClient({ baseUrl: getApiBaseUrl() }); } + +export function createV2ApiClient(): V2ApiClient { + return new V2ApiClient({ baseUrl: getApiBaseUrl() }); +} diff --git a/apps/mobile/src/api/v2-api.test.ts b/apps/mobile/src/api/v2-api.test.ts new file mode 100644 index 0000000..5d6a94b --- /dev/null +++ b/apps/mobile/src/api/v2-api.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { V2ApiClient, V2ApiError } from './v2-api'; + +const request = { + key: 'pizza', + requestId: '12345678-1234-4234-8234-123456789012', + ranking: [3, 1, 2], + fingerprint: 'installation-id', +}; + +describe('V2ApiClient.submitVote', () => { + it('submits typed rankings and returns the accepted response', async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + data: { status: 'accepted', voteId: 42, replayed: false }, + error: null, + }), + { status: 201 }, + ), + ); + const client = new V2ApiClient({ baseUrl: 'https://example.test/api/', fetchImpl }); + + await expect(client.submitVote(request)).resolves.toEqual({ + status: 'accepted', + voteId: 42, + replayed: false, + }); + expect(fetchImpl).toHaveBeenCalledWith('https://example.test/api/v2/votes.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: undefined, + }); + }); + + it('preserves typed duplicate errors', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: null, + error: { code: 'duplicate_device', message: 'Already voted.' }, + }), + { status: 409 }, + ), + }); + + await expect(client.submitVote(request)).rejects.toMatchObject({ + code: 'duplicate_device', + retryable: false, + status: 409, + }); + }); + + it('marks transport and server errors as retryable', async () => { + const networkClient = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => { + throw new Error('offline'); + }, + }); + const serverClient = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: null, + error: { code: 'server_error', message: 'Try later.' }, + }), + { status: 500 }, + ), + }); + + await expect(networkClient.submitVote(request)).rejects.toMatchObject({ + code: 'network', + retryable: true, + }); + await expect(serverClient.submitVote(request)).rejects.toMatchObject({ + code: 'server_error', + retryable: true, + }); + }); + + it('rejects malformed envelopes at the compatibility seam', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => new Response(JSON.stringify({ ok: true })), + }); + + await expect(client.submitVote(request)).rejects.toBeInstanceOf(V2ApiError); + await expect(client.submitVote(request)).rejects.toMatchObject({ code: 'malformed_response' }); + }); +}); diff --git a/apps/mobile/src/api/v2-api.ts b/apps/mobile/src/api/v2-api.ts new file mode 100644 index 0000000..2e0b2b1 --- /dev/null +++ b/apps/mobile/src/api/v2-api.ts @@ -0,0 +1,144 @@ +export type SubmitVoteRequest = { + key: string; + requestId: string; + ranking: number[]; + fingerprint?: string; +}; + +export type SubmitVoteResult = { + status: 'accepted'; + voteId: number; + replayed: boolean; +}; + +export type V2ApiErrorCode = + | 'validation_failed' + | 'ballot_not_found' + | 'idempotency_conflict' + | 'voting_closed' + | 'voter_name_required' + | 'secure_code_required' + | 'group_answers_required' + | 'fingerprint_required' + | 'duplicate_device' + | 'invalid_ranking' + | 'server_error' + | 'network' + | 'http' + | 'malformed_response'; + +export class V2ApiError extends Error { + constructor( + public readonly code: V2ApiErrorCode, + message: string, + public readonly retryable = false, + public readonly status?: number, + ) { + super(message); + this.name = 'V2ApiError'; + } +} + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +type V2ApiClientOptions = { + baseUrl: string; + fetchImpl?: FetchLike; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isKnownErrorCode(value: unknown): value is V2ApiErrorCode { + return ( + typeof value === 'string' && + [ + 'validation_failed', + 'ballot_not_found', + 'idempotency_conflict', + 'voting_closed', + 'voter_name_required', + 'secure_code_required', + 'group_answers_required', + 'fingerprint_required', + 'duplicate_device', + 'invalid_ranking', + 'server_error', + ].includes(value) + ); +} + +export class V2ApiClient { + private readonly baseUrl: string; + private readonly fetchImpl: FetchLike; + + constructor({ baseUrl, fetchImpl = fetch }: V2ApiClientOptions) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.fetchImpl = fetchImpl; + } + + async submitVote(request: SubmitVoteRequest, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await this.fetchImpl(`${this.baseUrl}/v2/votes.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error; + 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.'); + } + + 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, + ); + } + + const data = envelope.data; + if ( + !response.ok || + !isRecord(data) || + data.status !== 'accepted' || + typeof data.voteId !== 'number' || + typeof data.replayed !== 'boolean' + ) { + throw new V2ApiError('malformed_response', 'The vote server returned invalid success data.'); + } + + return { + status: 'accepted', + voteId: data.voteId, + replayed: data.replayed, + }; + } +} diff --git a/apps/mobile/src/app/ballot/[key]/index.tsx b/apps/mobile/src/app/ballot/[key]/index.tsx index 97ed9bd..506a9b4 100644 --- a/apps/mobile/src/app/ballot/[key]/index.tsx +++ b/apps/mobile/src/app/ballot/[key]/index.tsx @@ -1,6 +1,8 @@ import { createLegacyApiClient } from '@/api/client'; -import { LegacyApiError, type BallotDetail } from '@/api/legacy-api'; +import { LegacyApiError, type BallotDetail, type Candidate } from '@/api/legacy-api'; import { CandidateRanking } from '@/components/candidate-ranking'; +import { VoteSubmission } from '@/components/vote-submission'; +import { createRanking } from '@/features/ranking'; import { useLocalSearchParams } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; @@ -21,12 +23,18 @@ export default function BallotScreen() { const client = useMemo(() => createLegacyApiClient(), []); const [attempt, setAttempt] = useState(0); const [state, setState] = useState({ status: 'loading', key }); + const [ranking, setRanking] = useState([]); + const [voteAccepted, setVoteAccepted] = useState(false); useEffect(() => { const controller = new AbortController(); client.getBallot(key, controller.signal).then( - (detail) => setState({ status: 'loaded', key, detail }), + (detail) => { + setRanking(createRanking(detail.candidates, detail.ballot.orderedEntries)); + setVoteAccepted(false); + setState({ status: 'loaded', key, detail }); + }, (error: unknown) => { if (error instanceof Error && error.name === 'AbortError') return; setState({ @@ -77,7 +85,7 @@ export default function BallotScreen() { ); } - const { ballot, candidates, groupFields } = state.detail; + const { ballot, candidates } = state.detail; return ( @@ -103,19 +111,23 @@ export default function BallotScreen() { ) : null} - Your ranking - - Put your favorite choice first. Use the controls to move or remove choices; you can reset - the ballot at any time. - - - - {groupFields.length ? ( - - This ballot has {groupFields.length} voter question{groupFields.length === 1 ? '' : 's'}. - Questions will be enabled with the voting milestone. - + {!voteAccepted ? ( + <> + Your ranking + + Put your favorite choice first. Use the controls to move or remove choices; you can + reset the ballot at any time. + + + ) : null} + + setVoteAccepted(true)} ranking={ranking} /> ); @@ -148,15 +160,6 @@ const styles = StyleSheet.create({ metaLabel: { color: '#436251', fontSize: 12, marginTop: 2 }, sectionTitle: { color: '#1f3143', fontSize: 22, fontWeight: '800', marginTop: 30 }, helpText: { color: '#52697f', fontSize: 14, lineHeight: 20, marginTop: 6 }, - notice: { - backgroundColor: '#fff3dc', - borderRadius: 12, - color: '#6b4600', - fontSize: 14, - lineHeight: 20, - marginTop: 20, - padding: 14, - }, errorCard: { backgroundColor: '#ffffff', borderRadius: 18, diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 80054c7..f4bc512 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -35,11 +35,11 @@ export default function HomeScreen() { style={styles.container}> - PHASE 0 PREVIEW + PHASE 1 Open a ranked-choice ballot - Enter the shortcode from a Ranked Choices ballot. This preview is read-only; ranking - and vote submission arrive in the next migration slice. + Enter the shortcode from a Ranked Choices ballot to rank choices and submit an + anonymous vote. Ballot shortcode diff --git a/apps/mobile/src/components/candidate-ranking.test.tsx b/apps/mobile/src/components/candidate-ranking.test.tsx index 76d3c04..467954e 100644 --- a/apps/mobile/src/components/candidate-ranking.test.tsx +++ b/apps/mobile/src/components/candidate-ranking.test.tsx @@ -13,7 +13,12 @@ const candidates: Candidate[] = [ describe('CandidateRanking', () => { it('renders ranked candidates with accessible actions and boundary states', () => { const html = renderToStaticMarkup( - , + undefined} + orderedEntries={true} + ranking={candidates} + />, ); expect(html).toContain('2 choices ranked'); diff --git a/apps/mobile/src/components/candidate-ranking.tsx b/apps/mobile/src/components/candidate-ranking.tsx index 2b70ead..a0c10e5 100644 --- a/apps/mobile/src/components/candidate-ranking.tsx +++ b/apps/mobile/src/components/candidate-ranking.tsx @@ -1,17 +1,21 @@ import type { Candidate } from '@/api/legacy-api'; import { createRanking, moveCandidate, removeCandidate } from '@/features/ranking'; -import { useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; type CandidateRankingProps = { candidates: readonly Candidate[]; + onChange: (ranking: Candidate[]) => void; orderedEntries: boolean; + ranking: readonly Candidate[]; }; -export function CandidateRanking({ candidates, orderedEntries }: CandidateRankingProps) { - const [ranking, setRanking] = useState(() => createRanking(candidates, orderedEntries)); - - const reset = () => setRanking(createRanking(candidates, orderedEntries)); +export function CandidateRanking({ + candidates, + onChange, + orderedEntries, + ranking, +}: CandidateRankingProps) { + const reset = () => onChange(createRanking(candidates, orderedEntries)); return ( @@ -35,19 +39,19 @@ export function CandidateRanking({ candidates, orderedEntries }: CandidateRankin disabled={moveUpDisabled} label="Up" accessibilityLabel={`Move ${candidate.name} up`} - onPress={() => setRanking((current) => moveCandidate(current, candidate.id, 'up'))} + onPress={() => onChange(moveCandidate(ranking, candidate.id, 'up'))} /> setRanking((current) => moveCandidate(current, candidate.id, 'down'))} + onPress={() => onChange(moveCandidate(ranking, candidate.id, 'down'))} /> setRanking((current) => removeCandidate(current, candidate.id))} + onPress={() => onChange(removeCandidate(ranking, candidate.id))} /> diff --git a/apps/mobile/src/components/vote-submission.tsx b/apps/mobile/src/components/vote-submission.tsx new file mode 100644 index 0000000..a1b908d --- /dev/null +++ b/apps/mobile/src/components/vote-submission.tsx @@ -0,0 +1,198 @@ +import { createV2ApiClient } from '@/api/client'; +import type { Ballot, Candidate } from '@/api/legacy-api'; +import { V2ApiError } from '@/api/v2-api'; +import { + blockerMessage, + formatCutoffCountdown, + getOrCreateVoteRequest, + getVoteBlocker, + type PendingVoteRequest, +} from '@/features/vote-submission'; +import { loadInstallationId } from '@/utils/installation-id'; +import * as Crypto from 'expo-crypto'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; + +type SubmissionState = + | { status: 'idle'; rankingKey: string } + | { status: 'submitting'; rankingKey: string } + | { status: 'accepted'; rankingKey: string; replayed: boolean; voteId: number } + | { status: 'error'; rankingKey: string; error: V2ApiError }; + +type VoteSubmissionProps = { + ballot: Ballot; + onAccepted: () => void; + ranking: readonly Candidate[]; +}; + +export function VoteSubmission({ ballot, onAccepted, ranking }: VoteSubmissionProps) { + const client = useMemo(() => createV2ApiClient(), []); + const [now, setNow] = useState(() => Date.now()); + const [request, setRequest] = useState(null); + const rankingKey = ranking.map((candidate) => candidate.id).join(','); + const [state, setState] = useState({ status: 'idle', rankingKey }); + const currentState: SubmissionState = + state.rankingKey === rankingKey ? state : { status: 'idle', rankingKey }; + + useEffect(() => { + if (!ballot.voteCutoff) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [ballot.voteCutoff]); + + const blocker = getVoteBlocker(ballot, ranking.length, now); + const cutoffMessage = formatCutoffCountdown(ballot.voteCutoff, now); + + const submit = async () => { + if (blocker || currentState.status === 'submitting' || currentState.status === 'accepted') return; + + const activeRequest = getOrCreateVoteRequest(request, rankingKey, Crypto.randomUUID); + const activeRequestId = activeRequest.requestId; + setRequest(activeRequest); + setState({ status: 'submitting', rankingKey }); + + try { + const fingerprint = ballot.oneDeviceOneVote ? await loadInstallationId() : undefined; + const result = await client.submitVote({ + key: ballot.key, + requestId: activeRequestId, + ranking: ranking.map((candidate) => candidate.id), + fingerprint, + }); + setState({ + status: 'accepted', + rankingKey, + replayed: result.replayed, + voteId: result.voteId, + }); + onAccepted(); + } catch (error) { + setState({ + status: 'error', + rankingKey, + error: + error instanceof V2ApiError + ? error + : new V2ApiError('fingerprint_required', 'The device identifier could not be loaded.', true), + }); + } + }; + + if (currentState.status === 'accepted') { + return ( + + Vote recorded + + Thank you. Your vote was {currentState.replayed ? 'confirmed again safely' : 'accepted'}. + + + ); + } + + return ( + + {cutoffMessage ? ( + + {cutoffMessage} + + ) : null} + + {blocker ? ( + + {blockerMessage(blocker)} + + ) : null} + + {currentState.status === 'error' ? ( + + + {currentState.error.code === 'duplicate_device' ? 'Already voted' : 'Vote not recorded'} + + {currentState.error.message} + {currentState.error.retryable ? ( + [styles.retryButton, pressed && styles.buttonPressed]}> + Try again + + ) : null} + + ) : null} + + [ + styles.submitButton, + (blocker !== null || currentState.status === 'submitting') && styles.submitDisabled, + pressed && currentState.status !== 'submitting' && styles.buttonPressed, + ]}> + {currentState.status === 'submitting' ? ( + + + Submitting… + + ) : ( + Submit vote + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { marginTop: 22 }, + cutoffNotice: { + backgroundColor: '#fff3dc', + borderRadius: 10, + color: '#6b4600', + fontSize: 14, + marginBottom: 12, + padding: 12, + }, + blockerNotice: { + backgroundColor: '#f4eceb', + borderRadius: 10, + color: '#6f2822', + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + padding: 12, + }, + errorCard: { backgroundColor: '#fff1ef', borderRadius: 12, marginBottom: 12, padding: 14 }, + errorTitle: { color: '#81261f', fontSize: 17, fontWeight: '800' }, + errorText: { color: '#5f3a36', fontSize: 14, lineHeight: 20, marginTop: 5 }, + retryButton: { + alignSelf: 'flex-start', + backgroundColor: '#81261f', + borderRadius: 8, + marginTop: 12, + minHeight: 42, + justifyContent: 'center', + paddingHorizontal: 14, + paddingVertical: 9, + }, + retryText: { color: '#ffffff', fontSize: 14, fontWeight: '800' }, + submitButton: { + alignItems: 'center', + backgroundColor: '#146c43', + borderRadius: 12, + minHeight: 50, + justifyContent: 'center', + paddingHorizontal: 20, + paddingVertical: 13, + }, + submitDisabled: { backgroundColor: '#879891' }, + submitText: { color: '#ffffff', fontSize: 16, fontWeight: '800' }, + submittingRow: { alignItems: 'center', flexDirection: 'row', gap: 10 }, + buttonPressed: { opacity: 0.75 }, + successCard: { backgroundColor: '#e8f2ed', borderRadius: 14, marginTop: 22, padding: 18 }, + successTitle: { color: '#125435', fontSize: 21, fontWeight: '800' }, + successText: { color: '#365c48', fontSize: 15, lineHeight: 21, marginTop: 6 }, +}); diff --git a/apps/mobile/src/features/vote-submission.test.ts b/apps/mobile/src/features/vote-submission.test.ts new file mode 100644 index 0000000..0bae95c --- /dev/null +++ b/apps/mobile/src/features/vote-submission.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import type { Ballot } from '@/api/legacy-api'; + +import { + blockerMessage, + formatCutoffCountdown, + getOrCreateVoteRequest, + getVoteBlocker, + parseUtcTimestamp, +} from './vote-submission'; + +const ballot: Ballot = { + id: 1, + key: 'pizza', + name: 'Pizza', + positions: 1, + register: 0, + resultsRelease: null, + voteCutoff: null, + hideNames: false, + hideDetails: false, + allowCustom: false, + showGraph: false, + kickbackUrl: null, + iframeUrl: null, + oneDeviceOneVote: false, + isSecure: false, + orderedEntries: true, + allowGrouping: false, + createdBy: 'guest', +}; + +describe('vote submission states', () => { + it('reuses a request ID only while retrying the same ranking', () => { + const pending = { rankingKey: '1,2', requestId: 'original-id' }; + + expect(getOrCreateVoteRequest(pending, '1,2', () => 'new-id')).toBe(pending); + expect(getOrCreateVoteRequest(pending, '2,1', () => 'new-id')).toEqual({ + rankingKey: '2,1', + requestId: 'new-id', + }); + }); + + it('parses legacy database timestamps as UTC', () => { + expect(parseUtcTimestamp('2026-08-16 12:00:00')).toBe(Date.parse('2026-08-16T12:00:00Z')); + expect(parseUtcTimestamp(null)).toBeNull(); + }); + + it('blocks cutoff, name, secure, grouped, and empty-ranking states', () => { + const now = Date.parse('2026-08-16T12:00:00Z'); + + expect(getVoteBlocker({ ...ballot, voteCutoff: '2026-08-16 11:59:59' }, 2, now)).toBe('closed'); + expect(getVoteBlocker({ ...ballot, register: 1 }, 2, now)).toBe('voter_name_required'); + expect(getVoteBlocker({ ...ballot, isSecure: true }, 2, now)).toBe('secure_code_required'); + expect(getVoteBlocker({ ...ballot, allowGrouping: true }, 2, now)).toBe('group_answers_required'); + expect(getVoteBlocker(ballot, 0, now)).toBe('empty_ranking'); + expect(getVoteBlocker(ballot, 2, now)).toBeNull(); + }); + + it('formats the final five-minute cutoff warning', () => { + const now = Date.parse('2026-08-16T12:00:00Z'); + + expect(formatCutoffCountdown('2026-08-16 12:04:09', now)).toBe('Voting closes in 4:09.'); + expect(formatCutoffCountdown('2026-08-16 13:00:00', now)).toBeNull(); + expect(formatCutoffCountdown('2026-08-16 11:59:59', now)).toBe('Voting is closed.'); + expect(blockerMessage('secure_code_required')).toContain('voter code'); + }); +}); diff --git a/apps/mobile/src/features/vote-submission.ts b/apps/mobile/src/features/vote-submission.ts new file mode 100644 index 0000000..0f4b196 --- /dev/null +++ b/apps/mobile/src/features/vote-submission.ts @@ -0,0 +1,71 @@ +import type { Ballot } from '@/api/legacy-api'; + +export type VoteBlocker = + | 'closed' + | 'empty_ranking' + | 'voter_name_required' + | 'secure_code_required' + | 'group_answers_required'; + +export type PendingVoteRequest = { + rankingKey: string; + requestId: string; +}; + +export function getOrCreateVoteRequest( + pending: PendingVoteRequest | null, + rankingKey: string, + createId: () => string, +): PendingVoteRequest { + return pending?.rankingKey === rankingKey ? pending : { rankingKey, requestId: createId() }; +} + +export function parseUtcTimestamp(value: string | null): number | null { + if (!value) return null; + const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value) + ? `${value.replace(' ', 'T')}Z` + : value; + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? timestamp : null; +} + +export function getVoteBlocker( + ballot: Ballot, + rankingCount: number, + now = Date.now(), +): VoteBlocker | null { + const cutoff = parseUtcTimestamp(ballot.voteCutoff); + if (cutoff !== null && now >= cutoff) return 'closed'; + if (ballot.register === 1) return 'voter_name_required'; + if (ballot.isSecure) return 'secure_code_required'; + if (ballot.allowGrouping) return 'group_answers_required'; + if (rankingCount === 0) return 'empty_ranking'; + return null; +} + +export function blockerMessage(blocker: VoteBlocker): string { + switch (blocker) { + case 'closed': + return 'Voting has closed for this ballot.'; + case 'empty_ranking': + return 'Rank at least one choice before submitting.'; + case 'voter_name_required': + return 'This ballot requires a voter name and is not available in the anonymous flow.'; + case 'secure_code_required': + return 'This ballot requires a voter code. Secure-code entry is not available in the anonymous flow.'; + case 'group_answers_required': + return 'This ballot requires voter questions that are not available in the anonymous flow.'; + } +} + +export function formatCutoffCountdown(cutoff: string | null, now = Date.now()): string | null { + const cutoffTime = parseUtcTimestamp(cutoff); + if (cutoffTime === null) return null; + const seconds = Math.max(0, Math.ceil((cutoffTime - now) / 1000)); + if (seconds === 0) return 'Voting is closed.'; + if (seconds > 300) return null; + + const minutes = Math.floor(seconds / 60); + const remainder = String(seconds % 60).padStart(2, '0'); + return `Voting closes in ${minutes}:${remainder}.`; +} diff --git a/apps/mobile/src/utils/installation-id-store.test.ts b/apps/mobile/src/utils/installation-id-store.test.ts new file mode 100644 index 0000000..38ae494 --- /dev/null +++ b/apps/mobile/src/utils/installation-id-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getOrCreateInstallationId, type StringStorage } from './installation-id-store'; + +describe('getOrCreateInstallationId', () => { + it('reuses the persisted installation identifier', async () => { + const storage: StringStorage = { + getItem: vi.fn(async () => 'persisted-id'), + setItem: vi.fn(async () => undefined), + }; + const createId = vi.fn(() => 'new-id'); + + await expect(getOrCreateInstallationId(storage, createId)).resolves.toBe('persisted-id'); + expect(createId).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it('creates and persists an identifier once when missing', async () => { + const storage: StringStorage = { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + }; + + await expect(getOrCreateInstallationId(storage, () => 'new-id')).resolves.toBe('new-id'); + expect(storage.setItem).toHaveBeenCalledWith('@rankedchoices/installation-id', 'new-id'); + }); +}); diff --git a/apps/mobile/src/utils/installation-id-store.ts b/apps/mobile/src/utils/installation-id-store.ts new file mode 100644 index 0000000..85248ed --- /dev/null +++ b/apps/mobile/src/utils/installation-id-store.ts @@ -0,0 +1,17 @@ +export type StringStorage = { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +}; + +export async function getOrCreateInstallationId( + storage: StringStorage, + createId: () => string, + storageKey = '@rankedchoices/installation-id', +): Promise { + const existing = await storage.getItem(storageKey); + if (existing) return existing; + + const created = createId(); + await storage.setItem(storageKey, created); + return created; +} diff --git a/apps/mobile/src/utils/installation-id.ts b/apps/mobile/src/utils/installation-id.ts new file mode 100644 index 0000000..98380fc --- /dev/null +++ b/apps/mobile/src/utils/installation-id.ts @@ -0,0 +1,8 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Crypto from 'expo-crypto'; + +import { getOrCreateInstallationId } from './installation-id-store'; + +export function loadInstallationId(): Promise { + return getOrCreateInstallationId(AsyncStorage, Crypto.randomUUID); +}