Case study

Habit Jr.
Built end to end.

Habit Jr. is a routine app for kids on iPhone and iPad, with an API behind it. I did the product design, the app, the API, the servers and the monitoring myself. This page covers how it went together.

Staging: liveTestFlight build: uploadedProduction: not launched yet
6 days
From written spec to a live staging API
11k
Lines of SwiftUI in the app
27
Database migrations
0
Third-party SDKs in the app

What it is

Parents set up routines for their kids, ages 4 to 12. Kids check off tasks on their own device and earn points and stickers. Parents get a weekly summary.

Because it’s for children, a few rules came first: no ads, no photos, no free text from kids, and no rankings between siblings. It also has to work offline, since a kid’s tablet loses Wi-Fi.

The design process was light. I wrote a spec and made an HTML mock of the screens. When the two disagreed, I picked one, wrote the reason in a decision log, and moved on. I went native Swift over React Native, and I cut the task screen down to one menu because two buttons on every row was confusing.

One API, one worker, one database

Habit Jr. system architectureThe iOS app talks over HTTPS through a Cloudflare tunnel to the Hono API. The API and the worker both use one PostgreSQL database. The API also calls Apple sign-in and Resend email.SwiftUI appiPhone + iPadencrypted offline outboxCloudflaretunnel, no open portsAPI (Hono, Node)versioned /v1 routesBetter Auth at /api/authpure domain packagescoring and recurrenceWorker35-day horizon, day and week closePostgreSQLrow-level securityapp + auth schemasappend-only eventsThe API also calls Apple sign-in and Resend email

Parents sign in with Apple. Kids don’t have accounts. A parent creates a child profile and links the child’s device with a QR code, and that device can only ever see that one child’s plan. The worker creates upcoming tasks 35 days ahead and closes out each day and week.

The API

Hono on Node, Better Auth, PostgreSQL and zod.

A family’s data should never show up in another family’s request, so I made the database enforce it. Every table has a row-level security policy, and the API connects as a role that can’t turn it off. Each request starts by telling Postgres which family it’s acting for. Here’s the policy for one table:

ALTER TABLE app.occurrences ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.occurrences FORCE ROW LEVEL SECURITY;
CREATE POLICY household_scope ON app.occurrences
  USING      (household_id::text = current_setting('app.household_id', true))
  WITH CHECK (household_id::text = current_setting('app.household_id', true));

-- per request, inside the transaction:
SELECT set_config('app.household_id', $1, true);

A few other decisions shaped the rest of it:

  • The server calculates scores. The app sends “task done,” never a point value. The scoring rules live in a plain TypeScript package with no I/O, so they’re easy to test.
  • Writes are safe to retry. Every write carries an idempotency key and the revision the app last saw. A retry returns the saved result, and a stale revision gets a 409.
  • History can’t be edited. Completion and audit events are append-only. A replay command rebuilds every score from those events and compares it with what’s stored.
  • The contract is generated. The OpenAPI file comes from the server’s schemas, CI fails if it drifts, and the Swift client is generated from it.

The integration tests run against a real PostgreSQL as the restricted login. They cover cross-family access, adult roles, child credentials, revoked links, and duplicate or stale requests.

The app

About 11,000 lines of SwiftUI for iPhone and iPad.

The app handles screens, caching and queueing. It doesn’t calculate scores or generate recurring tasks, because the server does that.

  • Offline. A child’s plan and a queue of pending taps are stored in one encrypted file per profile, with the key in the Keychain. Each tap gets a UUID before it’s sent, so a retry never counts twice.
  • Parent and child modes. They’re separate parts of the app. Switching to parent mode on a shared tablet needs Face ID or a passcode.
  • Apple frameworks only. There are no analytics, ad or crash-reporting SDKs, which keeps the privacy review short.
  • Animation you can turn down. Buddies jump and spin when a task is done and a coin flies into a family jar. A per-child setting and iOS Reduce Motion both tone it down.

The first build has everything except push notifications. A TestFlight build is uploaded. Testing on real devices, legal review and a security review still have to happen before release.

Deploys

Small and repeatable.

On every pull request, CI type-checks, checks that the OpenAPI file is current, runs the domain tests, applies every migration to a fresh PostgreSQL and runs the integration suites. A macOS job builds the Swift contract code and tests the encrypted local store.

A merge to main builds one container image tagged with the commit SHA. A self-hosted runner deploys it to staging with Helm over a private network. A one-off Job applies migrations as the database owner, then the API and worker start with a restricted login.

Production runs an image that has already run on staging, picked by tag and never rebuilt. A rollback means deploying the previous tag. Every migration so far only adds things, so the previous image keeps working.

TargetWhat it isState
StagingOne small server running k3s, with its own PostgreSQLLive, behind a Cloudflare tunnel with no open ports
ProductionA second server joined to the same cluster, plus a managed PostgreSQL, defined in TerraformNot launched. About $27 a month
iOSTestFlight builds from my Mac, started by a GitHub Actions workflowBuild 0.1.0 uploaded

To save the cost of a second cluster, production joins the staging cluster as a worker node. If the staging server goes down, production keeps serving but can’t be restarted or redeployed until it’s back. I’ll revisit that once there’s real traffic.

Backups are a nightly encrypted pg_dump to a private bucket, kept for 30 days. I haven’t done a full restore rehearsal yet.

Monitoring

Kids’ data limits what I can log, so most of this is built from IDs and events.

  • Logs. They hold an error name, a request path with opaque IDs, or a job ID. Nicknames, task text, contact details and tokens never go in. They’re kept for 14 days.
  • Audit trail. Append-only events record who did what and when. Parents see a filtered version as an activity log in the app, and I can trace an incident from the same data.
  • Replay check. One command rebuilds every score from its events and fails if anything differs from what’s stored.
  • Probes and job retries. Kubernetes readiness and liveness probes hit a health endpoint. The worker takes row locks on jobs and retries failures after a minute.
  • Runbook. A written incident process covers severity levels, how to revoke one device or all of them, and how to trace access.

There’s no metrics or alerting yet, and the health endpoint doesn’t check the database.

Building something similar?

Tell me what you have in mind and I’ll tell you how I’d build it.

Habit Jr. is my own product. Figures come from its repository as of October 2026 and will change as it nears launch.