What You Are Building
The spec for the capstone app: its five screens, its data model, its offline rules, and the decisions to make before writing a screen.
app/sign-in.tsx, app/(tabs)/feed.tsx, app/(tabs)/settings.tsx, app/entry/[id].tsx, app/capture.tsxStage 7 builds one app end to end. Call it Fieldlog. A signed-in user writes short entries, attaches a photo taken with the camera, reads the feed with no network, and receives a push notification when someone replies. None of the parts are new. This is the earlier stages assembled in the order a real project assembles them.
The screens
Five routes. sign-in sits outside the tabs. A tab group holds feed and settings. entry/[id] shows one entry. capture opens over the feed. That surface is deliberately small, because every screen has to work offline, on both platforms, with a measured cold start.
The data model
Three shapes: an Entry, a Feed page, and a Session. An entry carries a syncState, so an entry written offline can be drawn differently from one the server has acknowledged. Timestamps are ISO 8601 strings in UTC. They are parsed at the edges and never held as a Date in the cache, because the cache is written to disk as JSON and a Date does not survive that round trip.
Decide these first
The id type, the timestamp format, the paging cursor, and what offline means for each screen. Those four reach every route parameter, every cache key and every stored payload. Changing one later is not a refactor of one file. It invalidates data already sitting on installed devices, which no app update can reach in.
Example
// src/domain/entry.ts
// The data model for the whole app, plus the two rules the UI asks about.
// No React here, so this file is the cheapest part of the app to test.
export type EntryId = string;
export type Entry = {
id: EntryId;
title: string;
body: string;
// An absolute URL, or null while a capture is still uploading.
photoUrl: string | null;
// ISO 8601 in UTC, never a Date: the cache is serialised to disk.
createdAt: string;
authorId: string;
syncState: "synced" | "pending" | "failed";
};
export type Feed = {
entries: Entry[];
// The cursor for the next page, or null when the server has no more.
nextCursor: string | null;
};
// Readable offline means the text arrived. A missing photo is not a
// failure; the detail screen draws a placeholder in its place.
export function isReadableOffline(entry: Entry): boolean {
return entry.syncState !== "failed" && entry.body.length > 0;
}
// max = 60 is a default parameter. The longhand is:
// function preview(entry, max) { if (max === undefined) max = 60; ... }
export function preview(entry: Entry, max = 60): string {
const flat = entry.body.replace(/\s+/g, " ").trim();
if (flat.length <= max) return flat;
// Budget the three dots, so the result is never longer than max.
return flat.slice(0, max - 3) + "...";
}
const sample: Entry = {
id: "e_7312",
title: "North fence",
body: "Rain held off until four. The north fence is down in two places.",
photoUrl: null,
createdAt: "2026-03-04T09:12:00Z",
authorId: "u_1",
syncState: "pending",
};
console.log(isReadableOffline(sample));
// Output: true
console.log(preview(sample, 40));
// Output: Rain held off until four. The north f...When to use it
- Agreeing the screens and the field names with a backend before either side writes code.
- Deciding which screens must work with the network off, and what they show when a photo has not downloaded.
- Writing the domain rules and their tests on day one, before any native dependency is installed.
More examples
The API contract, written before the client
One file naming every request and response the app makes. It is worth writing before any screen because it is the only place both sides of the wire are visible at once, and because the test suite and the cache both key off these names. Reach for it the moment a second developer, or a backend, enters the picture.
// src/api/contracts.ts
// What the server promises. Writing this first means the screens and the
// backend cannot quietly disagree about a field name.
import type { Entry, Feed } from "../domain/entry";
export type SignInBody = { email: string; password: string };
export type SignInResponse = {
accessToken: string;
refreshToken: string;
// Seconds, not milliseconds, and not an absolute time.
expiresIn: number;
userId: string;
};
// GET /entries?cursor=
export type FeedResponse = Feed;
// GET /entries/:id
export type EntryResponse = Entry;
// POST /entries, multipart when a photo is attached
export type CreateEntryBody = { title: string; body: string };
// The data object on a push notification. Keep it small: the payload
// travels through Apple and Google and is not a place for content.
export type PushData = { entryId: string; kind: "reply" };Session rules as pure functions
Expiry arithmetic belongs in a pure function that takes the current time as an argument. Passing now in makes the behaviour testable and removes the clock from the test suite. Use this shape whenever a rule depends on time: never call Date.now() inside the rule itself.
// src/domain/session.ts
// Session state is the one thing that decides which half of the app a
// user sees, so the rules live away from any screen.
export type Session = {
accessToken: string;
refreshToken: string;
userId: string;
// Absolute expiry, computed once from expiresIn on sign-in.
expiresAt: string;
};
export function isExpired(session: Session, now: Date): boolean {
return Date.parse(session.expiresAt) <= now.getTime();
}
// Refresh early. A token that expires mid-request produces a 401 the user
// sees as a random sign-out.
export function needsRefresh(session: Session, now: Date, skewSeconds = 60): boolean {
return Date.parse(session.expiresAt) - now.getTime() <= skewSeconds * 1000;
}
const session: Session = {
accessToken: "a",
refreshToken: "r",
userId: "u_1",
expiresAt: "2026-03-04T10:00:00Z",
};
// now is passed in rather than read from the clock, which is what makes
// these two lines a test and not a coin toss.
console.log(isExpired(session, new Date("2026-03-04T09:59:00Z")));
// Output: false
console.log(needsRefresh(session, new Date("2026-03-04T09:59:30Z")));
// Output: trueGuarding the push payload
A type predicate (value is PushData) narrows an unknown value for the rest of the code. This matters for notifications specifically: a tap on a malformed payload would otherwise navigate to /entry/undefined and show an error screen on launch. Write one of these for every value that enters the app from outside it.
// src/domain/push.ts
// A notification's data object arrives as unknown. It came over the
// network through a third party, so nothing about its shape is promised.
import type { PushData } from "../api/contracts";
export function isPushData(value: unknown): value is PushData {
if (typeof value !== "object" || value === null) return false;
const data = value as Record<string, unknown>;
return typeof data.entryId === "string" && data.kind === "reply";
}
console.log(isPushData({ entryId: "e_7312", kind: "reply" }));
// Output: true
console.log(isPushData({ entryId: 7312, kind: "reply" }));
// Output: false
console.log(isPushData(undefined));
// Output: false
Discussion