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
71 changes: 57 additions & 14 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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/)
7 changes: 7 additions & 0 deletions apps/mobile/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getApiBaseUrl } from '@/config/api';

import { LegacyApiClient } from './legacy-api';

export function createLegacyApiClient(): LegacyApiClient {
return new LegacyApiClient({ baseUrl: getApiBaseUrl() });
}
130 changes: 130 additions & 0 deletions apps/mobile/src/api/legacy-api.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading
Loading