Architecture
Dawnlet is eleven modules that all look the same from the inside. That sameness is the architecture: one pattern, repeated, so that adding a module is filling in a shape rather than inventing one.
§The shape
There is no server of ours. The app authenticates against Supabase and queries Postgres directly, with row-level security doing the job an API layer's authorization code would normally do. That removes a whole tier — and with it a class of bugs where the client and the server disagree about what a user is allowed to see.
| Piece | Responsibility | Rule it follows |
|---|---|---|
| Repository | Everything that touches the database for one module | A protocol first, with a real implementation and a mock one. Every method is async and can throw. |
| View model | The state one screen needs, and the actions it offers | Observable, main-actor. Catches repository errors into a message rather than throwing at the view. |
| View | Layout and gestures | Owns no data-fetching logic. Confirms destructive actions itself, so the view model stays testable. |
| Shared row | How one item of a module looks, everywhere it appears | Bundles the visuals, the list chrome and the swipe actions as one component. |
Each module was built at a different time, and several were mock-data placeholders before they were real. Because the seam between "screen" and "where the data comes from" is always a protocol, swapping a module from mock to real is a change on one side of that seam — the screen doesn't know it happened.
1Repositories
Every module owns a repository protocol describing exactly what that module needs from the database — fetch this, create that, delete the other — and nothing else. Two implementations sit behind it:
- The real one, which queries Postgres. It's the default, so a screen constructed with no arguments is wired to real data.
- A mock one, used by SwiftUI previews only. It holds a plausible dataset in memory so a preview renders something worth looking at without a network or an account.
Two conventions matter more than they look:
Everything can throw, and everything is async
No repository method quietly returns an empty array on failure. A failure is a thrown error the view model turns into a message on screen. Silent empty results are the specific failure mode that makes a sync bug look like "the user has no data", and they're designed out.
Re-fetch after a mutation, don't patch locally
After creating or deleting something, the module re-reads from the repository rather than surgically editing its local array. It costs a round trip and buys a guarantee: the screen can never drift from the database, because the database is the only thing it ever renders.
The one deliberate exception is a habit check-off, which flips locally first for a snappy tap and then reconciles — see habits & streaks.
2View models
One observable view model per screen, on the main actor. It holds the screen's data, an
isLoading flag and an optional error message, and exposes actions as plain methods.
The division of labour with the view is strict in one direction: the view model never asks a question. A destructive action's confirmation — the alert before deleting a category, an account, a habit — lives in the view. The view model just deletes. That keeps every action testable by calling it, with no UI in the loop.
An error alert bound to a constant value never appeared, because a constant binding can't round-trip its own dismissal. The fix — deriving a real two-way binding from the optional error — now applies everywhere an error alert is presented. Worth stating because it's invisible in review: the code reads fine and simply never runs.
3Shared rows
A habit appears on its own screen and inside the day view. A movie appears in the bucket list and in the day view. Rather than two row styles kept in sync by discipline, there is one row component per module, and both screens render it.
Crucially, the component bundles more than the visuals:
Bundling the swipes is what makes this more than a styling convenience. "Skip a recurring todo" behaves the same on both screens by construction, not because two implementations were kept in agreement.
Swipe actions only exist inside a real list. That single fact decides the structure of the day view: it must be a list, not a scrolling stack. An earlier attempt reimplemented swiping by hand to keep the freedom of a scroll view; it never matched the real gesture — the rubber-banding, the velocity, the full-swipe threshold — and was deleted. The rule now is to bend the layout to the platform's gesture rather than reimplement the gesture.
5Design system
Two layers, kept apart on purpose.
| Layer | Holds | Example |
|---|---|---|
| Primitives | Fixed brand hues, adaptive page/card/text colours, easing curves, the brand mark | The four module colours behind the sun in the app icon |
| Composed pieces | Reusable components built from those primitives | Card links, icon tiles, progress bars, week strips, the pill segmented control |
A distinction worth the words: a module's colour is fixed brand identity, while a category's colour is an arbitrary choice a person made in a picker. Those live in separate enumerations even though both are "a colour", because merging them would let a user's pick of "violet" for a grocery category quietly become part of the brand palette.
The launch animation and the live timer share an unusual property: both are pure functions of a single number — elapsed time — rendered each frame, rather than state machines advancing through timers. That makes both trivially scrubbable and impossible to desynchronise.
6Trade-offs, stated plainly
| Choice | What it costs | Why it's still right here |
|---|---|---|
| No API service | Business rules can't live server-side; every client would have to re-implement them | There is exactly one client. A second one would change this calculus, and that's the moment to revisit it. |
| Re-fetch after mutation | An extra round trip per change | Removes an entire category of "the screen disagrees with the database" bugs, on data measured in kilobytes. |
| List instead of scroll view | Less layout freedom on the busiest screen | Real swipe gestures, for free, forever. |
| Mock implementations kept around | A second implementation of every protocol to maintain | Previews render real-looking screens with no account, which is most of why the UI got built at all. |