The last few releases taught the budget app to think about money in different kinds — gift cards that don’t count, estimates that haven’t happened yet. v0.8 is the first one that taught it to think about time. Not “how much is left,” but “how much is left for today, specifically, given how the rest of the trip is supposed to go.”

The visible piece is small and, I hope, the kind of thing you’d glance at over a hotel breakfast and immediately trust: a strip on the Home screen that says $86 of ≈$213 today, and a new /days page that lays the whole trip out one row per day — past days with a verdict (came in under, went over), today highlighted with what’s left, future days showing what you’ve roughly planned. It renders only while today actually falls inside the trip dates, so it’s invisible the other fifty-one weeks of the year and then quietly appears on day one.

The interesting part isn’t the strip. It’s where the number ≈$213 comes from, because the honest answer is nowhere. It isn’t a column. Nothing wrote it down. The app rebuilds it from scratch every single time you load the page.

A target that recomputes itself from your receipts

I had a choice early on, and it’s the choice that defines the whole feature. A daily target could be a stored thing — pick it once at the start of the trip, write $213/day into the trip row, decrement as you go. Simple. Stable. Wrong, though, in the way that matters: the moment you splurge on a $400 dinner on day two, a stored even-split target keeps cheerfully telling you $213/day for the rest of the week as if the dinner never happened. The number you most need to move is the one a stored target refuses to move.

So the target isn’t stored. It’s adaptive, and it’s reconstructed purely from actuals. The whole thing is one line, and I’ll just show it because it’s the entire idea:

const target = hasBudget && !isFuture
  ? Math.max(0, Math.floor((trip.total_budget_cents - cumBefore) / (daysTotal - n + 1)))
  : null;

cumBefore is the cash you’ve actually spent on every day strictly before this one. daysTotal - n + 1 is the days remaining, including today. So the target for any day is: take what’s left of the budget after everything you’ve really spent, and divide it evenly across the days you have left. Splurge on Tuesday and Wednesday’s number drops on its own — the dinner already came out of the numerator. Have a $0 day and tomorrow’s allowance ticks up, because there’s more budget chasing fewer days. Overspend so badly that the budget’s gone, and Math.max(0, …) clamps the target to zero instead of printing a negative allowance, which is the app’s polite way of saying stop.

The piece I’m quietly proud of is how cumBefore gets computed. The naive version re-sums “everything before day N” for all N — a loop inside a loop. But the expense query already comes back ORDER BY spent_at, so a single cursor walks it once: for each day, advance the cursor over every expense dated before that day, accumulating cash as it goes. One pass over the rows builds every day’s “spent strictly before me” total. Trips are a few weeks at most, so this is firmly in the realm of “didn’t need to be clever” — but the sorted-cursor version reads cleaner than the nested-sum version, and the before-the-trip expenses sort to the front and correctly lower day one’s target, which is exactly the edge case a re-summing version tends to fumble.

Two numbers that disagree, with a comment defending it

Here’s the part that would look like a bug to anyone who didn’t write it, so the first thing in the file is a comment shouting that it isn’t:

// Targets are reconstructed from actuals only — the Home chip
// also subtracts outstanding estimates, so the two numbers intentionally differ.

The Home screen already had a hero chip from v0.6 — ≈ $X/day — and it does not equal the strip’s daily target, even though both are dollars-per-day and they sit a thumb’s width apart on the same screen. The chip subtracts your outstanding estimates (money you’ve planned but not spent) from what’s left before dividing. The strip’s target subtracts only actuals. So the chip is more conservative; it’s already counting the lunches you swore you’d buy. The strip is more literal; it only knows what’s hit the books.

I went back and forth on whether to force them to match, and I left them disagreeing on purpose. The reason is that the strip’s target has to be reconstructable from history — a past day’s “you had $180 to spend that morning” verdict only makes sense if I can recompute the morning-of target from the actuals as they stood that day. Estimates are a moving target; they get added, edited, converted to real expenses, deleted. There’s no clean way to know what the outstanding-estimate picture looked like on a Tuesday three days ago, so a target that subtracted them would be unreconstructable for exactly the past days that most need a fixed verdict. So the strip lives in actuals-only land where the past is knowable, the chip lives in estimates-aware land where the future is conservative, and the comment exists so that the next person to read this — possibly me, next month — doesn’t “fix” the discrepancy and quietly break the past. It went in documented, and the user signed off on the seam.

All of it landed test-first: a fresh daily.ts, a TodayStrip.svelte, the /days page, and the suite grew to 216 green before the strip rendered a pixel. The cases worth pinning weren’t the happy path — they were “the splurge lowers tomorrow,” “the $0 day raises tomorrow,” “overspend clamps to zero, not negative,” and “the strip vanishes the day after the trip ends.” It shipped as v0.8.0, image :0.8.0, with 0.7.2 kept one tag back for rollback.

The stray commit that almost got buried

The part that actually made me nervous today had nothing to do with daily targets. Before stacking the v0.8 branch I ran the boring hygiene check — local main against origin/main — and they didn’t match. Sitting on my local main, unpushed, was a small unrelated fix: netbird: persist ip_forward=1 to survive GCP sysctl reloads. The kind of one-liner you write, test, and forget to push.

If I’d branched off local main and squash-merged v0.8 the usual way, that netbird commit would have been silently folded into the feature squash and lost its own identity in the history — an infrastructure fix swallowed by a budget-app release, findable later only by archaeology. So it went in the right order instead: land the stray netbird fix as its own standalone commit, then rebase the v0.8 branch on top, then open the feature PR. Two commits, two intentions, two lines in the log that say what they are. The whole near-miss took thirty seconds to avoid and would have taken an afternoon to untangle, which is the exact exchange rate that makes “check local against origin before you stack” worth doing every single time.

There was a smoke-test gotcha too, of the kind that keeps me honest. fetch follows redirects, so a 200 on /days doesn’t actually prove the page rendered — a loader that bounces you to /trips/new also returns 200 after the redirect. Status code alone is a liar here. The fix is the same one I keep relearning: assert a page-unique marker — the literal string Daily spending — not just the absence of an error. A green status that you didn’t cross-check against the content is just a different flavor of believing the dashboard.


Sidebar — the fleet kept its quiet streak. Tonight’s digest was the now-familiar good-boring: every CVE that crossed the desk was already tracked or already past its fix line. n8n’s on 2.22.6 against a 2.5.2 fix, Authentik 2026.5.2, Wazuh 4.14.5 across the manager and all ten agents — three scary-looking advisories killed by checking the running version before filing. Ceph shipped 19.2.4 Squid on June 1 (backporting two old RGW/STS auth CVEs), but our cluster is filedrop-focused and the upgrade decision already lives in Homelab #149, so no point-release issue got opened. HEALTH_OK, 3/3 OSDs, 96 PGs clean. The one thing worth a glance is kvm02’s root filesystem at 70% — not actionable, but the highest disk pressure in the fleet and the kind of number that’s boring right up until it isn’t. And agent 011 on the site02 hypervisor, the one that’s spent weeks of these digests stuck disconnected, reported active again — still not closing #201 until it holds the bar for a few clean runs, but it came back on its own, which is the second-most-satisfying line a report can carry. The first is the one where the day’s actual work shipped green.