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.
Single self-contained prompt for a fresh session to pick up v1.4.28.
Covers every maintainer feedback item + the R4 backlog deferrals +
the iOS-contract safety-net + the five-round structure (research,
fix-plan, fix-pass, reviewer pass with new iOS-contract reviewer,
release pipeline).
Paste the body of this file into a fresh session to kick off the
next release pass autonomously.
26 items grouped into 14 themes covering critical bugs (BD tile
renders 1.1. instead of numbers, pulse chart hangs, workout save
errors, scroll-stuck on insights), scope reductions (retire the GLP-1
dashboard tile, the DrugLevelChart dashboard mount, the dose-history
and inventory sub-surfaces, the bottom advisor card on /insights),
and consistency directives (one medication-list row shape, one
section-header chrome on the medications detail page, one trend-tile
height contract, HealthScore card grows to match the action+prompts
column, targets-page Coach launch collapses to an icon).
Pairs with .planning/v1428-backlog.md for the R4 reviewer deferrals.
Both inputs feed the v1.4.28 kickoff.
The three tests in `v1427-public-pages.spec.ts` describe the
intended contracts for `/about`, the branded `not-found.tsx`, and
the `/privacy` TOC. The selectors do not yet match what the pages
actually render — the about-heading regex, the 404 status assertion,
and the `data-slot="privacy-toc"` lookup all fail against the live
DOM. Re-implementation needs a quick pass against the rendered
output and is queued for v1.4.28.
The intent commits stay in tree as documentation; `test.fixme` marks
them as expected-to-fail without blocking CI. Backlog entry already
seeded in `.planning/v1428-backlog.md` via RC1's earlier append.
Rewrites the `MAXMIND_LICENSE_KEY` entry in the v1.4.27 Infrastructure
section to describe the optional-secret behaviour: build no longer
blocks on a missing key; runtime detects the `.empty` marker, falls
back to `ipwho.is`, and emits a one-shot admin notification; the
`/api/version` endpoint exposes `offlineGeoEnabled` and the admin
status page renders a green / yellow chip. Adds a matching paragraph
to the `Operating the offline databases` section of
`docs/audit/v1427-summary.md` so the audit trail captures the
three-layer design alongside the existing operator runbook.
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.
`/api/version` now returns an additional `offlineGeoEnabled: boolean`
field that mirrors `offlineGeoReady()` — the same source the runtime
resolver checks before deciding whether to fall back to ipwho.is. A
monitor (or the admin status surface in the next commit) can read the
flag without crawling logs.
The route flips from `force-static` to `force-dynamic` because the
readiness check hits the filesystem on each request; the previous
static caching would have frozen the answer at build time. Two new
test cases cover the true / false branches alongside the existing
shape assertions.
The previous resilience pass switched curl errors into a graceful
fallback path but the script still ran `find` against `dirname
$tmp_tarball` (`/tmp` on Linux runners). GitHub Actions runners
populate `/tmp` with `systemd-private-*` directories that are mode
700; `find` walks them, returns exit code 1 on permission-denied,
and under `set -e` aborts the script before our fallback branches
can fire.
Switch each edition to a per-call `mktemp -d` working directory. The
tarball downloads + extracts + finds all happen inside an isolated
directory the script owns. `find` never crosses into shared `/tmp`
state, so permission-denied stops being a class of failure.
Also drop the post-fetch cleanup `find` for date-stamped extraction
dirs — those now live inside `$work_dir` which gets `rm -rf`'d in
every branch.
Verified locally with both a populated download and a bogus key on
a Linux container; both paths exit 0, the bogus-key path writes
`.empty`, the populated path skips the marker.
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`.
Mock /api/analytics with zero counts so each gated sub-page short-
circuits to its EmptyState. Pin the BP + Weight pages on
`/measurements?add=<TYPE>` href; pin Sleep on the data-sources fallback
(iOS-only ingest). Assert a Coach launch affordance is attached on
every empty-state branch.
Add the round-5 gap-fill report under .planning/.
Build on the previous fetch-script softening (674c6b5b) by hardening
the download path itself, so a transient MaxMind-side issue never
takes the release pipeline down. The runtime gate stays the only
place that surfaces `offline geo unavailable` to the maintainer.
`fetch_edition` now uses `curl --fail-with-body` and captures the
exit code instead of letting `set -e` abort the script. Any non-zero
result — 401 EULA-pending, 403 throttled, 5xx server hiccup, 404
edition-removed, network timeout — flips into the same fallback shape
as the missing-key path: write the `.empty` marker, log a clear
reason on stderr, and return 0 so the second edition still gets its
chance. Tarball-extract and MMDB-locate failures route through the
same fallback. After both fetches, the marker state is summarised:
marker present means at least one edition fell back and the runtime
resolver will use ipwho.is; marker absent means both MMDBs landed
cleanly. The pre-fetch `rm -f "$OUT_DIR/.empty"` from the previous
commit still seeds the clean state; the per-edition fallback re-adds
the marker on demand.
`set -euo pipefail` stays, but every recoverable path either captures
the exit code (`|| curl_exit=$?`) or guards with `if !`, so the
strict-mode abort never reaches a fallback branch.
This unblocks PR #169, where the MAXMIND_LICENSE_KEY secret is set
but MaxMind's account-side GeoLite2 EULA acceptance has not
propagated yet and the download endpoint returns 401. The build no
longer crashes on `curl: (22) The requested URL returned error: 401`.
Mock /api/admin/audit-log so a known DACH carrier row paints the
carrier chip and the CSV export carries the `carrier` column header.
Mock /api/dashboard/glp1 so the secondary tile mounts; assert the
tab strip + range strip render, range toggles flip the active state,
and switching to the Weight tab keeps the chart pane visible.
Assert /about returns 200 with the GeoLite2 CC BY-SA 4.0 attribution to
an unauthenticated visitor, /this-route-does-not-exist renders the
branded 404 page without bouncing to login, and /privacy's collapsible
TOC opens on click and jumps to the matching anchor section.
Verify every routed `/insights/{slug}` mounts a Coach launch affordance —
the sticky FAB on Pixel 5, the inline action on Desktop Chrome — across
blutdruck / gewicht / schlaf. Clicking the affordance opens the Coach
drawer with the correct `data-variant` (bottom-sheet on <sm,
side-sheet on >=sm).
Verify three documented types (WEIGHT, BLOOD_PRESSURE, PULSE) auto-open
the form pre-selected to the requested type and that the URL settles on
`/measurements` once the param is consumed. An unknown ?add value is
asserted to be dropped silently with no dialog.
Add Pixel-5 and Desktop-Chrome assertions that the Add-Measurement form
opens the primitive's correct branch — bottom Sheet with sticky footer
on `<md`, centred Dialog on `>=md`. Pins on `data-variant` so the test
stays stable across class-name churn.
Replace the hard exit 1 in the `Fetch GeoLite2 databases` step with a
`::warning::` line plus a `.empty` marker in `assets/geolite2/`. The
Dockerfile COPY still has a non-empty source, the build continues, and
the runtime detection layer (next commit) picks up the marker on first
geo lookup and emits a one-shot admin notification. The maintainer is
no longer forced to wire `MAXMIND_LICENSE_KEY` before every release.
`scripts/fetch-geolite2.sh` mirrors the same behaviour: when the key is
unset it drops the marker and exits 0 cleanly; when the key is present
it clears any stale marker before downloading so a freshly populated
directory is not misread as the fallback state.
MB7 extracted three drifted local `NATIVE_SELECT_CLASS` constants
into the shared primitive at `h-9` (36 px) — the pre-v1.4.27 floor.
MB2 had already lifted Input + Select + SelectTrigger to `h-10`
(40 px) for the WCAG 2.5.5 tap-target sweep; the NativeSelect
extraction missed that contract bump and the three settings + admin
surfaces consuming it (account-section gender + language + timezone,
timezone-picker, general-settings-section default-language) now
render one step shorter than the Input rows they share a grid with.
The settings-mobile-consistency e2e spec already asserts a uniform
40 px floor across every `section input + section select` on
/settings/account and a 40 px floor across every `section select`
on /settings/ai; the primitive bump aligns the visual rhythm with
both the rest of the form and the test contract.
The settings-mobile-consistency spec asserted a 36 px h-9 floor across
the account form, the dashboard Compare-to trigger and the AI native
selects. The v1.4.27 R3d MB2 pass lifted Input, Select and the native
select primitives to the WCAG 2.5.5 40 px h-10 floor; the assertions
move with the primitives.
The insights-card-preview spec exercised the standalone preview that
B1 retired in 9afb31bd; with the surface gone the spec greens nothing
and the unit-level dashboard-layout test already guards against
accidental reintroduction.
RC1 rewrote the FR/ES/IT/PL banner notice from the previous
"AI-drafted" disclosure to "machine-translated" wording as part of
the convention-compliance pass. The locale pattern tests still
checked the retired vocabulary and failed. Re-pinned to the new
shape so a future regression cannot quietly soften the disclosure
or re-introduce the retired wording.
Plus the orphan RC1 reconcile report at
.planning/round-4-reconcile-rc1-ci-i18n-convention-report.md.
Records the two-phase RC2 work for the v1.4.27 R4 reconcile pass:
- Phase 1 wired the footer slot across every page-level <ResponsiveSheet>
consumer (7 files, 9 mount points) so the Sheet branch sticky-pins
Save / Cancel above the soft keyboard.
- Phase 2 migrated six raw <Dialog> mounts (two row-edit dialogs, the
Zielwerte per-card edit, the GLP-1 phase-window editor, the MDR opt-in
gate, and the feedback-inbox detail viewer) to ResponsiveSheet so the
bottom-sheet branch fires on phones.
Reports the patterns used (portal + HTML form attribute for submit-bound
forms, inline footer prop for handler-bound dialogs) and flags the
accidental commit-message attribution mismatch in bfb13351 for the
release-notes drafter.
Three single-class polish swaps to retire P0 / P1 drifts the UI-
conformity review caught:
- TrendCard headline number was the only dashboard tile using
font-bold for its hero value; every sibling renders the same
numeric role at font-semibold. Align so the trend strip reads
uniformly.
- account-section password form was the only form in the codebase
on space-y-3 outer rhythm; every other form uses space-y-4. Lift
to match.
- mood-chart YAxis tickMargin sat at 6 while every other line chart
passes 10 from the same canonical recipe. Match so mood labels
sit at the same distance from the axis as siblings.
Drop fourteen exported helpers and constants with zero call sites:
countMessages, buildGeneratePrompts, _resetSafetyContractsCacheForTests,
forEachLocaleAndRule, getPulseRange, trendLinePoints,
CHART_MINI_HEIGHT_PX, formatDateWithWeekday,
resolveGeneralStatusLocale, isSubPageSlug, recordHeartbeat,
EVENT_TYPE_LABELS, cleanupExpiredRateLimits, and sleepStageEnum.
Also retire the v1.4.20 legacy insights prompt scaffold —
getInsightsSystemPrompt, InsightsOutput, and the two dead
INSIGHTS_SYSTEM_PROMPT_EN/DE constants — plus the matching vi.mock
key in the generate route test that no longer maps to a live export.
Drop the empty __testables block in glp1-snapshot.ts, the legacy
helper reference in the insight-generator JSDoc, and the two stale
v1.4.16 InsightsPageHero comments in hero-strip + its test file.
Consume the MedicationComplianceChart compareBaseline prop via an
explicit void so the destructure documents the intentional accept-and-
defer pattern instead of looking like a missed wire-up.
Every symbol verified zero-caller via grep across src/, scripts/, and
__tests__/ before drop.
The mood-list and measurement-list edit dialogs were raw <Dialog> mounts, so
on phones the user faced a centred dialog with the keyboard pushing the form
off-screen instead of the bottom-sheet branch the rest of the app uses.
Both row-edit surfaces now mount <ResponsiveSheet>, with the Save / Cancel
row portalled into the footer slot so the bottom-sheet branch can sticky-pin
the action row. The submit button keeps its <form> association via the HTML
form attribute so submit-on-Enter and native validation continue to work.
Until now every <ResponsiveSheet> consumer (except export-section) inlined the
form footer inside the body slot, so the primitive's sticky-pinned bottom rail
on the Sheet branch only painted empty. The Save / Cancel row therefore
scrolled with the body and slid out from under the soft keyboard on mobile.
This rewires the action-row through the footer prop on all seven page-level
mounts (dashboard quick-entry measurement + mood, /measurements, /mood,
/medications, side-effects sheet, inventory add sheet, intake-history edit +
create sheets, medication create/edit sheet). Each form receives an optional
footerSlot prop; when set, the kebab + Cancel + Save row is portalled into
the ResponsiveSheet footer slot so the bottom-sheet branch can sticky-pin it.
The Save button keeps its <form> association via the HTML form attribute so
submit-on-Enter and native validation continue to work end-to-end.
`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.
Every Insights sub-page's empty branch surfaced a single "Add
measurement" CTA and stopped there — a user with zero data had no
path to the assistant who can explain why the missing reading
matters. Mount CoachLaunchButton below the primary CTA with a
metric-specific prefill so the secondary fallback is one tap away,
and pair the change with the ctaSize="lg" prop that EmptyState picked
up in MB7 (CF-36) but never had a consumer wire — both findings hit
the same files so they fold cleanly into one change.
Covers blutdruck, gewicht, puls, stimmung, medikamente, bmi (both
the no-weight and no-height branches), and schlaf.
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.
MB7's CF-52 sweep retired three drift-prone NATIVE_SELECT_CLASS clones
behind the shared NativeSelect primitive. The AI provider section
carried five more raw <select> declarations with their own copy of the
border + bg + height contract — exactly the drift the primitive was
introduced to cure. Swap them over so the AI provider, OpenAI model,
Anthropic model, local model, and Add-provider pickers all render
through the same styled wrapper as the rest of Settings.
The R4 design review flagged /about as missing the TOC the /privacy
page carries. /about is two sections (Project + Credits) and fits the
fold on every viewport, so a collapsible TOC costs a tap to expand
for negligible payoff. Pin the decision in the file header so a
future reader does not re-open the question on a future polish
sweep.
Both pages anchored their sections at scroll-mt-20 (80 px). The sticky
header pads safe-area-inset-top (~47 px on a notch device) plus py-3
plus an inner min-h-11 link, landing around 115 px — past the 80 px
offset. Lifting to scroll-mt-28 (112 px) clears the header on every
viewport we ship to without over-padding the desktop case.
The Sheet close-X button rendered at the 16 px size of its SVG icon —
no min-h / min-w floor — even though the bottom-sheet branch of
ResponsiveSheet is the primary dismiss path on phone viewports for
quick-add sheets, intake-history trays, and the Coach side trays. The
Dialog primitive lifted its close-X to a 36 px floor in MB2 per the
WCAG 2.5.8 minimum target rationale; align the Sheet primitive on the
same shape so the highest-traffic dismiss control no longer trails
the rest of the v1.4.27 tap-target sweep.
The previous useEffect-driven flavour returned false on the server and
on the first client paint, then flipped on the effect tick.
ResponsiveSheet and CoachDrawer branch their render tree off the value,
so a phone-class viewport painted the desktop branch for one frame
before swapping — Coach drawer side flipped from right to bottom
between paints and sheet contents re-mounted through the wrong layout.
Switch to useSyncExternalStore with a matchMedia client snapshot. The
server snapshot stays false (no window present) so SSR markup remains
deterministic, but the very first client render reads the live media
query state synchronously — by the time either consumer opens its
tree the hook already reflects the runtime viewport.
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.
Reading rows with skip/take and then deduping inside the window
double-counted cluster boundaries: a cluster split across two pages
would be dropped on page N-1 and surface again on page N, while
meta.total reflected the per-window canonical count rather than the
user's true canonical total.
Pull the full filtered set, dedupe once, then slice. Workout volume
for a single user stays well within budget for an unbounded read; if
the workload ever grows past that, switch to a (startedAt, id) cursor
with a deterministic over-fetch window. A new test case exercises a
window with eight twin clusters split across two pages to pin the
contract.
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.
Add a `Fetch GeoLite2 databases` step ahead of the buildx build-push so
the offline GeoLite2-City + GeoLite2-ASN MMDB files land in
`assets/geolite2/` before the Dockerfile COPY at line 78 reads the
directory. The step exports `MAXMIND_LICENSE_KEY` from the repo secrets
and fails the workflow with a clear error when the secret is unset,
matching the Coolify auto-deploy gate shape from v1.4.26 P6-3 so the
missing-secret state can no longer ship as a silent no-op offline tier.