Case study · Kusuo Shipped Refused by design

Built to be opened in five seconds, by one person

A local-first personal growth app with no backend, no accounts, no telemetry and no gamification — and a written argument for each absence.

StackReact 19 · TypeScript · Vite · Dexie
RoleSole designer and developer, built with Claude Code
Tests30 unit files · 4 Playwright suites
Commits48
Contents · 9 sections
Overview

A personal growth app that keeps everything on the device

Kusuo is a local-first progressive web app for a few daily habits, a set-by-set training log, and reflection. All data lives in IndexedDB through Dexie. There is no server to sign in to, and nothing leaves the device.

Problem

Habit trackers that just didn't take off

Earlier attempts at tracking habits “just didn't take off” — so the aim was low-friction daily use rather than a rich feature set. Kusuo is built for one person, and holds two halves of the same practice: a small set of daily habits, and a proper training log for the days I lift.

Success is opening it, seeing today's habits and today's session within five seconds, and coming back tomorrow — not maximising a streak or completing a feature list. It is used on an iPhone, morning and evening, and read back on a Mac.

My role

Sole designer and developer

Requirements, product document, interface, data model, tests and deployment. 48 commits.

Approach

Decide the data model before the first table

The build brief came first — committed on 18 August, four days before any code. It fixed one writer and one reader, and an append-only log for completions, before a table existed.

PRODUCT.md sets out what the app is for and what it leaves out. It followed on 22 August, ahead of the data layer.

When three specifications later disagreed, SPEC.md was rewritten from a direct read of the code and made the authority.

Key decisions and trade-offs

01

An append-only event log, with one device that writes to it

Habit completions, logged sets, finished sessions, reflections and bodyweight are each a new event with its own ID; un-ticking a habit or removing a set appends another. Only the iPhone writes — the Mac has no write controls at all — so there is nothing to merge, and the log was not needed for correctness. It was chosen because it is cheap to build first and expensive to retrofit, and because it keeps history honest: un-ticking a habit is recorded, not erased. A second writer and a sync layer are planned; neither is built.

What it costNothing the log records is stored as a flag, so every tick, streak and lift record has to be derived by replaying it — and the replay needs strictly ordered timestamps to come out the same every time.
What it boughtA mistyped 200 kg bench can be taken back out of the records without erasing that it happened. And if a second writer is ever added, merging two histories is a set union by ID, not a rewrite.
02

No backend, no accounts, no telemetry

The app records private reflection. A server would mean an account, a consent surface, a privacy policy and a breach to worry about — for an app used by one person. So there is no server.

What it costNo cross-device sync, and no usage data — I cannot see which features get used.
What it boughtNothing to breach, nothing to consent to, and offline as the default rather than a feature.
Scope

What is in, what is out, and what was refused

In scope Habit and reflection entries, a set-by-set training log with splits and derived records, a today view, local persistence, offline use, installability. Built
Out of scope, for now Cross-device sync. It is planned and is not built; the event log keeps it possible. Planned
Refused Telemetry. I would rather not know which screen is popular than ship an app that reports on its user. Refused by design
Refused XP, levels, achievements and badges. Refused by design
How it ships

Three gates, in order, and nothing skips one

From .github/workflows/deploy.yml — public, and quoted rather than summarised.

verify
tsc -b · oxlint · vitest (30 unit files) · playwright vs WebKit (4 suites) Blocks a type error, a lint failure, a failing unit test, or a broken flow in the real engine.
build
needs verify Cannot start until verify is green, so a build artefact never exists for unverified code.
deploy
needs build The live site only ever receives an artefact that passed both gates.

“Nothing reaches the phone without passing this first. The build used to be the only gate, which meant a green deploy said the code compiled and nothing more.”

Comment in the workflow file, verbatim

The end-to-end suites run against WebKit, not headless Chromium — the engine the target iPhone actually uses. That is a choice, not a default.

What shipped

Live, installable, and tested where it breaks

  • A local-first PWA on React 19, TypeScript and Vite, with Dexie over IndexedDB.
  • 30 unit test files, and 4 Playwright end-to-end suites — including offline behaviour and viewport scaling, the two things a local-first web app fails at first.
  • A PRODUCT.md stating the scope, and the reasoning for each exclusion.
5 screens · scroll
Kusuo today view: two of five habits ticked, a 13-day Fajr streak, the week strip, and the day's training session nine of seventeen sets in
Today — habits and the day's session
A live training session: two sets of leg press logged at 210 kg with the third row open, last session's figures in the header, and the rest of the session listed below
Training — set-by-set logging
The splits screen: an active seven-day push, pull and legs schedule with per-day set counts and the next session marked, above the template library
Splits — the weekly schedule
A month of completion history in the calendar, each day carrying a dot for habits and a second dot for a finished session, with one date selected
Calendar — completion history
The records screen: heaviest set and date for every movement logged, with a kilogram and pound toggle, all of it derived from the append-only session log
Records — derived lift records
Kusuo today view on desktop, read-only for review
Today view, desktop — the Mac is read-only, for review.
What I would do differently

Ask the schema what tables exist, from the first table

Reset, backup and every test's setup each kept their own hand-written list of tables — six copies of the same list, none of which knew when the schema changed. Adding the tenth table left weigh-ins behind on reset while it promised to erase everything, and a backup parser with its own list could lose data with a success message.

The event log was designed so that nothing that happened is lost. The reset and the backup beside it, on a device holding the only copy, were not held to that standard until then. Now one module asks the database what tables exist; backup, reset and the tests all read from it, and a test proves every table round-trips through export and import.

This is how I make decisions at work too

Open to new roles — Toronto, hybrid, remote, or relocating.