diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a11024c..28f9438 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,18 +1,45 @@ # Ranked Choices mobile -Isolated Expo SDK 57 and TypeScript scaffold for the Ranked Choices migration. -The app currently proves routing, responsive layout, and local development -tooling. PHP connectivity and ballot rendering are layered in the next stacked -PR. +Phase 0 of the Ranked Choices Expo migration. This app currently provides a +shortcode lookup and read-only ballot preview backed by the existing PHP API. +It does not submit votes or authenticate users yet. ## Get started -```bash -npm install -npm start -``` +1. Install dependencies: + + ```bash + npm install + ``` + +2. Start the PHP API from the repository root in another terminal: + + ```bash + cd src + php -S 0.0.0.0:2461 + ``` + +3. Configure the API URL when needed: + + - iOS simulator defaults to `http://127.0.0.1:2461/api`. + - Android Emulator defaults to `http://10.0.2.2:2461/api`. + - For a physical device, copy `.env.example` to `.env.local`, replace the + host with the computer's LAN IP, and ensure both devices are on the same + network. + + API-backed ballot lookup on Expo web is deferred. The web app can render and + export, but browser requests to the PHP server on port 2461 require either a + same-origin development proxy or an explicit API CORS policy. Setting + `EXPO_PUBLIC_API_BASE_URL` to the PHP URL does not bypass that browser rule. -The terminal provides shortcuts for iOS, Android, and web. +4. Start the app: + + ```bash + npm start + ``` + +The terminal provides shortcuts for iOS, Android, and web. Incoming-link tests +should use a development build; Expo Go has limited linking support. ## Checks @@ -22,12 +49,28 @@ npm run typecheck npm run lint ``` +## Configuration + +`EXPO_PUBLIC_API_BASE_URL` must point to the directory containing the PHP API +scripts and should not end with a slash. Public Expo variables are embedded in +the client bundle, so never put credentials or secrets in them. + +Phase 0 supports API connectivity from iOS and Android. Expo-web API +connectivity will be designed alongside the later web deployment decision. + ## Current scope - Expo Router and TypeScript scaffold -- shortcode lookup and dynamic ballot route -- responsive native/web layout -- unit-test, typecheck, lint, and static-export commands +- development API base URL selection +- typed normalization of the legacy `get-candidates.php` response +- ballot lookup and read-only candidate display +- loading, closed, not-found, malformed-response, and network-error handling + +Voting, authentication, production deployment, and domain association files +are intentionally deferred to later RFC phases. + +## Expo resources -Voting, API integration, authentication, production deployment, and domain -association files are intentionally deferred. +- [Expo documentation](https://docs.expo.dev/) +- [Expo Router](https://docs.expo.dev/router/introduction/) +- [Development builds](https://docs.expo.dev/develop/development-builds/introduction/) diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts new file mode 100644 index 0000000..522dd3a --- /dev/null +++ b/apps/mobile/src/api/client.ts @@ -0,0 +1,7 @@ +import { getApiBaseUrl } from '@/config/api'; + +import { LegacyApiClient } from './legacy-api'; + +export function createLegacyApiClient(): LegacyApiClient { + return new LegacyApiClient({ baseUrl: getApiBaseUrl() }); +} diff --git a/apps/mobile/src/api/legacy-api.test.ts b/apps/mobile/src/api/legacy-api.test.ts new file mode 100644 index 0000000..a6a3059 --- /dev/null +++ b/apps/mobile/src/api/legacy-api.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LegacyApiClient, LegacyApiError, normalizeBallotDetail } from './legacy-api'; + +const legacyPayload = { + ballot: { + id: '42', + key: 'pizza night', + name: 'Pizza Night', + positions: '2', + register: '0', + resultsRelease: null, + voteCutoff: '2099-01-01 00:00:00', + hideNames: '1', + hideDetails: '0', + allowCustom: '0', + showGraph: '1', + kickbackUrl: null, + iframeUrl: '', + oneDeviceOneVote: '0', + isSecure: '1', + orderedEntries: '0', + allowGrouping: '1', + createdBy: 'guest', + }, + candidates: [ + { entry_id: '7', candidate: 'Mushroom', image: '', hyperlink: '', color: 'abcdef' }, + { entry_id: '8', candidate: 'Pepperoni', image: '', hyperlink: '', color: null }, + ], + groupFields: [ + { + id: '3', + title: 'Neighborhood', + question_text: 'Where do you live?', + type: 'select', + required: '1', + sort_order: '0', + options: [{ id: '9', label: 'North', sort_order: '0' }], + }, + ], +}; + +describe('normalizeBallotDetail', () => { + it('normalizes PDO string values into a native-friendly model', () => { + const detail = normalizeBallotDetail(legacyPayload); + + expect(detail.ballot).toMatchObject({ + id: 42, + positions: 2, + hideNames: true, + hideDetails: false, + showGraph: true, + isSecure: true, + allowGrouping: true, + iframeUrl: null, + }); + expect(detail.candidates[0]).toEqual({ + id: 7, + name: 'Mushroom', + image: '', + hyperlink: '', + color: 'abcdef', + }); + expect(detail.groupFields[0]).toMatchObject({ + id: 3, + type: 'select', + required: true, + sortOrder: 0, + options: [{ id: 9, label: 'North', sortOrder: 0 }], + }); + }); + + it('rejects malformed candidates at the compatibility seam', () => { + expect(() => + normalizeBallotDetail({ ...legacyPayload, candidates: [{ candidate: 'Missing ID' }] }), + ).toThrowError(LegacyApiError); + }); +}); + +describe('LegacyApiClient.getBallot', () => { + it('encodes the shortcode and returns normalized data', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify(legacyPayload))); + const client = new LegacyApiClient({ + baseUrl: 'https://example.test/api/', + fetchImpl, + now: () => 1234, + }); + + const detail = await client.getBallot(' pizza night '); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://example.test/api/get-candidates.php?key=pizza%20night&t=1234', + { signal: undefined }, + ); + expect(detail.ballot.key).toBe('pizza night'); + }); + + it('maps the legacy text not-found response to a stable error code', async () => { + const client = new LegacyApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => new Response('Shortcode not found.'), + }); + + await expect(client.getBallot('missing')).rejects.toMatchObject({ code: 'not_found' }); + }); + + it('preserves the results release when voting is closed', async () => { + const client = new LegacyApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response(JSON.stringify({ status: 'closed', resultsRelease: '2099-02-03 04:05:06' })), + }); + + await expect(client.getBallot('closed')).rejects.toMatchObject({ + code: 'closed', + details: { resultsRelease: '2099-02-03 04:05:06' }, + }); + }); + + it('maps fetch failures without exposing transport details', async () => { + const client = new LegacyApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => { + throw new Error('socket details'); + }, + }); + + await expect(client.getBallot('pizza')).rejects.toMatchObject({ code: 'network' }); + }); +}); diff --git a/apps/mobile/src/api/legacy-api.ts b/apps/mobile/src/api/legacy-api.ts new file mode 100644 index 0000000..8e00a4e --- /dev/null +++ b/apps/mobile/src/api/legacy-api.ts @@ -0,0 +1,255 @@ +export type Ballot = { + id: number; + key: string; + name: string; + positions: number; + register: number; + resultsRelease: string | null; + voteCutoff: string | null; + hideNames: boolean; + hideDetails: boolean; + allowCustom: boolean; + showGraph: boolean; + kickbackUrl: string | null; + iframeUrl: string | null; + oneDeviceOneVote: boolean; + isSecure: boolean; + orderedEntries: boolean; + allowGrouping: boolean; + createdBy: string; +}; + +export type Candidate = { + id: number; + name: string; + image: string; + hyperlink: string; + color: string | null; +}; + +export type GroupOption = { + id: number; + label: string; + sortOrder: number; +}; + +export type GroupField = { + id: number; + title: string; + questionText: string; + type: 'select' | 'checkbox' | 'text'; + required: boolean; + sortOrder: number; + options: GroupOption[]; +}; + +export type BallotDetail = { + ballot: Ballot; + candidates: Candidate[]; + groupFields: GroupField[]; +}; + +export type LegacyApiErrorCode = + | 'invalid_shortcode' + | 'not_found' + | 'unavailable' + | 'closed' + | 'network' + | 'http' + | 'malformed_response'; + +export class LegacyApiError extends Error { + constructor( + public readonly code: LegacyApiErrorCode, + message: string, + public readonly details?: { resultsRelease?: string | null; status?: number }, + ) { + super(message); + this.name = 'LegacyApiError'; + } +} + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +type LegacyApiClientOptions = { + baseUrl: string; + fetchImpl?: FetchLike; + now?: () => number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function malformed(field?: string): LegacyApiError { + const suffix = field ? `: ${field}` : ''; + return new LegacyApiError('malformed_response', `The ballot server returned invalid data${suffix}.`); +} + +function asString(value: unknown, field: string): string { + if (typeof value !== 'string' && typeof value !== 'number') { + throw malformed(field); + } + return String(value); +} + +function asNullableString(value: unknown, field: string): string | null { + if (value === null || value === undefined || value === '') { + return null; + } + return asString(value, field); +} + +function asNumber(value: unknown, field: string): number { + const number = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(number)) { + throw malformed(field); + } + return number; +} + +function asBoolean(value: unknown, field: string): boolean { + if (value === true || value === 1 || value === '1') return true; + if (value === false || value === 0 || value === '0' || value === null) return false; + throw malformed(field); +} + +function normalizeBallot(value: unknown): Ballot { + if (!isRecord(value)) throw malformed('ballot'); + + return { + id: asNumber(value.id, 'ballot.id'), + key: asString(value.key, 'ballot.key'), + name: asString(value.name, 'ballot.name'), + positions: asNumber(value.positions, 'ballot.positions'), + register: asNumber(value.register ?? 0, 'ballot.register'), + resultsRelease: asNullableString(value.resultsRelease, 'ballot.resultsRelease'), + voteCutoff: asNullableString(value.voteCutoff, 'ballot.voteCutoff'), + hideNames: asBoolean(value.hideNames ?? 0, 'ballot.hideNames'), + hideDetails: asBoolean(value.hideDetails ?? 0, 'ballot.hideDetails'), + allowCustom: asBoolean(value.allowCustom ?? 0, 'ballot.allowCustom'), + showGraph: asBoolean(value.showGraph ?? 0, 'ballot.showGraph'), + kickbackUrl: asNullableString(value.kickbackUrl, 'ballot.kickbackUrl'), + iframeUrl: asNullableString(value.iframeUrl, 'ballot.iframeUrl'), + oneDeviceOneVote: asBoolean(value.oneDeviceOneVote ?? 0, 'ballot.oneDeviceOneVote'), + isSecure: asBoolean(value.isSecure ?? 0, 'ballot.isSecure'), + orderedEntries: asBoolean(value.orderedEntries ?? 0, 'ballot.orderedEntries'), + allowGrouping: asBoolean(value.allowGrouping ?? 0, 'ballot.allowGrouping'), + createdBy: asString(value.createdBy, 'ballot.createdBy'), + }; +} + +function normalizeCandidate(value: unknown): Candidate { + if (!isRecord(value)) throw malformed('candidate'); + + return { + id: asNumber(value.entry_id, 'candidate.entry_id'), + name: asString(value.candidate, 'candidate.candidate'), + image: asString(value.image ?? '', 'candidate.image'), + hyperlink: asString(value.hyperlink ?? '', 'candidate.hyperlink'), + color: asNullableString(value.color, 'candidate.color'), + }; +} + +function normalizeGroupOption(value: unknown): GroupOption { + if (!isRecord(value)) throw malformed('groupField.option'); + return { + id: asNumber(value.id, 'groupField.option.id'), + label: asString(value.label, 'groupField.option.label'), + sortOrder: asNumber(value.sort_order ?? 0, 'groupField.option.sort_order'), + }; +} + +function normalizeGroupField(value: unknown): GroupField { + if (!isRecord(value)) throw malformed('groupField'); + const type = asString(value.type ?? 'select', 'groupField.type'); + if (type !== 'select' && type !== 'checkbox' && type !== 'text') { + throw malformed('groupField.type'); + } + if (value.options !== undefined && !Array.isArray(value.options)) { + throw malformed('groupField.options'); + } + + return { + id: asNumber(value.id, 'groupField.id'), + title: asString(value.title ?? '', 'groupField.title'), + questionText: asString(value.question_text ?? '', 'groupField.question_text'), + type, + required: asBoolean(value.required ?? 0, 'groupField.required'), + sortOrder: asNumber(value.sort_order ?? 0, 'groupField.sort_order'), + options: (value.options ?? []).map(normalizeGroupOption), + }; +} + +export function normalizeBallotDetail(value: unknown): BallotDetail { + if (!isRecord(value)) throw malformed(); + if (!Array.isArray(value.candidates)) throw malformed('candidates'); + if (value.groupFields !== undefined && !Array.isArray(value.groupFields)) { + throw malformed('groupFields'); + } + + return { + ballot: normalizeBallot(value.ballot), + candidates: value.candidates.map(normalizeCandidate), + groupFields: (value.groupFields ?? []).map(normalizeGroupField), + }; +} + +export class LegacyApiClient { + private readonly baseUrl: string; + private readonly fetchImpl: FetchLike; + private readonly now: () => number; + + constructor(options: LegacyApiClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ''); + this.fetchImpl = options.fetchImpl ?? fetch; + this.now = options.now ?? Date.now; + } + + async getBallot(key: string, signal?: AbortSignal): Promise { + const shortcode = key.trim(); + if (!shortcode) { + throw new LegacyApiError('invalid_shortcode', 'Enter a ballot shortcode.'); + } + + let response: Response; + try { + const url = `${this.baseUrl}/get-candidates.php?key=${encodeURIComponent(shortcode)}&t=${this.now()}`; + response = await this.fetchImpl(url, { signal }); + } catch (error) { + if (error instanceof LegacyApiError || (error instanceof Error && error.name === 'AbortError')) { + throw error; + } + throw new LegacyApiError('network', 'Could not reach the ballot server. Check your connection.'); + } + + if (!response.ok) { + throw new LegacyApiError('http', 'The ballot server could not complete the request.', { + status: response.status, + }); + } + + const raw = await response.text(); + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch { + const message = raw.trim(); + if (message === 'Shortcode not found.') { + throw new LegacyApiError('not_found', 'No ballot was found for that shortcode.'); + } + if (message === 'This ballot has no candidates and cannot accept votes.') { + throw new LegacyApiError('unavailable', message); + } + throw malformed(); + } + + if (isRecord(payload) && payload.status === 'closed') { + throw new LegacyApiError('closed', 'Voting has closed for this ballot.', { + resultsRelease: asNullableString(payload.resultsRelease, 'resultsRelease'), + }); + } + + return normalizeBallotDetail(payload); + } +} diff --git a/apps/mobile/src/app/ballot/[key]/index.tsx b/apps/mobile/src/app/ballot/[key]/index.tsx index 978c0ee..8c71ad5 100644 --- a/apps/mobile/src/app/ballot/[key]/index.tsx +++ b/apps/mobile/src/app/ballot/[key]/index.tsx @@ -1,57 +1,210 @@ +import { createLegacyApiClient } from '@/api/client'; +import { LegacyApiError, type BallotDetail } from '@/api/legacy-api'; import { useLocalSearchParams } from 'expo-router'; -import { StyleSheet, Text, View } from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -export default function BallotPlaceholderScreen() { +type LoadState = + | { status: 'loading'; key: string } + | { status: 'loaded'; key: string; detail: BallotDetail } + | { status: 'error'; key: string; error: LegacyApiError }; + +function unknownError(): LegacyApiError { + return new LegacyApiError('network', 'The ballot could not be loaded.'); +} + +export default function BallotScreen() { const params = useLocalSearchParams<{ key?: string | string[] }>(); const key = Array.isArray(params.key) ? params.key[0] : params.key ?? ''; + const client = useMemo(() => createLegacyApiClient(), []); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState({ status: 'loading', key }); + + useEffect(() => { + const controller = new AbortController(); + + client.getBallot(key, controller.signal).then( + (detail) => setState({ status: 'loaded', key, detail }), + (error: unknown) => { + if (error instanceof Error && error.name === 'AbortError') return; + setState({ + status: 'error', + key, + error: error instanceof LegacyApiError ? error : unknownError(), + }); + }, + ); + + return () => controller.abort(); + }, [attempt, client, key]); + + const retry = () => { + setState({ status: 'loading', key }); + setAttempt((value) => value + 1); + }; + + if (state.status === 'loading' || state.key !== key) { + return ( + + + Loading ballot… + + ); + } + + if (state.status === 'error') { + const canRetry = state.error.code === 'network' || state.error.code === 'http'; + return ( + + + Ballot unavailable + {state.error.message} + {state.error.details?.resultsRelease ? ( + Results release: {state.error.details.resultsRelease} + ) : null} + {canRetry ? ( + [styles.retryButton, pressed && styles.buttonPressed]}> + Try again + + ) : null} + + + ); + } + + const { ballot, candidates, groupFields } = state.detail; return ( - - - EXPO SCAFFOLD - Ballot route ready - - Shortcode: {key || 'none'} - {'\n\n'}The typed PHP adapter and read-only ballot display are intentionally layered in - the next stacked PR. + + + READ-ONLY BALLOT PREVIEW + {ballot.name} + Shortcode: {ballot.key} + + + + {candidates.length} + choices + + + {ballot.positions} + {ballot.positions === 1 ? 'seat' : 'seats'} + + {ballot.isSecure ? ( + + Code + required + + ) : null} + + + Candidates + + Candidate ordering is shown for connectivity testing. Ranking is not enabled in this + scaffold. + + {candidates.map((candidate, index) => ( + + + {index + 1} + + {candidate.name} + + ))} + + + {groupFields.length ? ( + + This ballot has {groupFields.length} voter question{groupFields.length === 1 ? '' : 's'}. + Questions will be enabled with the voting milestone. + + ) : null} - + ); } const styles = StyleSheet.create({ - screen: { + screen: { flex: 1, backgroundColor: '#f5f7fa' }, + scrollContent: { padding: 22 }, + content: { width: '100%', maxWidth: 680, alignSelf: 'center' }, + centered: { alignItems: 'center', backgroundColor: '#f5f7fa', flex: 1, justifyContent: 'center', padding: 24, }, - card: { + loadingText: { color: '#40556b', fontSize: 16, marginTop: 12 }, + eyebrow: { color: '#b24c00', fontSize: 12, fontWeight: '800', letterSpacing: 1.2 }, + title: { color: '#12355b', fontSize: 32, fontWeight: '800', lineHeight: 38, marginTop: 8 }, + shortcode: { color: '#52697f', fontSize: 15, marginTop: 6 }, + metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 22 }, + metaCard: { + backgroundColor: '#e8f2ed', + borderRadius: 12, + minWidth: 92, + paddingHorizontal: 14, + paddingVertical: 12, + }, + metaValue: { color: '#125435', fontSize: 18, fontWeight: '800' }, + 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 }, + candidateList: { gap: 10, marginTop: 16 }, + candidateRow: { + alignItems: 'center', backgroundColor: '#ffffff', + borderColor: '#d9e0e7', + borderRadius: 14, + borderWidth: 1, + flexDirection: 'row', + padding: 14, + }, + rankBadge: { + alignItems: 'center', + backgroundColor: '#12355b', borderRadius: 18, - maxWidth: 520, - padding: 24, - width: '100%', + height: 36, + justifyContent: 'center', + marginRight: 13, + width: 36, }, - eyebrow: { - color: '#b24c00', - fontSize: 12, - fontWeight: '800', - letterSpacing: 1.2, + rankText: { color: '#ffffff', fontSize: 15, fontWeight: '800' }, + candidateName: { color: '#1f3143', flex: 1, fontSize: 17, fontWeight: '700' }, + notice: { + backgroundColor: '#fff3dc', + borderRadius: 12, + color: '#6b4600', + fontSize: 14, + lineHeight: 20, + marginTop: 20, + padding: 14, }, - title: { - color: '#12355b', - fontSize: 28, - fontWeight: '800', - marginTop: 10, + errorCard: { + backgroundColor: '#ffffff', + borderRadius: 18, + maxWidth: 480, + padding: 24, + width: '100%', }, - description: { - color: '#40556b', - fontSize: 16, - lineHeight: 24, - marginTop: 12, + errorTitle: { color: '#81261f', fontSize: 24, fontWeight: '800' }, + errorText: { color: '#4e3b39', fontSize: 16, lineHeight: 23, marginTop: 10 }, + errorMeta: { color: '#6a5754', fontSize: 13, marginTop: 10 }, + retryButton: { + alignItems: 'center', + alignSelf: 'flex-start', + backgroundColor: '#146c43', + borderRadius: 10, + marginTop: 18, + paddingHorizontal: 18, + paddingVertical: 12, }, + retryText: { color: '#ffffff', fontSize: 15, fontWeight: '800' }, + buttonPressed: { opacity: 0.8 }, }); diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts new file mode 100644 index 0000000..49d61b0 --- /dev/null +++ b/apps/mobile/src/config/api.ts @@ -0,0 +1,15 @@ +import { Platform } from 'react-native'; + +const configuredBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL?.trim(); + +export function getApiBaseUrl(): string { + if (configuredBaseUrl) { + return configuredBaseUrl.replace(/\/$/, ''); + } + + if (Platform.OS === 'android') { + return 'http://10.0.2.2:2461/api'; + } + + return 'http://127.0.0.1:2461/api'; +} diff --git a/apps/mobile/vitest.config.mts b/apps/mobile/vitest.config.mts index 8d90142..c68ee2f 100644 --- a/apps/mobile/vitest.config.mts +++ b/apps/mobile/vitest.config.mts @@ -10,6 +10,6 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/utils/**/*.test.ts'], + include: ['src/**/*.test.ts'], }, });