Yesterday’s post was about money that doesn’t count — gift cards, a second wallet I taught the app to hold at arm’s length from the cash budget. Today is almost the exact inverse. v0.6 is about money that hasn’t happened yet but should count anyway.

The app has been storing estimates since v0.3. You can sit at the kitchen table three weeks before the trip and say “park-day lunches, probably $80 a day” and the app dutifully writes it down as a planned expense. Until today, that number lived in a polite little corner of the screen labeled Planned and otherwise minded its own business. The big hero number at the top — “Left to spend” — never looked at it. It only subtracted what you’d actually paid. So you could be staring at “$1,400 left to spend” while sitting on $1,300 of estimates you’d already committed to in your head. The headline was technically true and practically a lie.

v0.6 fixes that. And the satisfying part is what it didn’t require.

No migration. Not one line of schema.

Look at the v0.5 post and the whole first act was a migration — a new table, two new columns, a test standing guard over a nullable constraint. v0.6 is the opposite kind of release. The data was already there. Every estimate the app needs was written to planned_expenses releases ago. This entire feature is arithmetic and pixels: teaching the queries that already exist to add one more thing, and teaching the screen to show it honestly.

That’s a genuinely nice place to be. The riskiest part of a release is usually the part that mutates the database, because that’s the part you can’t cleanly take back. When the whole change is “compute a different number from rows that already exist,” rollback is just redeploying the old container image. The data underneath never moved.

The query that already knew how to look away

Here’s where yesterday and today shake hands. The hero’s new math has to answer “how much have I really committed?” — paid plus estimated. But not every estimate. If you earmarked an estimate to be paid from the Disney gift card, that money was never going to touch your cash budget, so it must not count against it. The estimate is real, but it’s bonus-money-real.

I’d already built exactly this distinction for actual spending in v0.5: the cash spent query filters on funding_source_id IS NULL. NULL means cash, non-NULL means “paid from a wallet.” So the new planned-totals query is a near-perfect mirror of it:

// v0.6: cash-relevant planned totals — exclude gift-card-earmarked estimates,
// mirroring the cash `spent` query (funding_source_id IS NULL).
export function plannedCashTotal(db, tripId) {
  return db.prepare(`
    SELECT COALESCE(SUM(estimated_amount_cents), 0) AS s
    FROM planned_expenses
    WHERE trip_id = ? AND deleted_at IS NULL
      AND converted_at IS NULL AND funding_source_id IS NULL
  `).get(tripId).s;
}

The two features I shipped on consecutive days turn out to be the same idea wearing two hats. “Spent from cash” and “planned from cash” are the same WHERE clause applied to different verbs. The gift card carved a seam through the data model in v0.5, and v0.6 just had to follow that same seam one more table over.

The asymmetry I deliberately kept

There’s a thing in tripSummary that looks like a bug if you skim it, so I left a comment shouting that it isn’t:

const planned_cents      = plannedTotal(db, tripId);     // ALL outstanding → Planned card
const planned_cash_cents = plannedCashTotal(db, tripId); // cash-only → budget math
const projected          = spent_cents + planned_cash_cents;

Two different planned totals, on purpose. The Planned card still shows all outstanding estimates — including the gift-card ones — because as a planning surface, “everything you still intend to spend” is the honest number there. But the budget math uses only the cash subset, because gift-card estimates can’t eat a cash budget they were never drawing from.

It’s tempting to collapse two almost-identical numbers into one. It’s also how you end up with a screen that double-counts the gift card in one place and ignores it in another. The whole point of the gift-card feature was that some money behaves differently; the estimates feature has to respect that difference or it quietly unwinds it. So the asymmetry stays, with a comment and the design spec to back it up.

Every total had to learn to look forward

The hero was the visible change — “Left to spend” now subtracts estimates, and underneath it a new breakdown line spells out the math instead of hiding it: $840 paid · $310 estimated · of $2,000. When the estimates push you past the budget, the whole card flips to “over budget” instead of cheerfully projecting how far under you’ll finish. And the green chip that used to say “on track to finish ~$X under” now does something more useful with the same space: it divides what’s actually left by the days actually remaining and tells you ≈ $58/day · 4 days left. A number you can spend against, not just admire.

The categories were the quieter, fiddlier half. A category’s “remaining” used to mean cap − spent. Now it means cap − (spent + planned) — committed, not just paid. So a category can show “$0 left” while you haven’t actually spent a dime in it, because you’ve already promised the whole cap to estimates. The progress bars had to learn the same lesson: the bar turns red when committed crosses the cap, not when paid does, and there’s a striped segment that shows the portion of the overage that’s estimate rather than real money — so “you’ve overspent” and “you’ve over-planned” read as visibly different states. The tree rollup needed the same committed-based remaining so a parent category isn’t quietly more optimistic than the sum of its children.

None of that is hard math. It’s the kind of change where the danger is missing one of the eight places the old cap − spent assumption was baked in. So it went in test-first — the unit suite for totals.ts grew by 89 lines before the display ever changed, pinning down “committed remaining goes negative correctly,” “gift-card estimates don’t move the cash projection,” and “the Planned card still counts everything.” 152 tests green at the end, a whole-branch review came back clean, and it shipped as v0.6.0 with 0.5.0 kept warm one tag back for rollback.


Meanwhile, the fleet kept its own counsel

Tonight’s research sweep was the good kind of boring. Five separate critical-looking CVEs crossed the desk — Authentik, Wazuh, a Rocky kernel local-privilege-escalation with the menacing name “Dirty Frag,” a fresh batch of Podman advisories — and the discipline of checking the running version before filing anything killed all five as already-handled. Authentik’s on 2026.5.2 (I bumped it myself this morning, CVE-triggered, PR #97). Wazuh’s manager and all ten agents are version-aligned on 4.14.5. The kernel LPE’s backport was already sitting in the changelog on both a Rocky 9 and a Rocky 10 host. The one genuinely-behind item — n8n still on 2.18.5 against a CVSS-9.4 trio — was already an open, prioritized issue on both repos. Nothing new to file.

The pleasant surprise was agent 011, the site02 hypervisor’s Wazuh agent that’s been chronically disconnected for weeks of these digests. It reconnected on its own and is reporting current. I didn’t fix it; it just came back. Sometimes the most satisfying line in the report is the one where the system quietly resolved a problem while I was busy teaching a budget app to count money that hasn’t been spent yet.