10 · Capstone — Ship a Production-Ready App¶
The capstone brings the whole course together. You'll take one app — the habit tracker you've grown since Level 1, or an app of your own with similar scope — and make it a genuine release candidate: something you could submit to both stores tomorrow with confidence. The deliverable isn't a new feature; it's the evidence that the app is ready.
The app¶
Habits — a habit tracker with:
- Tabs: Today, Stats, Settings (Expo Router, L2-02).
- Habits and check-ins in SQLite with migrations (L2-06); streaks computed by the tested pure functions from L1-10.
- Add/edit habit form with Zod validation (L2-07) in a modal route.
- Daily reminder with a proper permission flow (L3-03, L3-04).
- Swipe-to-complete with haptics and an accessible alternative (L3-06, L3-07).
- Celebration animation that respects Reduce Motion (L3-05).
- Optional sync to a server using the Field Notes sync engine (L3-10) — choose whether your RC includes it, and say so in the README.
If you'd rather build something else, keep the same ingredients: multiple routes, local persistence with migrations, a form, a permission, a notification or device API, a list that can grow large, and an animation.
Part 1 — architecture review¶
Write a one-page ARCHITECTURE.md answering:
- Where does each kind of state live? (L2-04's table: local UI, shared client, server, navigation, persisted.) Name the actual hooks/stores/tables.
- What are the data flows for "check off a habit" from tap to SQLite to every screen that shows it?
- What happens offline? Every screen: works, degrades (how?), or unavailable (why?).
- What's the navigation tree? A Mermaid diagram like L2-01's, including modals and where auth would go.
Reviewers (including you in six months) should be able to understand the app from this page.
Part 2 — the quality gates¶
Tests¶
- Unit tests for all pure logic: streaks (including month/year boundaries and DST), reducers, schemas, sync policy if included.
- Component tests (RNTL 14, L4-03) for: the add-habit form's validation; a habit row's role/name/checked state; the empty state; one data screen's loading and error states.
- One or two E2E flows (L4-04): add a habit → complete it → relaunch → still completed; and the reminder permission-denied path.
Run npx jest --coverage and look at what's not covered: is any untested branch something that
could lose user data? Test that first.
Accessibility¶
Complete the L3-07 exercise for the whole app with VoiceOver and TalkBack, at the largest text size,
with Reduce Motion on. Record issues and fixes in ACCESSIBILITY.md.
Performance¶
Seed 3 years of history (about 1,100 days × 6 habits) through a debug-only "seed data" button, make a preview/release build, and on the slowest device available:
- Cold start to interactive Today screen — measure with a stopwatch or a simple timestamp log.
- Scroll the history log (FlashList, L4-02) — watch the perf monitor.
- Type in any search box — no input lag.
Record what you measured, on which device and build type, in PERFORMANCE.md. Real numbers from your
device only; if you couldn't test on Android, write that down.
Security and privacy¶
- Run
npx expo export --platform androidandgrep -athe bundle for anything secret (L4-05). - Deep links and notification payloads go through
safeInternalPath. PRIVACY.mdlists every piece of data the app stores or sends, matching your App Privacy / Data Safety answers (L4-07) and your analyticsEventMap(L4-09).
Part 3 — release engineering¶
app.config.tswith development and production variants (L3-09).eas.jsonwith development, preview and production profiles; remote app versions with auto-increment (L4-06).runtimeVersion: { policy: 'fingerprint' }andexpo-updatesconfigured with channels (L4-08); the in-app update banner.- Sentry (or equivalent) with source maps for builds and updates, release tags, privacy scrubbing
(L4-09). Route-level
ErrorBoundaryon every screen that loads data. RELEASE.mdrunbook (L4-07) — and actually walk through it once with a preview build.
Part 4 — an automated preflight check¶
Some release mistakes are mechanical and easy to catch with a script that runs in CI before every production build. This one reads your resolved Expo config and fails on the problems this course has warned about:
// Usage: node scripts/preflight.mjs (run from the project root)
import { execFileSync } from 'node:child_process';
const config = JSON.parse(
execFileSync('npx', ['expo', 'config', '--json', '--type', 'public'], { encoding: 'utf8' }),
);
const problems = [];
const warn = [];
if (!/^\d+\.\d+\.\d+$/.test(config.version ?? '')) problems.push(`version "${config.version}" is not MAJOR.MINOR.PATCH`);
if (!config.ios?.bundleIdentifier) problems.push('ios.bundleIdentifier is missing');
if (!config.android?.package) problems.push('android.package is missing');
if (!config.scheme) warn.push('no URL scheme: deep links and some auth flows will not work');
const rv = config.runtimeVersion;
if (!rv) warn.push('runtimeVersion is not set: OTA updates are not configured');
else if (typeof rv === 'object' && rv.policy !== 'fingerprint') warn.push(`runtimeVersion policy "${rv.policy}": remember to bump it for every native change`);
// Permission strings: plugins that need one must have a specific, non-default sentence.
const PERMISSION_OPTIONS = { 'expo-camera': 'cameraPermission', 'expo-location': 'locationWhenInUsePermission' };
for (const entry of config.plugins ?? []) {
const [name, options = {}] = Array.isArray(entry) ? entry : [entry];
const key = PERMISSION_OPTIONS[name];
if (!key) continue;
const text = options[key];
if (!text) problems.push(`${name}: no ${key} string (the library default is generic)`);
else if (text.length < 30 || !/\b(so|to)\b/i.test(text)) warn.push(`${name}: "${text}" may be too vague for App Review`);
}
// Public env vars whose names suggest secrets.
for (const name of Object.keys(process.env)) {
if (name.startsWith('EXPO_PUBLIC_') && /SECRET|PRIVATE|PASSWORD|TOKEN/i.test(name)) {
problems.push(`${name} looks like a secret but EXPO_PUBLIC_ values ship inside the app bundle`);
}
}
for (const w of warn) console.log(`warn ${w}`);
for (const p of problems) console.log(`FAIL ${p}`);
console.log(problems.length ? `\n${problems.length} problem(s) found.` : '\npreflight passed.');
process.exit(problems.length ? 1 : 0);
npx expo config --json --type public prints the fully resolved config — after app.config.ts logic
and plugins — so the script checks what will actually be built. This course ran the script against the
small Field Notes config from L3-09 (which has no runtimeVersion, and whose app.json had been
given an expo-location plugin with the short string "Tag notes with a place."), with a deliberately
badly named variable set:
$ EXPO_PUBLIC_STRIPE_SECRET=x node scripts/preflight.mjs
warn runtimeVersion is not set: OTA updates are not configured
warn expo-location: "Tag notes with a place." may be too vague for App Review
FAIL EXPO_PUBLIC_STRIPE_SECRET looks like a secret but EXPO_PUBLIC_ values ship inside the app bundle
1 problem(s) found.
The exit code was 1, so a CI step running it would stop the release. Extend it with your own checks:
minimum target SDK, required ErrorBoundary exports, presence of RELEASE.md.
Deliverables¶
- The app's repository with
ARCHITECTURE.md,ACCESSIBILITY.md,PERFORMANCE.md,PRIVACY.md,RELEASE.mdand a README explaining how to run it, test it and build it. - Passing unit/component tests and at least one passing E2E flow (or a clear note on why you couldn't run E2E and what you'd need).
preflight.mjspassing.- A preview build installed on at least one real device, with your measurements.
- A short release-readiness memo: what you're confident about, what you didn't verify, and what you'd do before submitting. Honesty here is the skill — "I couldn't test on iOS" is a perfectly good sentence; a fabricated "tested on all devices" is not.
How It Actually Works¶
A release candidate is a claim: "this exact binary and bundle behave correctly for real users." Each part of the capstone gathers evidence for one aspect of that claim. Unit and component tests check logic and UI behaviour in Node, fast and deterministic. E2E tests check that the compiled binary — the native modules, permissions, storage and navigation together — works on a real OS. Accessibility and performance checks run on devices because screen readers, text scaling and frame budgets only exist there. Preflight checks the build configuration itself, because many production incidents in mobile apps come from configuration (a missing permission string, a wrong runtime version) rather than code. Monitoring closes the loop after release, turning real-world failures into new tests.
Common mistakes¶
- Polishing features instead of gathering evidence — the capstone is about readiness.
- Measuring performance on a development build or only on a flagship phone.
- Tests that only cover the happy path — the bugs that hurt users are in error and offline paths.
- Privacy docs that don't match what the code sends.
- Overclaiming in the readiness memo.
Exercise¶
Complete the capstone. Then, as a final reflection, list three things you'd do differently if you started the app again today — about architecture, testing or release — and one thing from this course you'll carry into every future mobile project.