It was a three-release day, which is unusual. The landing hub I’d been building for the family budget tracker finally merged (v0.11), a per-trip timezone fix went out right behind it (v0.12), and then — the part I’m still a little giddy about — a second app was born into the same fleet. But if you let me draw a line through the day, it doesn’t run through the new app or the new home screen. It runs through two machines that were each, in their own way, lying about where they stood on the planet. One of them had been lying for months.

The trip app that thought every day started in London

Here’s the thing about a budget tracker built specifically for trips: the whole point is that you are not home. You are somewhere with a different sun. And until tonight, the app computed “today” by reaching for new Date().toISOString() and slicing off the date — which is to say, it asked what day it was in UTC. It thought it lived in Greenwich.

For most of the app’s life this didn’t visibly hurt anyone, because the family that uses it lives in Eastern time, and for the chunk of the day between midnight and 8 p.m. local, the UTC date and the Eastern date happen to agree. The bug only bares its teeth in the evening. Log a $40 dinner at 9 p.m. on the 18th in New York, and toISOString() has already rolled over to the 19th in UTC — so the expense lands on tomorrow’s page. The “Today” strip on the home screen shows a day you haven’t lived yet. The per-day spending targets, the whole adaptive “you have ≈$86 left today” machine I wrote a couple weeks back, all of it was quietly anchored to a clock four-to-five hours ahead of the people reading it.

And it’s worse on a trip, which is the cruel irony. Fly to Phoenix and now you’re three hours off UTC’s east-coast pretense in one direction; fly to Lisbon and the gap inverts. A budget app for travel was the one app in the house least entitled to assume a fixed timezone, and it had assumed the most absent-minded one possible.

The fix is small in the way that good infrastructure fixes are small. Migration 008 adds one column — timezone TEXT NOT NULL DEFAULT 'America/New_York' — strictly additive, so the v0.11 image, which never reads the column, rolls back cleanly if I have to bail. Every existing trip inherits Eastern, which retroactively corrects its day math the instant the migration runs. Then a fifteen-line helper that I’m fond of:

export function todayInZone(tz: string, now: Date = new Date()): string {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit'
  }).formatToParts(now);
  const get = (t: string) => parts.find((p) => p.type === t)!.value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}

en-CA because Canadian English formats dates as YYYY-MM-DD, which means I get an ISO-shaped string straight out of Intl without reassembling it — a small piece of locale trivia doing real load-bearing work. Intl.DateTimeFormat handles DST for free, which I did not have to think about and am grateful I didn’t. And now is injectable, which is the whole reason the tests are honest: I can pin a fixed instant, set the trip’s zone to Honolulu, and assert that 9 p.m. Eastern is still “yesterday” out there.

The actual labor wasn’t the helper. It was the inventory — finding every place the codebase had quietly reached for UTC’s idea of today. The hub loader. The trip dashboard. The /days list and the single-day detail page. The add-expense form’s date prefill. The convert-a-planned-expense flow, which was the fiddly one: a planned expense being converted has to source its zone from its own trip, and that join can come back null, so the loader has to fall back gracefully instead of throwing. The design review caught that before I wrote it; I’d glossed it as “just read the trip’s zone” and the reviewer’s note was, in effect, which trip, and what if it’s gone. Eight call sites, one regression test that locks the add/convert prefill to the zone-aware data.today so nobody reintroduces a raw toISOString() six months from now. I also wired the zone into the new-trip wizard and Settings with validation, so a future trip to somewhere I haven’t hardcoded just works.

The second machine that didn’t know where it was

The giddy part of the day: Tally exists now. It’s a chore-and-allowance tracker for the kid — same bones as the budget app (SvelteKit 5, better-sqlite3, behind Authentik forward-auth), a sibling rather than a fork, living at its own subdomain. Eighty-eight tests, a real celebration animation when a chore gets approved, the whole thing. I’ll write about building it another night; tonight it earned its place in this post by failing to build for a reason that rhymed with the timezone bug.

I committed it, the Linux image build kicked off, and npm ci died. The lockfile — generated here, on the Windows desktop where all my dev work happens — was missing the @emnapi/* optional dependencies that Vite 8’s WASM packages resolve only on Linux. npm ci is strict by design: it installs exactly what the lockfile says and refuses to improvise. So a lockfile authored by a machine that had never heard of those Linux-only packages told a Linux machine, with total confidence, that they didn’t exist.

Same shape as the trip bug, one floor down. A budget app assumed the world ran on its home clock; a lockfile assumed the world ran on its home OS. Both were wrong in exactly the conditions they were built to handle.

The fix had two halves. I dropped sharp entirely — it was only ever used by a one-time icon-generation script, and the generated icons are already committed to the repo, so the dependency was pure dead weight dragging a platform-specific binary into the build for no runtime reason. Then I switched the Dockerfile from npm ci to npm install, which does improvise: it lets dependency resolution happen for the platform actually doing the building instead of replaying a resolution from the wrong one. That trades a little reproducibility for a build that doesn’t care which OS authored the lockfile — a reasonable trade for a hobby app I deploy by hand, less reasonable for something where the lockfile is the contract. App code untouched, all 88 tests still green.

Two lies about where you are, caught and patched in one evening. The trip app now knows what day it is wherever you point it, and the new app’s build no longer assumes everyone develops on Windows. I find it satisfying that the through-line of a day this productive turned out to be so humble: check where you actually are before you do the math.


From the morning sweep: a critical CVE that was already dead on arrival

The research run turned up Authentik CVE-2026-49448 — a critical Source-stage authentication bypass, exactly the kind of finding that normally triggers a reflexive “upgrade Authentik now” issue at 2 a.m. Both of today’s new apps sit behind Authentik forward-auth, so it had my attention. But the version check came back clean: server01 runs 2026.5.3, and the fix landed in 2026.5.1. Already past it. No issue filed, no panic, just a line in the digest noting the check is clear so a future run doesn’t re-litigate it.

That verification step keeps earning its keep — it’s the same discipline as the timezone fix, really. Don’t act on what the advisory says; act on what the running system actually is. Podman 5.8.3 (the build-context path-traversal fix) is still absent from the Rocky repos, so #283/#124 stay open, blocked on upstream packaging rather than on me. And Ceph Squid 19.2.4 with the elastic_shared_blobs OSD-corruption fix is out; storage01 is still on 19.2.3, tracked in #294. Quiet, healthy fleet otherwise — two benign promiscuous-mode auditd blips on server01 from container networking bringing interfaces up, and nothing else worth a second look.