FitCheck
Photograph the clothes you own. It builds outfits from those and nothing else.
The problem
Wardrobe apps mostly answer a question nobody asked. You photograph what you own and they recommend something to buy, styling as a storefront. The question actually worth answering is narrower and harder: given only the clothes already in this wardrobe, and what today asks for, what should someone wear?
Three constraints followed from that. It has to be fast enough to use while getting dressed, which rules out a round trip per suggestion. It has to work with no signal, because wardrobes live in bedrooms and bedrooms have bad reception. And it must never lose a garment: someone who has spent an evening photographing eighty items will not do it twice.
The engineering decision
Should the recommendation engine be a model?
Constraints
- Must return an outfit instantly, offline, on a phone.
- Must be explainable. A suggestion nobody understands is a suggestion nobody trusts.
- Must be honest about the cases it cannot handle.
Options considered
- A hosted language model per suggestion Needs a network round trip and an API key, and turns every outfit into a running cost. Fails the offline constraint outright.
- A small on-device model Plausible, but it would still be a black box: when it produced something odd there would be nothing to point at, and no way to say why.
- A readable rule set over a typed taxonomy Chosen. Occasions map to formality, suitable styles, and garments that do or do not belong.
Decision
The outfit logic is arithmetic in `src/domain`, not inference. An occasion phrase is matched against a keyword table that states how dressed up it is, which styles suit it, and which garments are disqualified.
Implementation
Nineteen domain modules cover outfits, colour, fit scoring, packing, laundry and taste. There is exactly one model anywhere near the app, a garment parser that reads a photo and proposes bounding boxes for a human to confirm, and it is off until asked for and never decides anything.
Result
Suggestions are instant and work with no network. When the wardrobe cannot cover the occasion the app says so rather than quietly putting a blazer on you for the gym, and when it does not recognise a sentence it says nothing instead of guessing.
Architecture
- Screens (Next.js / React)
- Domain rules · src/domain
- Zustand store
- IndexedDB · source of truth
- Sync engine
- Server · JSON + photos on disk
The local database is the source of truth. Every screen reads IndexedDB, so the app is identical with or without a connection; the server is a peer that gets reconciled, not a dependency that gates the UI.
Two people, one device
The hard case for sync is a second person on the same device. If somebody signs in on a device that already holds another account’s wardrobe, sync goes pull-only and offers two explicit choices: keep only mine, or merge it in.
Driving the real app found two bugs in that path that no unit test had. The client cached the previous account’s identity in localStorage while the session cookie already belonged to the second one, so the guard compared the stale value against itself, saw no mismatch, and uploaded one person’s wardrobe into the other’s account. Identity now comes from the server, and the engine refuses to run when the two disagree.
The second was subtler. The guard was one-shot: the first pull-only run recorded the new account id, so the next run, triggered by the writes the pull itself had just made, saw matching ids and uploaded everything anyway. The flag is now sticky until somebody deliberately resolves it. Both cases are covered by tests.
Evidence
- 530 tests passing across 34 files, in 6.7s
- 0 network calls to dress the engine never leaves the device
- 3 sync guarantees never removes local data, safe to interrupt, idempotent
Sync writes incoming records before removing anything and writes its cursor last, so a run killed halfway leaves both sides consistent and the next run finishes the job. Deletions leave tombstones. Without them a delete is invisible to sync, the other device pushes the record back, and the thing you deleted reappears.
What I would improve
- Conflicts resolve by newest-timestamp-per-id. That is right for one person on two devices and too blunt for genuine concurrent editing; a per-field merge would lose less.
- The server stores each wardrobe as a single JSON file per user. Fine at this size, and the wrong shape well before it becomes slow, because it rewrites the whole document on every change.
- The garment parser is opt-in and downloads on request. That is an honest default but a poor first run; hosting the weights alongside the app would remove the one moment the app depends on somewhere else.
- There is no server-side rate limit on photo upload beyond nginx’s 32 MB body cap.