Categories endpoint + conflict-resolution lock. A two-item follow-up
to v1.4.30: the categorisation overlay shipped yesterday as a
TypeScript map; the new `GET /api/measurement-categories` endpoint
projects it over HTTP so the iOS-side picker can fetch the canonical
shape rather than carry a hard-coded mirror — the unblocker the iOS
team flagged as the one missing piece. In parallel the SyncMode
conflict-resolution policy that the v0.6.0 standalone-first track
depends on lands as §13 of `08-locked-contracts.md`.
§2 of the v1.5 strategic plan now ends v1.4.33 without the freeze
marker. A new v1.4.34 sub-section lands per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R2: scope is
`POST /api/import/apple-health-export` (multipart) + streaming XML
parser + async job model (`{ jobId }` + `/status` polling) +
per-MeasurementType ingestion stats + idempotent UPSERT on
`externalId` + admin endpoint variant + mapping for
HKQuantityTypeSample, HKCategoryTypeSample and HKWorkout
(HKClinicalRecord defers per R-F T3). Effort L (~3-4 days).
The web-freeze marker moves to v1.4.34. The freeze posture, the
v1.5.0 version-bump shape, and the decision-log §5 rows
(web-freeze trigger, v1.5 release shape, web patch sequence, plus
a new "Apple Health XML import slot" row) all renumber. The
deferred §4 row and §6 open question #3 close out — the import is
now scheduled, not deferred.
§5b "Coach SSE" and §6 "Coach SSE drawer iOS implementation" both
reframed per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R6.
The server endpoint `GET /api/insights/chat` stays live indefinitely
for PWA users, future iOS reevaluation post-MDR, and any other
client that adopts the SSE protocol. v1.5.0 iOS ships Apple
Foundation Models on-device Daily Briefing + Trend Observations as
the primary assistant surface; a native server-Coach drawer is
deferred pending MDR Class-IIa pre-review.
§3 of the iOS contributor brief now reflects the v1.4.30 production
state (both hosts on the iOS-coordinated foundation tag) and the
v1.4.30.1 follow-up. The recent-releases table gains two rows; the
"endpoints the iOS client may consume today" list gains the four
v1.4.30 surfaces (`/api/sync/state`, `/api/mood-entries/bulk`,
`/api/medications/intake/bulk`,
`/api/admin/drain-per-sample-cumulative`) plus the new v1.4.30.1
`/api/measurement-categories` projection.
Appends §13 to `08-locked-contracts.md` codifying the hard-spec
delivered in `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R9.
Covers bulk-backfill, steady-state bidirectional sync, write
conflict (LWW by `updatedAt` with server-wins on the millisecond
tie), delete conflict (409 + canonical row vs 410 Gone), and the
`GET /api/sync/state` envelope shape.
iOS uses this as the contract for the v0.6.0 standalone-first track
once `SyncMode = paired` is the live state. The "What is NOT in
this file" stub renumbers to §14.
Adds `GET /api/measurement-categories`, a thin projection of the
v1.4.30 `MEASUREMENT_CATEGORIES` overlay at
`src/lib/measurements/categories.ts`. iOS reads the response on cold
start to drive the HealthKit permission picker grouping; the future
web Insights nav and the Coach evidence shelf consume the same shape.
Response envelope carries a `version: 1` marker, an ordered
`categories` list with `labelKey` translation keys, and the
`assignments` map keyed on `MeasurementType`. `requireAuth()` gates
the route — any logged-in user passes — and the response carries
`Cache-Control: public, max-age=600` so clients honour a 10-minute
edge cache.
Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
iOS team's 2026-05-16 response confirms two strategic pivots
(Apple Foundation Models on-device + standalone-first for v0.6.0)
and asks ten coordination items. Every R1-R10 answered with
hard-spec commitments. Two new items land in the v1.4.x roadmap:
GET /api/measurement-categories endpoint (R1, v1.4.30) and the
conflict-resolution policy lock (R9, v1.4.30). Apple Health XML
import (R2) slates to v1.4.34 alongside the freeze marker.
v1.4.30 commit 7 follow-up. The new MeasurementType values land in
every derived registry the canonical enum drives:
- measurement-list-meta.ts label keys + icons + colors so the
measurements list view renders both types (Gauge for steadiness,
Volume2 for the loud-listening event)
- 6 locale files (de/en/es/fr/it/pl) carry typeWalkingSteadiness +
typeAudioExposureEvent keys; English-fallback copy for the four
non-EN/DE locales matches the existing v1.4.25 W8d pattern
- chart-tokens.ts ALLOWED_CHART_TOKENS list so the LLM can
reference `metric:WALKING_STEADINESS` + `metric:AUDIO_EXPOSURE_EVENT`
in prose; dedicated chart components ship alongside the iOS sync
- the enum-coverage drift-guard test's EXPECTED_TYPES + PDF
exclusion set so the canonical enum-extension lint stays green
Full unit suite passes (4035 specs).
v1.4.30 backstop. Every new endpoint introduced earlier in the patch
run rides the existing real-Postgres container fixture (v1.4.29's
contribution): the SyncMode handshake, the mood-entries bulk
upsert, the medication-intake bulk insert with idempotency-key
collision, the mood-note column round-trip, and the drain helper
itself.
This commit adds the missing slot — the admin endpoint
POST /api/admin/drain-per-sample-cumulative — so the route's admin
gate, dryRun default, and commit path all run against real SQL
end-to-end. Mocked-prisma siblings would have hidden the v1.4.29
lesson (the date_trunc bound-parameter bug); the route's
transactional upsert + delete needs the same fence.
Coverage delta: 3 new integration specs, all green.
R-F T1.4 + T1.5 close-out. Two new MeasurementType enum values
unlock the iOS Tier-1 surfaces Apple's own Health app shows
prominently.
Schema (migration 0064, additive):
- measurement_type gains WALKING_STEADINESS + AUDIO_EXPOSURE_EVENT
WALKING_STEADINESS — iOS 15+ Mobility daily rollup. Apple ships a
0..1 fraction; HealthLog stores 0..100 percent (same convention as
oxygen saturation + body fat).
AUDIO_EXPOSURE_EVENT — iOS 13+ category-type that fires when the
rolling 7-day average crosses the WHO 80-dBA loud-listening
threshold. Both the environmental + headphone events map to the
same MeasurementType so chart-card consumers pick them up uniformly;
the notes field carries the source token at iOS write time.
Updates ride the same release:
- Zod measurementTypeEnum gains both values
- apple-health-mapping.ts maps three HK identifiers
(AppleWalkingSteadiness, EnvironmentalAudioExposureEvent,
HeadphoneAudioExposureEvent) and removes the same identifiers
from HK_QUANTITY_TYPE_DEFERRED
- categories.ts assigns WALKING_STEADINESS to activity and
AUDIO_EXPOSURE_EVENT to hearing
- pr-direction.ts pins WALKING_STEADINESS to MAX (higher steadiness
is the achievement) and AUDIO_EXPOSURE_EVENT to no-PR (threshold-
cross flag, not goal-axis)
iOS contract: the two new enum values are net-new — iOS clients that
predate the codegen pass will not encounter them in read paths
because no source writes them yet. New iOS code paths can post the
new HK identifiers via the existing batch endpoint without further
server changes.
R-F §4 close-out. The flat 27-value MeasurementType enum stays as the
storage shape; this overlay is presentation-only and drives both
consumers:
- iOS HealthKit permission picker — grouped by category so the user
sees one consent screen per logical area (vitals / activity /
sleep / hearing / environment / cardiovascular / metabolic / body)
rather than a flat 27-line list
- web Insights nav (post-v1.5) — the existing 7-page strip stays
curated; an "All metrics" overview can group cards by category
cheaply from this map
- Coach evidence shelf — chip-grouping by category makes the prose
more navigable than a flat list
The map is ReadonlyMap<MeasurementType, MeasurementCategory> so
consumers cannot mutate it accidentally. Adding a new MeasurementType
without a sibling entry is caught at test time by the completeness
wall.
iOS contract: presentation-only — no API surface change. The iOS
client mirrors this map verbatim once the swift-openapi-generator
wiring (R-E H-8) lands; new types stay invisible to clients that
predate the codegen pass.
R-F T1.1 close-out for the dashboard "Recent workouts" tile. Same
intent as pickCanonicalSourceRows() for measurements, applied to
workouts. A user wearing an Apple Watch + a Withings ScanWatch
records the same run via each integration; without the picker the
dashboard would show the run twice.
Bucket key = (5-min-rounded startedAt, sportType). The 5-minute slot
absorbs the clock skew between HK and Withings without collapsing
two back-to-back runs into one bucket (a user does not run twice in
a five-minute window in practice).
Source-priority ladder ride the existing measurement "steps" key —
unifying on one Settings UI rather than introducing a new "workouts"
metric key. Default ladder is APPLE_HEALTH > WITHINGS > MANUAL >
IMPORT per the source-priority validator's canonical default.
Metric-aware tunes (route → Apple wins, HR zones → Withings wins)
defer to v1.5.x per R-F open question #4.
iOS contract: helper is read-side only — no API surface change. The
workout batch ingest route (verified in v1.4.25 W8d) keeps the
existing rate-limit + idempotency + 5 MB body cap + 100-workout
batch cap. /api/workouts/batch is the iOS endpoint going into v1.5.
Picker exercises empty-input, single-row, cross-source overlap,
sport-discriminator-keeps-distinct, > 5-minute-distinct, user-
supplied ladder honoured, manual-only fallback, and an unrecognised
ladder fallthrough.
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.
Schema (migration 0063, additive):
- mood_entries.note (TEXT, nullable)
Validation + routes:
- createMoodEntrySchema gains optional note (max 500 chars)
- updateMoodEntrySchema accepts note + null to clear
- POST /api/mood-entries persists note alongside tags
- PUT /api/mood-entries/[id] honours partial note updates
- POST /api/mood-entries/bulk carries note through the bulk path
Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.
iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
R-E C-2 server-side. Three additive columns + three new endpoints
let the iOS SyncMode store pair, drain, and reconcile against a
fresh paired-server.
Schema (migration 0062, additive):
- Measurement.sync_version (Int, default 1) for last-writer-wins
- Measurement.deleted_at (Timestamp, nullable) for soft-deletes
- User.last_synced_at (Timestamp, nullable) for the handshake
checkpoint
Endpoints:
- GET /api/sync/state — handshake response (lastSyncedAt, server
clock, live + tombstoned measurement counters); call also bumps
the checkpoint so iOS reads the OLD value then trusts subsequent
writes
- POST /api/mood-entries/bulk — UPSERT per entry keyed by
(userId, date, moodLoggedAt); probe-then-upsert so the response
reliably reports inserted vs duplicate; rate-limited at 60/min
- POST /api/medications/intake/bulk — INSERT per entry with
P2002-on-idempotencyKey → duplicate; up-front user-owns-medication
check skips foreign rows with a documented reason
iOS contract: every change is additive. Existing per-sample POSTs
keep round-tripping (sync_version defaults to 1 server-side;
deleted_at stays NULL on every existing read path; last_synced_at is
opt-in). The new endpoints are net-new — no iOS consumer yet.
Tests cover the handshake checkpoint advance, live-vs-tombstoned
counters, clean batch ingest, re-post upsert semantics, ownership
filtering, idempotency-key collision, and over-cap rejection — all
against the real-Postgres container.
Idempotent maintenance tool that collapses pre-Option-A per-sample
APPLE_HEALTH cumulative rows (steps, active energy, flights climbed,
walking + running distance, time in daylight) into one row per day
per type, keyed by the locked dailyStatsExternalId shape. Per
user × type × calendar-day (anchored to User.timezone): SUM the values,
UPSERT a daily row at canonical midday-local (matching the Withings
activity-sync convention), DELETE the per-sample rows.
Three entry points share src/lib/measurements/drain-per-sample-cumulative.ts:
- Library helper for unit + integration tests
- CLI at scripts/drain-per-sample-cumulative.ts with --user / --all
+ --confirm flags (default dry-run)
- Admin endpoint POST /api/admin/drain-per-sample-cumulative gated
by requireAdmin() with dryRun defaulting to true
Operator runs the drain once after the iOS TestFlight build adopts
HealthKitStatisticsService.swift. Re-running on a fully-collapsed
account reports zero buckets touched.
Tests cover bucketing semantics, the canonical-timestamp math across
DST + half-hour zones, idempotent re-runs, dry-run preview, spot-metric
exclusion, and already-collapsed-row skip via real-Postgres.
R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside
the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints
"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one
row per day per cumulative type via the existing batch endpoint, the
unique index collapses re-syncs idempotently, and a future PATCH-on-
divergence call updates a late-watch-sync revision without inserting a
second row. Handoff docs (06 + 08) lock the shape for the iOS client.
Cumulative-type set unchanged from v1.4.29; spot metrics keep the
per-sample uuid externalId. Server tolerates both shapes during the
cutover window — the drain script (next commit) collapses pre-existing
per-sample rows post-cutover.
Tests cover the helper round-trip, the cumulative-type identifier wall,
and the iOS-trusted date-string passthrough.
Single onboarding doc the iOS contributor (or their automation) reads
once and then references continuously. Covers: the release model
(v1.5.0 is version-bump-only marker), every convention every commit
must honour, what's live in production today (v1.4.29.1), what
incoming patches deliver through to web freeze, and the locked
forward state at v1.5.0 — every endpoint + DTO + Prisma column + new
MeasurementType the iOS app can rely on consuming by ship date.
Maintainer directive 2026-05-16: the iOS TestFlight build is already
posting per-sample cumulative rows; the daily-stats cutover cannot
wait for a v1.5 sprint. v1.4.30 now ships the R-A server-side helper
plus a one-shot drain script so the iOS TestFlight build can adopt
`HKStatisticsCollectionQuery` immediately. The remaining web patches
slide one slot — v1.4.31 picks up the assistant toggles + insights
blocking + Coolify auto-deploy fix, v1.4.32 + v1.4.33 carry the
HealthKit Tier 1 surfaces.
Daily-step aggregation hotfix shipped same day as v1.4.29; closure
stanza records PR #173, squash SHA 945b5566, tag, and the SSH
host-side fallback that finished apps01 rollout after Coolify
auto-deploy stalled for the third release in a row.
Daily-step aggregation hotfix. The dashboard 7-day step chart now renders daily totals (sums in the thousands), not per-sample averages, by routing cumulative HealthKit types through the sum branch of the client-side daily aggregator.
Daily-step aggregation hotfix. The dashboard 7-day step chart now
renders daily totals (sums in the thousands), not per-sample
averages, by routing cumulative HealthKit types through the
sum branch of the client-side daily aggregator.
The 7-day window on the dashboard chart still pulls raw rows from
`/api/measurements` (the server-side `aggregate=daily` path kicks in
only for windows over a week). The client-side daily aggregator then
reduced every per-day bucket with `sum / count`, which averages step
counts across the per-sample rows Apple Health emits instead of
summing them. Steps on a 7-day view rendered as the per-sample
average (hundreds), not the daily total.
The reduction now branches on `CUMULATIVE_HK_TYPES` (the same set
that drives the server-side SQL aggregator in v1.4.29): cumulative
types reduce with `sum`, spot metrics keep the `sum / count`
average.
v1.4.29 ships dashboard performance + chart polish on both hosts.
Two new findings surfaced during the window: an /insights tab-strip
blocking root cause and an assistant-optional operator toggle plan.
Both land as v1.4.30 scope add-ons alongside the iOS server-prep menu.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
Three small fixes that compound on a power-user dashboard mount:
- **H2**: The per-context glucose summaries walked every persisted
`BLOOD_GLUCOSE` row a multi-year user has written. The summaries
only feed the trailing tile, so bound the read to the last 30
days.
- **M1**: The BP-in-target chunked walk passed no `since` bound
even though `computeBpInTargetWindows`'s longest sub-window is
`priorYear`. Pass a 365-day floor through both
`fetchMeasurementSeriesChunked` calls.
- **M5**: The three inline dashboard `useQuery` blocks shared no
`staleTime` / `refetchOnWindowFocus` settings, so a tab-focus-
and-return triggered an immediate refetch storm. Lift a shared
`DASHBOARD_QUERY_OPTS` object so every query inherits the
1-minute staleness window the chart queries already use.
iOS-neutral. No contract change; the analytics response shape
stays identical.
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.
Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.
Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.
The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
The dashboard tile strip enforces equal heights at `sm:` and above
via `grid auto-rows-fr` but lets each tile take its content height
at `<sm`. Sibling drift of 16-24 px appears whenever the comparison
callout or the sub-row pair paints on one tile and not on its
neighbour (the glucose tiles ship without `trend7Delta` /
`compareDelta`, making this the visible case).
Pin a 140-px contract at `<sm` and let it release back to `auto`
from `sm:` upwards:
- Strip wrapper carries `[--tile-h:140px] sm:[--tile-h:auto]`.
- Each tile cell consumes `h-[var(--tile-h)] sm:h-auto`.
- Inside `<TrendCard>`, the comparison-callout slot reserves
`min-h-[18px]` even when empty and clamps to `line-clamp-1`
at `<sm`.
- Sub-row pair switches to `flex-nowrap overflow-hidden` at `<sm`
(clipping over wrapping) and releases to `flex-wrap` from `sm:`.
A new snapshot test pins the contract: callout slot reserves the
minimum height, callout clamps to a single line, sub-row pair
renders with `flex-nowrap` + `overflow-hidden`.
Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.
Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.
The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
`useChartOverlayPrefs` keyed under `["dashboard-layout"]` while the
dashboard page, Settings → Dashboard, every other surface that calls
the same `/api/dashboard/widgets` endpoint, and `queryKeys` itself
all use `["user", "dashboardWidgets"]`. Two cache slots for one
endpoint — `/api/dashboard/widgets` fired twice on dashboard mount
and the overlay-prefs cache never warmed from the primary fetch.
Switch the hook to `queryKeys.dashboardWidgets()` so both readers
share the same slot, eliminating the duplicate request and closing
a stale-data corner case where one slot updated and the other
didn't.
The chart fetcher always pulled raw rows from `/api/measurements` —
up to ~5 000 pulse samples per range-tab change on a continuous-
monitoring account. The server's `aggregate=daily` SQL path lands
in v1.4.29 C2; this commit teaches `<HealthChart>` to ask for it
on every window over 7 days. Short windows (7-day overview) keep
raw fetching so hour-by-hour detail stays visible.
Effect on a 30-day pulse view: payload drops from up to ~5 000
rows to one row per day. The client-side `dailyAggregates` Map
becomes a no-op on already-bucketed rows.
Range test gains two assertions: the default 30-day window asks
for `aggregate=daily`; the `windowOverride="last7days"` mini-chart
omits the parameter.
`aggregate=daily|weekly|monthly` averaged step counts (and every
other cumulative HK type) instead of summing — partial-day rows
collapsed to a per-bucket mean rather than the daily total.
Five rows of 1000 steps reported 1000, not 5000.
Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the
five cumulative MeasurementTypes the iOS / Withings ingest path
emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks
`SUM(value)` for those types and keeps `AVG(value)` for every spot
metric (BP, pulse, weight, BG, body fat, mood, sleep).
Two new integration cases pin the contract: a step day collapses
to the per-row sum; a pulse day still averages.
The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements`
500'd in production for every grain. Two root causes that the
mocked-`$queryRaw` unit suite hid end-to-end:
1. `date_trunc(${truncUnit}, …)` passed the grain as a bound
parameter; Postgres rejects — the unit argument must be a SQL
literal. Switch to `Prisma.raw` for the unit string; safe because
the grain is upstream-restricted to the `AggregateGrain` Zod enum.
2. The `daily → day` mapping ignored the `weekly` and `monthly`
forms; Postgres rejects those as units. Map all three grains to
the singular Postgres unit (`day` / `week` / `month`).
The user-id cast `${type}::"MeasurementType"` also referenced a
non-existent type name; the actual Postgres enum is `measurement_type`.
Corrected alongside.
Add `tests/integration/measurements-aggregate-daily.test.ts` against a
real Postgres so every grain compiles + executes end-to-end. The
mocked unit suite stays for the route-shape contracts.
v1.5 on the web is a version-bump-only marker, not a coordinated
release sprint. Every functional change the iOS client depends on
lands in v1.4.29 -> v1.4.30 -> v1.4.31 -> v1.4.32 ahead of the iOS
app shipping. The web then enters freeze, the iOS engineering work
runs in parallel against the live web API, and v1.5.0 ships the day
after Apple review clears.
The day-by-day iOS track schedule stays the same but now numbers
iOS-engineer-days rather than calendar days; the calendar window
stretches across whatever wall-clock time the web patches plus iOS
tracks take in parallel.
Six parallel research outputs feeding one synthesized plan:
step aggregation shape, dashboard performance audit, mobile tile
symmetry, settings drag-list compactness, iOS planning state, and
HealthKit integration depth. The plan closes the v1.4 web line in
one patch and frames the 10-working-day iOS sprint that ships v1.5.
The metadata-action semver patterns only match `vMAJOR.MINOR.PATCH`,
so a tag push for a hotfix like `v1.4.28.1` produced only the
`sha-<short>` manifest and no version-named tag. Deploys that pull
`ghcr.io/mbombeck/healthlog:1.4.28.1` then fail with a `manifest
unknown`.
Adding `type=match,pattern=v(.+),group=1` publishes the tag with the
leading `v` stripped, matching the established three-segment naming
on prior releases. `type=ref,event=tag` is a belt-and-braces fallback
that also keeps the raw tag (`v1.4.28.1`) addressable.
## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix
"Speichern" on the Dashboard works again for every account whose
layout was last persisted before v1.4.28. The v1.4.28 retire of the
`glp1` widget id left an orphan entry in `dashboardWidgetsJson` for
legacy users, which made the save validator reject the payload; the
resolver now filters unknown widget ids on read.
### Fixed
- **Dashboard save against legacy layouts.**
`resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops
widget ids that are no longer in the registry before returning
the layout, so the `dashboardWidgetsJson` round-trip
(`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`)
succeeds even when the persisted JSON still names `glp1` or any
other retired widget.
### Tests
- `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the
retired-id filter (19/19 green).
Document the root cause of the v1.4.28 `offlineGeoEnabled: false`
shape (two layered silent-fallback bugs in the fetch script and
workflow), the script + workflow changes that close the gap, the
local smoke-test results, and the operator action needed for the
v1.4.28.1 hotfix build.
v1.4.28 shipped with `offlineGeoEnabled: false` on both production
hosts because the Fetch step could silently emit the `.empty` marker
and the workflow trusted the script's exit code. The script-side
hardening in the previous commit closes the fail-loud gap for download
errors; this commit adds a workflow-side belt-and-braces check so a
stale `.empty` marker or a workspace-cache surprise cannot survive
into the image either.
New "Verify GeoLite2 databases" step runs on each platform leg after
the Fetch step and before `docker/build-push-action`. When
`MAXMIND_LICENSE_KEY` is set it asserts both MMDBs exist in
`assets/geolite2/` with non-zero size and that no `.empty` marker
survived; either failure exits the leg with a clear `::error::` line.
When the secret is unset the step short-circuits to a notice — the
intentional "ship without offline tier" path stays usable.
`secrets.*` is not allowed in step-level `if:` expressions, so the
gate is implemented via env-surfaced presence check inside the step.
Validated with `actionlint`.
Co-references the live-environment shape in
`.planning/round-5-geolite2-ci-fix-report.md`.
When `MAXMIND_LICENSE_KEY` is unset the script still drops the `.empty`
marker and exits 0 — that path is the deliberate maintainer override and
the runtime resilience layer in `src/lib/geo.ts` carries it. When the
key IS set, every failure mode now bubbles up:
- A curl error (401 wrong-key, 403 throttle, 5xx upstream, network
drop) returns the curl exit code instead of swallowing it into a
fallback `.empty` marker. The diagnostic names the most common
causes so the operator can act without re-reading the script.
- A tar extraction failure or a missing MMDB inside the tarball
returns 1 with a matching message.
- A final assertion at end-of-script confirms both
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` ended up non-empty in
the output directory; otherwise exit 1.
Smoke-tested locally: no-key invocation still exits 0 with the `.empty`
marker; a deliberately bad key now exits 22 with the curl 401 surfaced.