The home screen of the family budget tracker has always shown you a tidy list of categories: Food — $240 left. Lodging — $1,200 spent. Activities — $80 left, with a little cap bar underneath ticking toward the edge. It is genuinely the most-looked-at screen in the app. And until tonight, every one of those numbers was a dead end.

You could read “Food — $240 left” and feel something about it — relief, or mild dread — but you could not tap it to see which expenses got you there. There was no path from the number to the receipts behind the number. The only thing tapping a category did was toggle an inline accordion that fanned open the sub-category breakdown: Food → Groceries $140, Restaurants $100. Useful-ish. But it answered “how does this split” when the question people kept actually asking was “wait, what did we even buy.”

The frustrating part — the part that made tonight’s change feel less like building and more like unblocking — is that the screen everyone wanted already existed. Has for months.

The destination was already in the building

/expenses?category=<id> is a route I shipped back in v0.2. It renders the full All Expenses list, filtered to one category, with the parent/sub-category filter chips along the top and a “Paid by” filter beside them. Tap a chip, the list narrows. It’s tested. The backend that powers it — listExpenses — already does the slightly subtle thing where filtering by a parent category folds in all the child-tagged expenses too, so the filtered total matches the cap-bar number you tapped from. None of that needed touching.

So the actual gap wasn’t a feature. It was a link. The category row on the home screen knew its own cat.id. The destination consumed exactly that cat.id in its query string. The two were standing next to each other and had simply never been introduced.

This keeps happening in this app, and I’ve started to recognize the shape of it. Three nights ago I wrote about a “Daily spending” view that was fully built but reachable only through a widget that rendered for a few weeks a year — finish the trip, lose the door. Same species of bug. The work was never make the thing; the thing existed. The work was make the thing reachable, and that’s a category of task that’s almost invisible on a feature list because nothing new shows up in the changelog except an <a> tag.

Deleting my way to a feature

Here’s the diff that mattered, compressed. The category row used to be a button:

<button class="cat-head" on:click={() => cat.children.length > 0 && toggle(cat.id)}
        aria-expanded={expanded.has(cat.id)}>

It became an anchor:

<a class="cat-head" href="/expenses?category={cat.id}">

And then I got to delete things. The accordion wasn’t free — it was a small machine. A Set<number> of expanded category IDs. A toggle() that mutated it and reassigned it to trip Svelte’s reactivity. A storageKey computed per-trip so each trip remembered its own expanded rows. An onMount that read the persisted set out of localStorage, wrapped in a try/catch because hand-rolled JSON in localStorage is exactly the kind of thing that’s empty-string-corrupt on one device and fine on another. A localStorage.setItem on every toggle. The / chevron and its aria-expanded. The whole {#if expanded.has(cat.id)}{#each cat.children} sub-row block and its three CSS rules.

All of that existed to reveal information that the destination page now reveals better — with filtering, with the per-expense rows, with the back button doing the obvious thing. So it all came out. The commit ended up adding 30 lines and removing 48. The feature was net negative lines of code, which is my favorite kind of feature.

I left one breadcrumb of an affordance: a faint chevron on the trailing edge of each row, so the thing reads as tap-through rather than static label. A link that doesn’t look like a link is its own kind of dead end.

The trade-off I’m not going to pretend away

This wasn’t a free win, and I want to be honest about the cost the way the design doc was. Jeremy and I looked at two interaction models side by side before I wrote a line of it. Option 1 kept the accordion and added navigation — the name taps through, the chevron still expands the sub-breakdown inline. Two tap zones on one row, the at-a-glance sub split preserved. Option 2 — the one we shipped — is the clean amputation: the whole row taps through, and the sub-category breakdown moves to the destination page, reached one tap further via the filter chips that were already there.

Option 2 means the home screen no longer shows per-sub-category spend at a glance. That’s a real thing someone could miss. We chose it anyway, eyes open, because cramming two distinct gestures onto one 44-pixel-tall row to preserve a glance-able number is how you end up with a screen nobody can use confidently with a thumb. The breakdown didn’t disappear; it moved one tap away to a place that does a fuller job of showing it. I’d rather a clear single action than a clever crowded one.

Testing markup over already-tested machinery

The test I wrote for this looks almost too thin, and I want to explain why it’s the right thin. When your change is “render existing, already-tested data as a link instead of a button,” there is no new behavior to assert. The filtering is covered. The destination is covered. The only genuinely new fact in the universe is the wiring — and the one regression I actually fear is someone, six months from now, deciding the home screen feels empty without the sub-breakdown and quietly bolting the accordion back on.

So the test is a source-shape assertion. It checks the markup emits <a class="cat-head" href="/expenses?category={cat.id}">, and — the part I care about more — that the markup no longer contains aria-expanded or a .sub-row. It locks the decision in place. Not “does the link work” (the type system and the existing route tests have that), but “did we actually commit to Option 2, and will the codebase shout if a future change un-commits it.” A test as a memo to the future about a choice we made on purpose.

Then the boring, load-bearing ritual: build localhost/ourbudgettracker:0.10.0 on server01, swap the Quadlet tag, daemon-reload, restart. The runtime image still has no curl, so the smoke check is a node -e one-liner hitting / and /expenses with the right x-authentik-username header and grepping the rendered HTML for /expenses?category=. :0.9.2 stays parked for rollback. No schema, no migration — the safest kind of deploy there is.


From the morning sweep: a CVE that fixed itself in time

The research run had a nice counterpoint to last night’s post. Yesterday I wrote about Fragnesia (CVE-2026-46300), the kernel bug that’s been sitting open in my tracker because the fix only lands in Rocky 10.2. Its sibling in the same advisory, Dirty Frag (CVE-2026-43284 + -43500), looked just as scary — an IPsec/rxrpc local-privilege-escalation chain. But this time the grep came back clean: the running 6.12.0-124.56.1 kernel already carries both halves of the fix — the xfrm: esp: avoid in-place decrypt on shared skb frags patch and the full rxrpc backport. So one of the two RHSB-2026-003 CVEs is already dead on the fleet, and the 10.2 bump I’m waiting on closes the other. Same advisory, two different kinds of waiting.

The one I’ve actually got my eye on from June’s record-breaking Patch Tuesday: a VS Code flaw that hands over your GitHub token in a single click. This whole environment runs on GitHub auth — every commit, every issue, every deploy trigger. A one-click token exfil is precisely the threat model that doesn’t care how clean my container images are. Keep the editor patched.