There are nine of them now. Tasks, groceries, generic lists, workouts, golf, what to watch, trips, the house, and my notes. All lowercase, all ending in a period, all running on the server in my closet: todo. shop. list. fit. golf. watch. trips. home. vault.

I did not set out to build nine apps. I set out to stop paying for the fourth mediocre one.

Why not just buy them

Commercial family-logistics software has a structural problem: it’s built for tenants, and my family is not a tenant.

A grocery app has to support account creation, password resets, billing, sharing invitations, permission tiers, and abuse prevention — for four people who live in the same house and already trust each other completely. All of that machinery is overhead on a problem that is, at its core, a list of strings that two phones and an Echo need to agree on.

The apps also don’t talk to each other, which is the actual pain. Our shopping list lived simultaneously on Alexa, on my phone, and on my wife’s phone, and the three never agreed. Movie night was worse: our want-to-watch data was sharded across a streaming service’s watchlist, Letterboxd, and Trakt — none of which knew what was already in our local library — so recommendations kept surfacing things we already owned or had already watched.

None of these are hard problems. They’re just nobody’s problem, because no vendor owns both ends.

There are no login screens anywhere in the fleet. Reaching the network is the authentication, which I’ve written about before — a single-family app on a private network doesn’t need a doorman, and every login screen you don’t build is a session bug you don’t debug.

The fleet

todo. is the Todoist replacement: a self-hosted task backend with a hand-built front-end over it. shop. is the shopping list, syncing two-way with Alexa so a voice add from either house lands in the same place as a typed one. list. handles collections that are neither groceries nor dated tasks — packing lists, gift ideas, books. fit. logs workouts. watch. owns the want-to-watch state and decides what’s on tonight. trips. builds itineraries from live resort availability. vault. publishes my notes. home. is the house.

The naming is deliberate. One lowercase word, one blue period. It reads as a family on a home screen before you’ve read a single label.

The stack is boring on purpose

Two patterns, both dull:

Most apps are Node 22 + Hono + better-sqlite3 in WAL mode, with a Vite + Svelte 5 PWA front-end. A few older ones are Astro with the Node adapter. Each is one container, one SQLite file on the pool, one Caddy block.

That sameness is the trick. The infrastructure cost is sunk, so the marginal cost of app number nine is a schema, some routes, and an afternoon. list. went from “we need somewhere to put packing lists” to deployed in a day, because there was nothing to decide — the stack, the deploy command, the backup story, the health-check convention, and the design system were all already answers.

If I were doing this again I’d pick the boring stack even faster. Variety in a personal fleet is a tax you pay every single time you context-switch.

The AI is small, cheap, and writes its answers down

Every app that uses a model uses the cheapest fast one, for one narrow job, and — this is what keeps it cheap — caches the result permanently so it never asks twice.

shop. categorizes items into aisles. A keyword map runs first and costs nothing. Only a miss fires an async Haiku call, which re-categorizes the item, pushes the change live over SSE so you watch it hop aisles, and writes the answer back into the keywords table. Every item name is asked exactly once, ever. The system gets cheaper the longer it runs; API spend trends toward zero. The same one-ask-ever pattern handles name canonicalization, so “OJ” and “orange juice” stop being four different products in the history.

todo. runs a triage service that polls the inbox every 20 seconds and classifies each new task — project, up to three labels, priority, due date. Four things I learned making that trustworthy:

It explains itself. Every filed task gets a comment: ”→ Finance — HOA payment is a financial obligation.” When it’s wrong you can see why, which is the difference between fixing it and losing faith in it.

It only fills gaps. Anything you typed explicitly — title, priority, due date, labels — is never overridden. Additions are disclosed in the same comment. An assistant that quietly rewrites your input is worse than no assistant.

It has a world-knowledge file. Triage once filed “better ha ui” to my laptop, reasoning that UI work happens on the MacBook. It had no way to know Home Assistant runs on the server. No prompt tweak could fix that. The answer was a context.md that gets injected into every classification — a machines-to-services map, the people, the properties, and a meta-rule: service work routes to the machine running the service. It reloads on file modification time, so editing it changes the next classification with no rebuild. It’s capped at 8,000 characters, which is a useful forcing function.

Corrections are training data. When I move a task by hand, that correction lands in a log and becomes a few-shot example. I tightened the pickup window from six hours to fifteen minutes so a fix I make now teaches the model this afternoon.

One anti-pattern I hit: saved filters in the task backend surface as pseudo-projects, so “Today” and “Overdue” were in the list of valid classification targets. The model would occasionally file things into a view. Excluding them from the routing enum fixed a class of misfiling that no amount of prompt tuning would have.

Consistency beats polish

The fleet shares one design system — JetBrains Mono and Inter, a single blue, no shadows, corner radius capped at 4px, an IDE metaphor where the top bar is a tabbar and the bottom bar is a modeline that doubles as the sync indicator.

It also shares one app-icon template, specified down to a baseline formula. The spec, the sizing rule that makes short and long names look like siblings, and the icon-lineup test that caught my own shipped icons drifting all live in the design-system post.

The short version of that lineup test: the template turned out to be tighter than the family it came from. That’s my favorite result of the whole project — the design system had grown more correct than the apps implementing it.

PWA delivery is its own discipline

Shipping a new icon across nine installed apps taught me that favicons and installed-app icons are separate cache surfaces, and updating the file is not enough. The five-step release checklist that finally worked — version every icon reference, check the generated HTML, verify the live bytes after deploy — is in the design-system post, along with the Vite plugin behavior that made the middle step necessary.

Service workers bite the same way. A stale worker precaching dead asset hashes means the install fails, never activates, and push notifications hang forever. The fix is no-store on the service worker and the shell, plus a per-build version query on the registration. Both halves matter; undoing either brings the bug back.

What I got wrong

home. is the flop. I was building a standalone house app to replace the Home Assistant dashboard, and an audit made me stop: it was duplicating authentication, WebSocket state, entity discovery, and service calls — a second, worse implementation of a thing that already worked. I’d also let the underlying setup rot, with 228 devices and only 32 assigned to areas. So I deleted the ambition, redesigned the native dashboard instead, and left the app online as a fallback. Optimizing the thing you have beats maintaining a second version of it.

A schema migration once took shop. down for two minutes while 228 tests stayed green — the full confession is in the shopping-list post. The lesson became a regression test: migration tests need an existing-database-shape case, not just fresh-install and idempotence.

And one deploy gotcha I’d wish on nobody: docker compose up -d --build does not reliably recreate the container on this host. The image rebuilds, tags correctly, and compose reports the container is Running — serving the old code. I verify the container’s image ID against the just-built one after every deploy now, because a successful deploy and the new code actually running turned out to be two different things.

Nine apps, four people, one closet. The apps are not impressive individually, and several still have seams I can see from across the room. Collectively they’re also the most fun I’ve had with computers in years — and the family keeps using them, which is the only review that counts.