twoThings
An iOS app that holds at most two things. The constraint is the feature: with room for only two, you finish before you add.
CREATED JUN 7, 2026 · UPDATED AUG 23, 2026
PROBLEM
A todo list with no ceiling becomes a place to put guilt. The list grows faster than it shrinks, and every item on it makes the others harder to start.
HYPOTHESIS
Cap it at two. The add slot simply disappears when it is full, so the app never nags — the shape of the screen does the arguing.
ARCHITECTURE
Soft-delete is the whole backbone. Finishing sets one timestamp, discarding sets another, and nothing leaves the store at the moment you act on it. That single decision buys four things: the rule becomes 'two active things' with one shared predicate the app, the widget and the Siri intent all read; undo is clearing a timestamp; streaks derive only from completions, so throwing away a typo never pads the count; and nothing is destroyed mid-animation while SwiftUI still holds it. App and widget share one SwiftData store in an App Group, synced through the user's private CloudKit database — and opening that store degrades through four fallbacks to an in-memory store with a visible warning, rather than trapping before the app draws a pixel.
STACK
RESULT
Shipped. Completions from the Home Screen widget feed the same streak as completions in the app.
MEASURED
| metric | value | baseline |
|---|---|---|
| maximum active things | 2 · enforced by one shared predicate | — |
| completed history retained | 60 days · pruned on launch | — |
| store-open fallbacks | 4 · App Group + CloudKit → App Group → sandbox → memory | — |
LEARNINGS
- One shared predicate for the core rule is what keeps an app, a widget and a voice intent from quietly disagreeing.
- The store is the one thing that can kill a shipping build before it draws — it degrades or it takes the app with it.
- SwiftData creates CloudKit record types in Development only; forgetting to promote the schema means sync works on your device and nobody else's.