Marks the document line as one coherent milestone: the vault becomes a
place to understand files, not just store them — read, indexed, searched,
shared, and answered — with a local-first / consented AI posture and a
vendor-blind egress notice held at every step. Ships alongside a full
correctness, resilience, and quality sweep down to the low-priority findings.
The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.
scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.
Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.
H2
The measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.
src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.
Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.
H4
The timeline windows a flat item list (month labels + chunked card rows)
with @tanstack/react-virtual over the shell's scroll container — single
scroll owner, bounded DOM at four-digit corpus sizes. Columns are
measured by a ResizeObserver so the row model and the painted grid never
disagree. Cards are compact-density: kind glyph, foreground title, one
muted meta line, condition pills, a download-only badge for attachment-
class formats, and a hover/focus selection checkbox above the whole-card
click target. In-flight and failed uploads share the same footprint with
a live progress ring and translated failure copy.
Plan-Task: W2-T2, W2-T4 (upload cards)
next 16.2.10, @tanstack/react-query 5.101.2, nodemailer 9.0.3,
react-hook-form 7.81.0, @types/node 26.1.0; docker buildx/metadata/
build-push action pins to their current releases.
Add a Google Health connection that reads Fitbit, Pixel Watch, and Fitbit
Air data through the user's Google account, alongside the existing Fitbit
connection, as the path forward before the classic Fitbit Web API sunsets
in September 2026.
Restore the daily briefing when it cites your own vitals (the number-grounding
gate stripped it on any blood-pressure, pulse, weight, mood or sleep figure it
did not recognise), surface a medication's dose as a tag beside its category,
let the custom on-time window switch stay on, and drop the layout-shifting
back-link from the Appearance sub-pages.
The date field opened the browser's native date picker, which rendered by
the browser and kept its intrinsic width on mobile. Replace it with the
shadcn calendar in a popover: a fully app-controlled react-day-picker
calendar that looks the same everywhere and never overflows a phone-width
sheet. The field keeps its preference-formatted, typeable display and its
yyyy-MM-dd value contract, so the date-time field, the time field and
every form that uses them are unchanged. The calendar's month and weekday
labels follow the UI language.
A native date input keeps its intrinsic width on mobile WebKit and won't
shrink, so the `date + fixed-width time` row overran a phone-width sheet —
the time field and its clock icon spilled past the screen edge and every
add form (measurements, mood, medication, …) scrolled sideways. Stack the
two halves on narrow viewports and only place them side-by-side from the
`sm` breakpoint up; each half then takes the full sheet width and nothing
overflows.
Renumber the custom-metrics migration to 0223 (the next free slot on the
trunk) and cut the release: version bump, regenerated OpenAPI contract,
service-worker stamp, and CHANGELOG entry.
The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.
Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.
A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.
In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
A self-hoster can set IP_GEO_ALLOW_INSECURE=true alongside an http:// URL to
use a free HTTP-only provider — the free ip-api.com endpoint is HTTP-only (its
HTTPS form needs a paid key) but its geolocation is often more accurate and it
carries the ISP. The default stays HTTPS-only with ipwho.is; plain HTTP is
refused unless the operator explicitly opts in, since the looked-up IP then
travels unencrypted over the server's own egress. Whitelisted in compose and
documented in the env example.