A production-grade React Native reference app.
One TypeScript codebase for iOS, Android and web: shared screens and state, an accessible UI library, one typed API contract and a zero-copy native camera module.
It started as a take-home brief. I built it as I would build a production app.
![]() |
![]() |
![]() |
![]() |
| Account Balance, form, last 5 |
Native receipt photo Pixels shared, not copied |
Refused debit Reference ID and server trace |
Market service down Saved prices, dark theme |
All documents in this repository use ASD-STE100 Simplified Technical English. See docs/STE-WRITING-RULES.md.
Short on time? The Reviewer guide is a 10-minute tour: where to look first, the trade-offs and the evidence.
- Highlights
- Screenshots
- Architecture
- Engineering decisions and trade-offs
- Quality gates
- Known limits and next steps
- Quick start
- Reference: features, start the apps, tests, HTTP API, troubleshooting
Ledger records credits and debits, shows the balance and the last 5 transactions, and shows mock stock prices from a slow server that fails at random. The features are small on purpose. The engineering around them is the point.
| Area | What the code does | Read |
|---|---|---|
| One contract | One Zod schema gives the TypeScript types, the server validation, the client validation and the OpenAPI 3.1 document. CI fails when the document is old. | ADR 0007 |
| Correct money | All calculations use integer cents. The server refuses a negative balance and replays the ledger at start-up to check each balance. | ADR 0005 |
| Safe retries | Each write has an idempotency key. A lost response, a retry or a second tap cannot record a debit two times, also after a server restart. | ADR 0011 |
| Bad-network resilience | React Query retries with backoff, debounces the search and falls back to saved prices with a clear message. The mock server adds random delay and random failure. | MarketsScreen |
| Native where it matters | A Nitro module (Swift, Kotlin) decodes the receipt photo one time into native memory. JavaScript reads it as an ArrayBuffer: no copy, no base64. Measured: 4.7 MB shared, native decode 3 ms, JS analysis 2 ms. |
ADR 0008, NATIVE-CAMERA |
| End-to-end tracing | Each user action gets one W3C trace ID. The UI shows it as a Reference ID. Support uses it to find the server steps and the stored record. | ADR 0009, TRACING |
| Accessible by default | WCAG 2.1 AA. Each component maps to VoiceOver, TalkBack and ARIA. Tests check the contrast of each color token pair in both themes. ESLint stops raw primitives in screens. | ACCESSIBILITY |
| One UI, three platforms | iOS, Android and web use the same screens, the same Redux store and the same UI library (react-native-web). Only the platform services (camera, image picker) are platform code, behind an interface. | ADR 0006 |
| Failure containment | An error boundary for each tab and one for the app. The fallback shows a Reference ID. Error reports keep action types only, never form values or amounts. | ADR 0012 |
| AI with guardrails | An AI agent wrote code under rules (CLAUDE.md), two project skills and automated gates. A person reviewed each change. | AI-WORKFLOW |
![]() |
![]() |
![]() |
| Account, dark theme | Last 5 transactions, newest first | Live prices, refresh every 30 seconds |
![]() |
![]() |
| Storybook with axe-core: 0 violations | OpenAPI 3.1, generated from Zod |
flowchart LR
subgraph apps
mobile["apps/mobile<br/>Expo, React Native"]
web["apps/web<br/>Vite, react-native-web"]
server["apps/server<br/>Express 5"]
end
subgraph packages
contracts["@ledger/contracts<br/>Zod schemas, types, OpenAPI"]
core["@ledger/core<br/>API client, Redux, React Query"]
ui["@ledger/ui<br/>tokens, accessible components"]
features["@ledger/features<br/>Account and Markets screens"]
camera["@ledger/native-camera<br/>Nitro: Swift, Kotlin"]
end
mobile --> camera
mobile --> features
web --> features
features --> core
features --> ui
core --> contracts
server --> contracts
apps/
mobile/ Expo React Native app (iOS, Android). Jest tests and Detox tests.
web/ React web app (Vite + react-native-web). Same screens as mobile.
server/ Node.js mock API (Express 5, Zod, OpenAPI 3.1, Swagger UI).
packages/
contracts/ Zod schemas, TypeScript types, OpenAPI document builder. Server and clients use it.
core/ Platform-independent logic: API client, Redux store, React Query hooks, formatting.
ui/ Design system: tokens, accessible components, Storybook.
features/ Shared screens: Account, Markets. Mobile and web use them.
native-camera/ Nitro native module (Swift, Kotlin): camera, shared pixel memory, PhotoView.
docs/ Plan, architecture, accessibility, decisions (ADR).
.claude/ AI skills and settings.
The rules that keep this structure:
- An app can use a package. A package cannot use an app.
coredoes not importreact-native, so its logic runs in plain Node.js tests.uidoes not know about transactions or the API.- Screens do not import native modules. They use the
PlatformServicesinterface, and each app gives its implementation. - Packages have no build step. Each package exports
src/index.ts, so "go to definition" opens the real source.
Read docs/ARCHITECTURE.md for state ownership and the data flow.
Each decision has an Architecture Decision Record (ADR) with the context, the alternatives and the consequences.
| Decision | Why | What it costs | ADR |
|---|---|---|---|
pnpm monorepo, apps/ and packages/, packages as TypeScript source |
Three apps share one contract and one set of screens. Copies would become different over time. | Each bundler must compile workspace TypeScript. | 0001 |
pnpm nodeLinker: hoisted |
jest-expo, Detox and some native scripts find modules by a walk up the folder tree. | Less strict dependency isolation. | 0002 |
| Redux Toolkit for account data, React Query for stock data | Each data type has one owner. A write changes two views; quotes are server cache data. | Two state tools to learn. | 0003 |
Only 200 and 500, with a closed list of error.code values |
The brief allows only these two statuses. The code carries the meaning. | Clients cannot use the HTTP status. | 0004 |
| Integer cents for all calculations | 0.1 + 0.2 is not 0.3 in binary floating point. |
A conversion at each boundary. | 0005 |
| One UI library for mobile and web with react-native-web | One implementation, one set of tests, one Storybook. | Web-only ARIA needs a small adapter (webOnlyProps). |
0006 |
| Zod as the single source of truth for the API | A hand-written OpenAPI document and the code become different. | The OpenAPI document is generated, not written by hand. | 0007 |
Nitro module with a shared ArrayBuffer for the receipt photo |
The usual base64 path copies the image several times and decodes it two times. | Swift and Kotlin code to maintain. | 0008 |
| W3C Trace Context from the thunk to the ledger file | Support must connect a user report to the server steps of that one action. | One header and one trace log. | 0009 |
Local JSON file behind a LedgerRepository interface |
Data stays after a restart. Atomic writes and an integrity check at start-up. | One process only. A database replaces it in production. | 0010 |
| Idempotency key for each write | A duplicate debit is a serious error. A mobile network can lose a response. | One more header and a key index on the server. | 0011 |
| Error boundaries and a pluggable error reporter without personal data | One render error must not stop the full app. Reports must not leak amounts or titles. | No real crash service in the showcase (setErrorReporter is the hook). |
0012 |
React Compiler on mobile and web, checked by pnpm compiler:report |
Manual memoization is easy to forget and easy to do incorrectly. | 4 functions had to change to compile. | 0013 |
pnpm verify runs the first five gates. CI runs them on each push and pull request, and also builds Storybook and the web app. The Detox job runs on a macOS runner for each push to main.
| Gate | What it proves | Command |
|---|---|---|
| TypeScript, strict mode | All packages typecheck against the installed library types. | pnpm typecheck |
| ESLint | Includes a rule that stops raw Text, Pressable and TextInput in screens. |
pnpm lint |
| 212 unit tests | Rules and screens, on 3 renderers: Node.js, react-native-web (DOM) and the React Native test renderer. | pnpm test |
| OpenAPI check | apps/server/openapi.json agrees with the Zod schemas. |
pnpm openapi:check |
| React Compiler report | The compiler compiles all 34 components. No bail-outs. | pnpm compiler:report |
| 8 Detox tests (iOS) | The release build operates end to end, also the camera module and the trace of a refused debit. | pnpm e2e:test:ios |
Other evidence: the contrast test checks each text and background token pair in both themes; the idempotency test also runs after a server restart; the error-report test makes sure that a report has no form values. See the Reviewer guide.
| Known limit | Next step |
|---|---|
| No authentication. There is one local account. | OIDC with PKCE. Tokens in the Keychain and the Android Keystore. |
| The data is a JSON file for one process. | A database behind the existing LedgerRepository interface. |
| No offline mode. | A persisted write queue. It sends with the existing idempotency keys. |
No Android end-to-end tests. iOS Detox runs on main only. |
Detox on Android, and iOS Detox on each pull request. |
| No real crash service. | Connect Sentry or Crashlytics with setErrorReporter. |
| No release pipeline. | EAS build profiles and over-the-air updates with a staged rollout. |
docs/SECURITY-PRIVACY.md gives the data inventory, the threats and the production checklist.
| Tool | Version | Necessary for |
|---|---|---|
| Node.js | 22.12 or later (24 recommended) | All |
| pnpm | 12 (corepack enable) |
All |
| Xcode and an iOS simulator | Xcode 26 or later | iOS app, iOS Detox tests |
| Android Studio, an emulator | API 35 or later | Android app |
| applesimutils | latest | iOS Detox tests only |
The web app needs only Node.js and pnpm. Run these commands from the repository root:
corepack enable # gives the correct pnpm version
pnpm install
pnpm dev:server # terminal 1: API on http://localhost:4000, Swagger UI on /docs
pnpm dev:web # terminal 2: web app on http://localhost:5173
pnpm verify # the first five quality gatesFor the iOS and Android apps, Storybook and the Detox tests, read Start the apps.
The sections below are the full setup and reference material.
| Requirement | Where |
|---|---|
| Record one transaction (date/time, amount, type, description) | packages/features/src/account/TransactionForm.tsx |
| Refuse a transaction that makes the balance negative | apps/server/src/domain/ledger.ts (server rule), error shown in the form banner |
| Show the account balance | packages/features/src/account/BalanceCard.tsx |
| Show the last 5 transactions | packages/features/src/account/RecentTransactions.tsx |
| HTTP API: read balance, read last 5, write one, only 200 or 500 | apps/server/src/app.ts, OpenAPI document |
| Title, description and image for expenses | NewTransaction schema in packages/contracts/src/schemas.ts |
| Global state (Redux Toolkit) | packages/core/src/store/ |
| Stock data with React Query (slow, fail, retry, debounce, fallback) | packages/core/src/stocks/, packages/features/src/markets/MarketsScreen.tsx |
| Shared UI library for web and mobile, with Storybook | packages/ui/ |
| OpenAPI document, validated with Zod | packages/contracts/src/openapi.ts, apps/server/openapi.json |
| Unit tests and Detox tests | **/test/, apps/mobile/__tests__/, apps/mobile/e2e/ |
| WCAG 2.1 AA (Section 504) | docs/ACCESSIBILITY.md: conformance table, VoiceOver and TalkBack test procedure |
Two AI skills: add or change a packages/ui component; WCAG 2.1 AA review of a screen |
.claude/skills/shared-ui-library, .claude/skills/accessibility-review, docs/AI-WORKFLOW.md |
| Take a receipt photo with a native Nitro module; share pixels with JS through an ArrayBuffer | packages/native-camera, docs/NATIVE-CAMERA.md |
| Trace one user request end to end (Reference ID) | docs/TRACING.md |
| Keep the data in a local JSON file, with showcase data | apps/server/data/, docs/DATA-STORAGE.md |
Start the API first. The web app and the mobile app use it.
pnpm dev:server| URL | Content |
|---|---|
| http://localhost:4000/api/v1 | API |
| http://localhost:4000/docs | Swagger UI |
| http://localhost:4000/openapi.json | OpenAPI 3.1 document |
You can change the server behavior with environment variables:
| Variable | Default | Effect |
|---|---|---|
PORT |
4000 |
HTTP port |
CHAOS_FAILURE_RATE |
0.3 |
Probability (0 to 1) that a stock request fails |
CHAOS_MIN_LATENCY_MS |
150 |
Minimum delay of a stock request |
CHAOS_MAX_LATENCY_MS |
2500 |
Maximum delay of a stock request |
INITIAL_BALANCE |
0 |
Start balance in USD |
LOG_REQUESTS |
true |
Write one log line for each request |
DATA_FILE |
apps/server/data/ledger.json |
The ledger data file (JSON) |
SEED_ON_FIRST_START |
true |
Copy data/seed.json when the data file does not exist |
TRACE_FILE |
apps/server/data/traces.jsonl |
Request trace log (JSON Lines) |
Example: make every stock request fail, to see the fallback data.
CHAOS_FAILURE_RATE=1 pnpm dev:serverNOTE: The server keeps the ledger in
apps/server/data/ledger.json. At the first start it copies 13 showcase transactions fromdata/seed.json. To start again from the showcase data, runpnpm --filter @ledger/server data:reset. Read docs/DATA-STORAGE.md.
pnpm dev:webOpen http://localhost:5173. To use a different API, set VITE_API_URL.
The app uses native modules (the image picker and the Nitro camera module in packages/native-camera), so it needs a development build. Expo Go is not sufficient.
On the iOS simulator there is no camera. The "Take photo" button then gives a generated sample receipt, so you can see the native preview and the shared-memory details. On a device or on the Android emulator, the system camera opens.
-
Start the API (step 2.1).
-
Build and start the iOS app on a simulator.
pnpm --filter @ledger/mobile ios
-
Or build and start the Android app on an emulator.
pnpm --filter @ledger/mobile android
The app finds the API in this order:
- The
EXPO_PUBLIC_API_URLenvironment variable. - The host of the Metro server (for a physical device on the same network).
http://localhost:4000on iOS,http://10.0.2.2:4000on Android.
pnpm storybookOpen http://localhost:6006. Use the Theme toolbar to see the light and dark themes. Use the Accessibility panel to see the axe-core results for each story.
Run all unit tests:
pnpm test| Package | Runner | What the tests check |
|---|---|---|
packages/contracts |
Vitest | Zod rules, money helpers, the OpenAPI document is valid |
apps/server |
Vitest + Supertest | Ledger rules, all endpoints, 200/500 contract, chaos, idempotency |
packages/core |
Vitest + Testing Library | API client errors, Redux slices, safe write retries, error reports |
packages/ui |
Vitest + react-native-web | Roles, names and states on the web; color contrast of all theme tokens |
packages/features |
Vitest + react-native-web | Full Account flow: validation, credit, refused debit; error boundary |
apps/mobile |
Jest (jest-expo) + RNTL 14 | Native accessibility mapping (roles, states, hints) of the same screens |
packages/native-camera |
Vitest | Pixel analysis on an RGBA ArrayBuffer (row padding, sampling, luminance) |
For the other checks, read Quality gates.
The Detox tests start the mock API themselves. The API uses no chaos and an empty ledger.
CAUTION: Stop
pnpm dev:serverbefore you run the Detox tests. The tests need port 4000 and an empty ledger.
-
Install applesimutils.
brew tap wix/brew && brew install wix/brew/applesimutils -
Build the release app. This step generates the
iosfolder and installs the pods.pnpm e2e:build:ios
-
Run the tests.
pnpm e2e:test:ios
The configuration uses an "iPhone 17 Pro" simulator. To use a different simulator, change devices.simulator in apps/mobile/.detoxrc.json.
Last result: 8 of 8 tests passed on 2026-10-15 (iPhone 17 Pro, iOS 26.5, release build). See docs/REVIEWER-GUIDE.md, section 4.1.
The OpenAPI document is the reference. Swagger UI shows it at http://localhost:4000/docs.
| Method | Path | Result |
|---|---|---|
| GET | /api/v1/balance |
Current balance |
| GET | /api/v1/transactions?limit=5 |
Last transactions, newest first |
| POST | /api/v1/transactions |
Records one transaction, sends new balance |
| GET | /api/v1/stocks/quotes?symbols=AAPL |
Mock quotes (random delay and failure) |
| GET | /api/v1/stocks/search?q=app |
Stock search (random delay and failure) |
| GET | /api/v1/health |
Service status |
| GET | /api/v1/traces/{traceId} |
Server events of one user action (diagnostics) |
The assignment allows only "200 OK" and "500 Internal Server Error".
For this reason, every error uses status 500 and a body with a stable error.code:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The transaction is refused. The balance cannot go below $0.00. Available balance: $20.00."
}
}error.code |
Cause | Client retries? |
|---|---|---|
INSUFFICIENT_FUNDS |
The debit makes the balance less than 0 | No |
IDEMPOTENCY_KEY_REUSED |
The idempotency-key belongs to a transaction with different data |
No |
VALIDATION_FAILED |
The request data is not correct (details has the fields) |
No |
UPSTREAM_UNAVAILABLE |
Random failure of the mock market service | Yes |
INTERNAL_ERROR |
Unexpected error | Yes (writes too, with the same idempotency key) |
Example request:
curl -X POST http://localhost:4000/api/v1/transactions \
-H 'Content-Type: application/json' \
-H 'idempotency-key: 6f1c2a3e-8b4d-4f0a-9c1e-2d3b4a5c6d7e' \
-d '{"type":"credit","amount":120.50,"title":"Salary","description":"October"}'Send the same command again. The server does not record a second transaction. It sends the first transaction and the header idempotent-replayed: true. Read ADR 0011.
| Problem | Cause | Action |
|---|---|---|
| The mobile app shows "The app cannot connect to the server." | The API does not operate, or the device cannot reach it | Start pnpm dev:server. On a physical device, set EXPO_PUBLIC_API_URL=http://<computer-LAN-IP>:4000. |
| Detox stops with "A server already uses port 4000." | pnpm dev:server operates |
Stop the dev server. Then run the tests again. |
| The iOS build fails after a dependency change | The generated ios folder is old |
Run pnpm --filter @ledger/mobile prebuild. Then build again. |
| The server stops with "The data file … is not valid" | ledger.json was changed by hand, or its balances do not agree |
Correct the file, or run pnpm --filter @ledger/server data:reset. |
| The web app has no "Take photo" button | The web app has no native camera module | This is correct. Use "Add image". |
| A user gives a Reference ID | — | Follow the procedure in docs/TRACING.md, section 6. |
| pnpm shows "Ignored build scripts" | pnpm blocks unknown install scripts | Add the package to allowBuilds in pnpm-workspace.yaml only if it is necessary. |
MIT. Read LICENSE.










