The PUT /api/measurements/[id] handler dispatched the Prisma
update without a try/catch, so re-pointing measuredAt onto an
existing (userId, type, measuredAt, source, sleepStage) tuple
bubbled the P2002 violation as a 500 and the row-edit Sheet
rendered the generic save-error toast.
Mirror the POST handler's P2002 catch: return a clean 409 with a
stable `meta.errorCode = "measurement.duplicate_timestamp"` and a
new measurements.duplicateTimestamp i18n key across the six
locales. The mutation in measurement-list.tsx reads the error
code from the response envelope and surfaces the localised
banner instead of the upstream English fallback.
iOS contract — PATCH /api/measurements/{id}: additive. The 200
happy-path response shape (MeasurementWireDTO), the 422
validation path and the updateMeasurementSchema are untouched.
The new 409 path is strictly clearer than the 500 it replaces.
Both the `/admin` overview snapshot and the full `/admin/system-status`
page now read `offlineGeoEnabled` from `/api/version` and render a
status row with a green chip when the offline databases are loaded or
a yellow chip with the configuration hint when the runtime is on the
ipwho.is fallback. The row only renders when the field is present so
legacy `/api/version` responses do not produce a placeholder slot.
Adds six locale keys per bundle (`admin.offlineGeoLabel`,
`offlineGeoEnabled`, `offlineGeoFallback` plus the three
`admin.overview.snapshotOfflineGeo*` siblings) across all six
locales.
The runtime gate for the resilient GeoLite2 fallback. On the first
`lookupIpLocation` or `lookupIpAsn` call that has to leave the offline
tier, the resolver checks `offlineGeoReady()` — false if the geo asset
directory carries the `.empty` marker or the City MMDB is absent — and
fires `dispatchLocalisedNotification` to every admin user, exactly
once per process lifetime, with a body that points the maintainer at
the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs.
Adds the two translation keys
`notifications.admin.offlineGeoUnavailableTitle` /
`offlineGeoUnavailableBody` to every locale bundle, locks them down in
the drift-guard, and covers the latch, the marker, the no-admin path
and the ASN fallback with 8 new vitest cases under
`geo-offline-detection.test.ts`.
`dashboard.glp1.lastInjection`, `nextInjection`, `weightDelta`, and
`inDays` were translated to DE only in the v1.4.27 dashboard rebuild
— the FR/ES/IT/PL bundles still held the EN strings verbatim and
rendered "Last injection" / "Next injection" / "since start" / "in
{count} days" on the live tile. Translate all four keys into native
phrasings:
- FR: `Dernière injection` / `Prochaine injection` / `depuis le début` /
`dans {count} jours`
- ES: `Última inyección` / `Próxima inyección` / `desde el inicio` /
`en {count} días`
- IT: `Ultima iniezione` / `Prossima iniezione` / `dall'inizio` /
`tra {count} giorni`
- PL: `Ostatnia iniekcja` / `Następna iniekcja` / `od początku` /
`za {count} dni`
The other seven keys in the `dashboard.glp1.*` namespace already ship
correctly across every locale, so this closes a copy-paste residue
rather than a missing namespace.
The earlier translation pass dropped the surrounding spaces around the
connector word on ten counter / pagination keys — the result was
`Page 2 de10` / `Page 2 z10` rendering on every FR/ES/IT/PL
`pageInfo`-shaped surface plus the admin pagination, onboarding tour
step pill, getting-started progress chip, target consistency chips,
and target weekly summary headline. Restore the spaces and translate
the surrounding nouns natively at the same pass:
- pageInfo (measurements / mood / medications), `admin.section.auditLog.pageOf`
→ FR `Page {page} sur {total}`, ES `Página {page} de {total}`,
IT `Pagina {page} di {total}`, PL `Strona {page} z {total}`.
- `admin.showingEntries`, `onboarding.tour.stepOf`, `gettingStarted.progress`,
`targets.consistency.daysInRange`, `targets.consistency.daysLogged`,
`targets.summary.weekTitle` — translated in full with native verb forms
and balanced placeholder whitespace.
The drift-guard test stays green; a follow-up tightening to assert
`[A-Za-zÀ-ÿ]\{|\}[A-Za-zÀ-ÿ]` returns no match across the bundle would
have caught the original drift and is queued for the next i18n round.
Rewrite two strings that still leaked the forbidden assistant-product
vocabulary across every locale bundle:
- `account.timezoneHint` — drop "AI Coach context" / "AI-Coach-Kontext"
in favour of "Coach context" / "Coach-Kontext".
- `i18n.maintainershipBanner.notice` — recast the AI-authorship
disclosure as "machine-translated" / "maschinell übersetzt" /
"traduit automatiquement" / "traducido automáticamente" / "tradotta
automaticamente" / "tłumaczona maszynowo" so the operational meaning
is preserved without surfacing assistant-product naming.
Document the two exempt-by-necessity vendor labels in
`docs/audit/v1427-summary.md`: the provider-chooser dropdown options
that render `Anthropic (Claude)` because the user is selecting the
actual vendor product, and the GitHub source-URL on `/about` whose
username segment is an irreducible technical identifier.
Replace `insights.aiAnalysisTitle` with `insights.advisorTitle` across
all six locale bundles and the four call sites in
`insight-advisor-card.tsx`. EN renders "Personal health advisor", DE
"Persönlicher Berater" matching the card subtitle vocabulary, and
FR/ES/IT/PL pick up native phrasings instead of the English fallback
that previously leaked verbatim on those locales. The card now reads
in the same voice as the rest of the Coach and advisor surfaces.
`measurements.filterByType` shipped in en + de with the
v1.4.27 measurement-list aria-label wiring; the locale-integrity
test failed because the four English-fallback locales (es, fr, it,
pl) had not been backfilled. Add the placeholder English string so
each locale file is key-parity with `messages/en.json` again; real
translations follow in the v1.4.28 locale sweep.
Lift the icon-button and pagination controls on the list + form
surfaces under `/measurements` and `/medications` so every touch
target clears the 44 px WCAG 2.5.5 floor.
- `measurement-list.tsx`: mobile-row edit + delete buttons `size-8`
→ `size-11`; pagination chevrons `size="sm"` → `size="icon"
size-11` with translated `aria-label`; edit-dialog kebab
`h-9 w-9` → `size-11`; `DeleteButton` default `h-8 w-8` →
`size-11`. Empty-state CTAs drop `size="sm"` so they sit at the
default 40 px primitive height.
- `measurement-form.tsx`: reset kebab `h-9 w-9` → `size-11`.
- `intake-history-list.tsx`: desktop + mobile per-row edit + delete
buttons `size-8` → `size-11`; pagination chevrons → `size-11`
icon-buttons; edit-dialog kebab → `size-11`; shared `DeleteButton`
in this file → `size-11`.
- `SideEffectsSection.tsx`: per-entry delete `h-7 w-7` → `size-11`.
- `inventory-section.tsx`: live-pen "mark as in use" / "mark as used
up" pills drop `size="sm"`/`h-7` so they sit at `min-h-11`; past-pen
delete `h-6 w-6` → `size-11` icon-button.
Adds `measurements.previousPage` / `measurements.nextPage` and
`medications.previousPage` / `medications.nextPage` translation keys
so the pagination icon-buttons carry accessible labels in every
locale.
Lands every new translation key referenced by the v1.4.27 buckets B1,
B3, B4 and B5 across all six locale bundles (38 keys per locale):
- dashboard.glp1.tabLevel / tabWeight / tabsAria / rangeStripLabel /
levelUnavailable / weightUnavailable (B1 — drug-level vs weight tab
strip on the GLP-1 dashboard tile).
- admin.carrier / admin.carrierUnknown (B3 — IP-address carrier chip
on the admin login overview).
- insights.emptyState.{bloodPressure,weight,pulse,bmi,mood,medication,sleep}.{title,description,cta}
(B4 — per-metric empty-state cards on the insights sub-pages).
- notifications.admin.{deployFailedTitle,deployFailedBody,testNotificationTitle,testNotificationBody,reminderCheckMissedTitle,reminderCheckMissedBody,reminderCheckOverdueTitle,reminderCheckOverdueBody}
(B5 — admin notification messages routed through the localised
dispatch helper).
- notifications.user.telegramTestBody (B5 — user-facing Telegram test
message localised to User.locale).
Also retires the now-dead charts.moodLabel1..5 chart-axis label keys:
the mood chart and the mood list now consume the same MOOD_LABEL_KEYS
map from a new src/lib/mood/labels.ts so the chart-axis copy stays in
lockstep with the list (BL-P6-11). Three distinct copy sets for the
same five mood states drifted twice during v1.4.18 / v1.4.25
polishing; the shared resolver locks them together going forward.
New drift-guard test at src/__tests__/i18n-drift-guard.test.ts pins
the GLP-1 tab, admin carrier, insights empty-state, admin/user
notification, and personal-record key sets so a contributor who only
edits one bundle gets a precise failure instead of a 200-line parity
diff. The locale-integrity test continues to assert global parity;
the drift-guard layers on top with call-site-specific coverage.
Adds a new `format` namespace across every locale bundle that
documents the locale's native date / time pattern ordering as
template strings (`{day}.{month}.{year}`, `{month}/{day}/{year}`, …).
DE keeps DD.MM.YYYY with dot separators, EN keeps MM/DD/YYYY with
slashes, FR/ES/IT adopt DD/MM/YYYY with slashes (the previous
EN-shaped fallback was visibly wrong on French weekly-report PDFs),
PL adopts DD.MM.YYYY with dots.
The runtime `makeFormatters()` helper still routes through `Intl.*`;
the keys exist so PDF / CSV / email surfaces that render outside a
React context can still pick the right ordering hint.
A new smoke test at
`src/lib/i18n/__tests__/format-locale-order.test.ts` pins the
ordering per locale so a copy-paste regression can't silently flip
DD/MM to MM/DD on a non-English bundle.
Sweeps 154 dead keys per locale bundle (~924 strings across six
locales) flagged by the v1.4.27 dead-key audit. Drops the
insights.*Status* trio (loading / noKey / unavailable) across six
metrics, the legacy `insights.aiInsights` / `dashboard.insightsPreview`
roots that B1 replaced with the gated tab strip, the
`insights.healthScore.askCoach` / `coachPrompt` pair retired with the
inline button, the orphan onboarding.v2.* stub set, plus the
ad-hoc `medications.glp1.drug.*.class` and `notifications.eventPersonalRecord*`
strings that lost their callers in v1.4.25/W17.
Also drops the stale prefill-format test under
`health-score-card.test.tsx` that asserted the now-removed coachPrompt
key shape — the negative-assertion test for the retired inline button
stays in place.
The locale-integrity test already validates the new shape: no
re-baselining needed because the test asserts parity + no-empties
rather than a key-count threshold.
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.