mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.4.28.1
## [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).
This commit is contained in:
1 parent
2d111fcb24
commit
232ea43d99
27 files changed
+3662
-167
No files matched your search
@@ -103,17 +103,24 @@ jobs:
|
||||
# downloads both editions into `assets/geolite2/` so the Dockerfile
|
||||
# COPY at line 78 picks them up.
|
||||
#
|
||||
# The build is intentionally resilient to a missing
|
||||
# `MAXMIND_LICENSE_KEY` secret: when the key is unset we emit a
|
||||
# `::warning::` line, drop a `.empty` marker into the assets
|
||||
# directory so the Dockerfile COPY still has a non-empty source,
|
||||
# and continue cleanly. The runtime resolver in `src/lib/geo.ts`
|
||||
# detects the marker on first lookup and emits a one-shot
|
||||
# admin notification — the maintainer hears about the gap from
|
||||
# the running app rather than from a red CI badge that blocks
|
||||
# every release. To enable the offline feature, set the secret
|
||||
# under repo Settings → Secrets and variables → Actions and
|
||||
# redeploy.
|
||||
# Two intentional paths:
|
||||
# - `MAXMIND_LICENSE_KEY` unset → emit a `::warning::`, drop a
|
||||
# `.empty` marker into the assets directory so the Dockerfile
|
||||
# COPY still has a non-empty source, and continue cleanly. The
|
||||
# runtime resolver in `src/lib/geo.ts` detects the marker on
|
||||
# first lookup and emits a one-shot admin notification — the
|
||||
# maintainer hears about the gap from the running app rather
|
||||
# than from a red CI badge that blocks every release.
|
||||
# - `MAXMIND_LICENSE_KEY` set → run `fetch-geolite2.sh` and
|
||||
# fail the build if it errors. The "Verify GeoLite2 databases"
|
||||
# step below adds a belt-and-braces assertion that both MMDB
|
||||
# files landed in `assets/geolite2/` before the Dockerfile
|
||||
# COPY reads the directory, so v1.4.28-era silent fallback
|
||||
# (offlineGeoEnabled: false on both nodes after a tag build)
|
||||
# can no longer ship.
|
||||
#
|
||||
# To enable the offline feature, set the secret under repo
|
||||
# Settings → Secrets and variables → Actions and redeploy.
|
||||
- name: Fetch GeoLite2 databases
|
||||
env:
|
||||
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||
@@ -126,6 +133,42 @@ jobs:
|
||||
fi
|
||||
./scripts/fetch-geolite2.sh
|
||||
|
||||
# Build-time assertion: with the license key present, both MMDB
|
||||
# files must land in `assets/geolite2/` before the Dockerfile
|
||||
# COPY at line 78 picks them up. The fetch script already fails
|
||||
# loud on a curl / tarball error, but a stale `.empty` marker
|
||||
# from a previous keyless run or a workspace-cache surprise could
|
||||
# otherwise survive into the image — this step is the operator
|
||||
# guarantee that "key set" really means "MMDBs in the image".
|
||||
#
|
||||
# `secrets.*` is not available in step-level `if:` expressions,
|
||||
# so we surface the presence flag through env and gate inline.
|
||||
- name: Verify GeoLite2 databases
|
||||
env:
|
||||
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||
run: |
|
||||
if [ -z "$MAXMIND_LICENSE_KEY" ]; then
|
||||
echo "MAXMIND_LICENSE_KEY unset — verification skipped (offline geo tier intentionally absent on this build)."
|
||||
exit 0
|
||||
fi
|
||||
missing=()
|
||||
for mmdb in GeoLite2-City.mmdb GeoLite2-ASN.mmdb; do
|
||||
if [ ! -s "assets/geolite2/$mmdb" ]; then
|
||||
missing+=("$mmdb")
|
||||
fi
|
||||
done
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
echo "::error::MAXMIND_LICENSE_KEY is set but the following GeoLite2 MMDB files are missing or empty in assets/geolite2/: ${missing[*]}. The Dockerfile would COPY them into /opt/geolite2/ in the final image, leaving the offline geo tier disabled at runtime. Investigate the previous 'Fetch GeoLite2 databases' step before retrying."
|
||||
exit 1
|
||||
fi
|
||||
if [ -f assets/geolite2/.empty ]; then
|
||||
echo "::error::Stale .empty marker present in assets/geolite2/ despite MAXMIND_LICENSE_KEY being set. The runtime resolver would treat the image as missing the offline tier. Investigate the previous 'Fetch GeoLite2 databases' step before retrying."
|
||||
exit 1
|
||||
fi
|
||||
city_size="$(stat -c%s assets/geolite2/GeoLite2-City.mmdb)"
|
||||
asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)"
|
||||
echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes."
|
||||
|
||||
# Per-ref + per-platform gha cache scope. Tag builds warm-start
|
||||
# off main's last good layer set for the same platform; tag and
|
||||
# main builds at the same SHA do not contend on writes, and
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-code.md
|
||||
purpose: R4 code review of the v1.4.28 diff vs v1.4.27 — 30 commits, severity-grouped findings
|
||||
created: 2026-05-16
|
||||
contributor: R4 code-review
|
||||
---
|
||||
|
||||
# v1.4.28 R4 — code review
|
||||
|
||||
Scope: 30 commits `948fcd93..HEAD` on `develop` (post-kickoff). Bug-fix sub-pass (FB-B1/C1/D2/D3), six retirements, consistency sweep, perf instrumentation. Review is read-only; findings are sorted by severity with a fix-shape suggestion per item.
|
||||
|
||||
## Critical
|
||||
|
||||
### R4-CODE-C1 — Aggregated `/api/measurements` truncates before bucketing
|
||||
File: `src/app/api/measurements/route.ts:67-86` (commit `b00be286`)
|
||||
The aggregation branch fetches `prisma.measurement.findMany({ take: limit, orderBy: { measuredAt: "asc" } })` then bucketises in memory. With `orderBy asc` the server returns the earliest `limit` rows in the window — for a Pulse-every-minute account selecting a 365-day window with `limit=5000`, only ~3.5 days of raw data feed the aggregator and the chart paints 3 daily buckets instead of 365. The whole point of FB-D2 is high-density data; the aggregation defeats itself on the exact accounts it targets.
|
||||
Fix shape: drop `take`/`skip` from the aggregation branch (the bucket count is already bounded by the window length, never exceeds ~52 weekly or ~365 daily rows per type), or push aggregation into SQL via `date_trunc` + `GROUP BY`. Add a regression test that seeds 30 000 rows across a 365-day window and asserts the response bucket count equals the calendar-day count, not `min(limit, count)/grain`.
|
||||
|
||||
### R4-CODE-C2 — Aggregation auto-triggers on any wide window, breaking the documented iOS contract
|
||||
File: `src/app/api/measurements/route.ts:56` + `src/lib/measurements/range-aggregation.ts:44-46` (commit `b00be286`)
|
||||
The commit message asserts the aggregated branch fires only when "from + to are present AND the window is wide enough" — but the implementation fires on `from && to && pickAggregateGrain(...) !== "raw"`, and `pickAggregateGrain` auto-promotes to `daily` past 90 days even without an explicit `aggregate` param. iOS currently calls `/api/measurements?limit=400` without `from`/`to` and stays safe, but a future iOS update that adds date filtering for a > 90-day drill-down silently receives the aggregated wire shape (`{ type, value, measuredAt, count }`) instead of the locked `MeasurementWireDTO`. The `.planning/v15-ios-handoff/08-locked-contracts.md` shape for `GET /api/measurements` is `Measurement[]` with full row fields.
|
||||
Fix shape: gate the aggregation branch on `aggregate !== undefined` (caller must opt in explicitly); keep the auto-grain promotion as a hint the chart applies client-side. Add an integration test that asserts `/api/measurements?from=…&to=…` without `aggregate` still returns the raw DTO shape regardless of window width.
|
||||
|
||||
### R4-CODE-C3 — Web-vitals beacon is an unauthenticated log-injection vector
|
||||
File: `src/app/api/internal/web-vitals/route.ts:45-76` + `src/components/monitoring/web-vitals-reporter.tsx:21-49` (commit `ebf83b1e`)
|
||||
The route accepts unauthenticated POSTs of arbitrary `{ name, value, rating, navigationType }` and forwards `name`/`rating`/`navigationType` strings verbatim into `annotate({ meta })`. The header comment claims "CSP + same-origin policy keep cross-site reporting out" — neither does. `sendBeacon` issues a simple POST with no CORS preflight; any external page can flood `/api/internal/web-vitals` with custom `name` payloads and pollute the wide-event log. There's also no rate limit ("deliberately none") and the route is publicly indexable.
|
||||
Fix shape: restrict `name` to the documented web-vitals identifier set (`CLS|FCP|FID|LCP|TTFB|INP`); reject unknown names early; clamp `value` to a sane numeric range; add a session-cookie check or a same-origin Referer guard at the route boundary. Web Vitals don't need auth on a logged-in user — but they do need an attestation that the caller came from a HealthLog page.
|
||||
|
||||
## High
|
||||
|
||||
### R4-CODE-H1 — Mislabelled commit `0e7c97c5` bundles FB-K trend-row work under a CTA-variant subject
|
||||
File: commit `0e7c97c5` body vs diff
|
||||
The subject claims "align briefing empty-state CTA variant" and the body only describes the daily-briefing CTA. The diff actually rewrites `trends-row.tsx` (FB-K1/K2 equal-height contract), `mood-chart.tsx`, `trend-annotation.tsx` and two new test files — six files, 130 net additions. Atomic-commit hygiene violation; reviewers cannot bisect the FB-K contract from the FB-M2 CTA fix; future `git log -- trends-row.tsx` will mislead.
|
||||
Fix shape: leave as-is for v1.4.28 (rewriting history before tag is destructive) but add a `Note: also lands FB-K1/K2 trend-row equal-height contract` postscript in the release notes; pin atomic-commit discipline in the v1.4.29 kickoff.
|
||||
|
||||
### R4-CODE-H2 — Mislabelled commit `235e52cb` ships Coach drawer + mobile rail tray under a charts subject
|
||||
File: commit `235e52cb` body vs diff
|
||||
Subject: "refactor(charts): single HealthChartDynamic re-export". Diff: 237 net additions across `coach-drawer.tsx`, the new `mobile-rail-tray.tsx`, and its test fixture. Zero chart files. The commit body is missing entirely. Worse atomic-hygiene violation than H1 — the subject is actively misleading.
|
||||
Fix shape: same as H1 — note in release closure; the chart re-export work appears to live in `8f3bfc37` ("collapse health-chart dynamic imports onto re-export"), so the two commit subjects are swapped between two adjacent commits.
|
||||
|
||||
### R4-CODE-H3 — Test-then-fix split across commits leaves intermediate `develop` red
|
||||
File: `1b0e81ae` (icon-only coach launch) → `5570971f` (test update, 23 min later)
|
||||
`1b0e81ae` rewrites `target-coach-button.tsx` to render an icon-only affordance but leaves `targets-responsive.test.tsx` asserting the old `w-full sm:w-auto` text-bearing wrapper. The per-commit gate (`pnpm typecheck` + `pnpm lint` + relevant tests) listed in the kickoff would have flagged the failure. Either the gate did not run, or the test was skipped; either way `develop` between `1b0e81ae` and `5570971f` failed the suite.
|
||||
Fix shape: amend the kickoff convention to "tests update inside the same commit that ships the behaviour change"; cap the time-between-commits monitoring (a 23-minute red `develop` window blocks any concurrent contributor's pre-push hook).
|
||||
|
||||
### R4-CODE-H4 — Forbidden vocabulary "wave-4b" reintroduced into code comments
|
||||
File: `src/components/medications/medication-detail-section.tsx:7` + `src/components/medications/DrugLevelChart.tsx:253` (commit `5109e930`)
|
||||
Both comments use "wave-4b" as a historical bucket identifier. The kickoff explicitly lists `wave` in the forbidden vocabulary. The original v1.4.25 W21 comment ("The four wave-4b sections") was carried forward verbatim during the chrome rewrite; a clean rewrite should have neutralised the bucket name.
|
||||
Fix shape: replace "wave-4b" with the neutral identifier "v1.4.25 W21 medication-detail section family" or drop the bucket reference entirely. Same scan should pass on the rest of the file (no other `wave` matches).
|
||||
|
||||
### R4-CODE-H5 — Forbidden vocabulary "AI" used in new code comments
|
||||
File: `src/app/insights/schlaf/page.tsx:62-72` (commit `8f7cbd49`); `src/components/insights/__tests__/hero-strip.test.tsx:196` (commit `cad53a68`)
|
||||
The kickoff bans the substring `AI`. The schlaf comment ("written per-section AI assessment") and the hero-strip test comment ("the AI schema slot ... are gone") both add the forbidden word in lines that this release authored. Pre-existing `/lib/ai/*` path references are allowed (path identifier exception); free-form comment strings are not.
|
||||
Fix shape: rewrite to "written per-section assessment" / "the assistant schema slot". The same scan should grep `git diff v1.4.27..HEAD` for `\bAI\b` in non-path lines as a release-gate.
|
||||
|
||||
### R4-CODE-H6 — Maintainer first-name reference in commit body breaks the no-PII rule
|
||||
File: commit `9a020f21` body ("FB-I1 — Marc asked for a tappable `?` glyph...")
|
||||
Commit messages on `develop` become the public squash-merge subject + body on `main` and are visible in the GitHub UI for any browser of the repo. The kickoff bans the maintainer's name in user-facing artifacts; the public commit log is user-facing. Compare with `538b44f7`'s test fixture (`username: "marc"`) which is internal test code — different exposure surface.
|
||||
Fix shape: drop the first-name reference, rewrite as "FB-I1 — the maintainer asked for a tappable `?` glyph" or "FB-I1 — surface a tappable `?` glyph". The squash-merge subject + body is the primary touch-point; one-line scrub is enough.
|
||||
|
||||
## Medium
|
||||
|
||||
### R4-CODE-M1 — Aggregated DTO ships `count` field that's not declared anywhere
|
||||
File: `src/app/api/measurements/route.ts:80-86`
|
||||
The aggregated branch invents an inline DTO `{ type, value, measuredAt, count }` directly inside the route — no Zod schema, no `MeasurementAggregateWireDTO` type, no entry in `.planning/v15-ios-handoff/08-locked-contracts.md`. Consumers (the chart, any future iOS update, OpenAPI generator) have nothing to import. The `count` field is forwarded but never spec'd.
|
||||
Fix shape: lift the inline shape to `src/lib/validations/measurement.ts` as `aggregatedMeasurementSchema`; export the TS type; add an `aggregateRow` Zod schema for OpenAPI emission. Document the new shape in `08-locked-contracts.md` before the iOS team consumes it.
|
||||
|
||||
### R4-CODE-M2 — `limit` cap loosened from 500 to 5000 without bumping the locked-contract doc
|
||||
File: `src/lib/validations/measurement.ts:249`
|
||||
The locked-contract excerpt in `.planning/v15-ios-handoff/03-api-contracts.md:305` says `limit: z.coerce.number().int().min(1).max(500)`. The v1.4.28 schema now reads `.max(5000)`. Backwards-compatible for existing callers but a contract drift; the iOS team's `MeasurementListWireResponse` may carry the documented cap.
|
||||
Fix shape: bump the locked-contract doc in lockstep; surface the change in the release notes under "API contract additions".
|
||||
|
||||
### R4-CODE-M3 — Several commits ship without bodies
|
||||
Files: `75773ca0`, `ebf83b1e`, `b0ef80dc`, `8c89ddac`, `8f3bfc37`, `235e52cb`, `d286220b`
|
||||
The kickoff says "Atomic commits per logical sub-task. No 'WIP', 'various improvements', 'end-of-day commit'." — implicitly the body explains the "why" for non-trivial subjects. `ebf83b1e` (274-line bundle-analyzer + web-vitals wire-up) and `8c89ddac` (277-line insights-sub-page consolidation, six pages touched) both ship body-less. `b0ef80dc` (225-line locale cache) is forgiven because the rationale lives in the file header.
|
||||
Fix shape: enforce a per-commit hook that requires a body for diffs over a 40-line / 3-file threshold. Retroactive for v1.4.28 is moot; the release-notes pass can backfill the "why" for the heavier commits.
|
||||
|
||||
### R4-CODE-M4 — FB-E3 (opt-in GLP-1 dashboard widget) silently deferred
|
||||
File: `.planning/v1428-feedback-2026-05-15.md:66` vs. the 30-commit diff
|
||||
FB-E3 is Medium severity in the feedback doc. Two retirement commits (`8e5f71b1`, `8c81af10`, `8c8d6dc2`) reference "an opt-in dashboard widget that surfaces the same level chart will land later in this cycle" — the cycle is v1.4.28, and the widget has not landed. No entry in a v1.4.29 backlog (file does not exist).
|
||||
Fix shape: create `.planning/v1429-backlog.md` and seed FB-E3 with the explicit rationale ("Medium polish item, scope-reduction sub-pass took precedence in v1.4.28"); soften the commit-message wording in the release-notes editorial pass.
|
||||
|
||||
### R4-CODE-M5 — Typed-error pattern repeated inline rather than extracted
|
||||
File: `src/components/measurements/measurement-list.tsx:248-260` (commit `538b44f7`)
|
||||
The mutation casts `as Error & { errorCode?: string; status?: number }` in three places (response parse, throw, onError handler). The same pattern will recur on every mutation that handles a 409 with a structured error code; v1.4.28 sets the first instance. A `class HttpError extends Error { errorCode?: string; status?: number }` (or a `parseApiError` helper) would carry the contract instead of re-casting at every callsite.
|
||||
Fix shape: extract `HttpError` into `src/lib/http-error.ts`; migrate the measurement-list call-site as the first consumer; queue the rest under a v1.4.29 polish item.
|
||||
|
||||
### R4-CODE-M6 — `useMemo` for `fetchWindow` recomputes `new Date()` only on dep change
|
||||
File: `src/components/charts/health-chart.tsx:518-532` (commit `b00be286`)
|
||||
`fetchWindow` calls `new Date()` inside a `useMemo` keyed on `[rangePoints, effectiveCompareBaseline]`. Within a long-lived session (a user leaves the page open for hours), the `to` boundary never advances — the chart paints data up to the time of last range-tab flip, not "now". For most users it's invisible; for a power user staring at the live pulse chart, the right edge slowly stalls.
|
||||
Fix shape: re-key the memo on a 60-second-rounded timestamp (`Math.floor(Date.now() / 60_000)`) so the window snaps to "now" each minute without churning the cache key on every render.
|
||||
|
||||
## Low
|
||||
|
||||
### R4-CODE-L1 — `i18n: add the lastYear coach window key` commit drops the conventional-commit scope parens
|
||||
File: commit `75773ca0` subject
|
||||
Subject reads `i18n: add the lastYear coach window key`. Every other commit in the v1.4.28 range uses `type(scope): subject` (`fix(api)`, `feat(insights)`, `chore(medications)`). The plain `i18n:` form drops `(scope)`. Mild format drift, not a blocker.
|
||||
Fix shape: pin `(i18n)` as the scope label for future locale-only commits; or treat it as the scope itself with a body subject (`feat(i18n): add the lastYear coach window key`).
|
||||
|
||||
### R4-CODE-L2 — Test mock fixture uses the maintainer's first name
|
||||
File: `src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts:39`
|
||||
`username: "marc"` in a mock session fixture. Internal-only (test fixture, never serialised to the client) — the no-PII rule arguably stops at user-facing artifacts. Carried forward from v1.4.27 fixtures.
|
||||
Fix shape: rename to a neutral fixture name (`test-user`) at the next refactor pass; not worth a v1.4.28 follow-up commit.
|
||||
|
||||
### R4-CODE-L3 — Empty body on the perf+web-vitals commit obscures the cumulative LOC impact
|
||||
File: commit `ebf83b1e` body (empty)
|
||||
274-line addition (Next config wrapper + `@next/bundle-analyzer` dep + new internal route + the client reporter). The intent is clear from the subject but the body could carry the "why" (perf-baseline plumbing for the v1.4.29 measure-then-act pass).
|
||||
Fix shape: backfill the body in the release-notes editorial; not actionable on the commit itself without a rewrite.
|
||||
|
||||
---
|
||||
|
||||
## Per-commit notes
|
||||
|
||||
| Commit | Subject | R4 verdict |
|
||||
|---|---|---|
|
||||
| `538b44f7` | fix(api): return 409 on duplicate-timestamp measurement edit | Clean; failing-test-first; iOS-safe |
|
||||
| `b00be286` | perf(charts): bound health-chart fetches to the active range window | **C1, C2, M1, M2, M6** — needs server-side aggregation fix + iOS gate |
|
||||
| `0d591ac9` | fix(insights): cap status-card provider calls at 20s with graceful fallback | Clean; coverage at `pulse-status-timeout.test.ts` |
|
||||
| `ac80c099` | fix(insights): unstick scroll on tab-strip and mother-page navigation | Clean; e2e spec covers both regressions |
|
||||
| `59ef95f2` | fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive | Clean; failing-test-first locks the date-shape regression |
|
||||
| `8e5f71b1` | chore(dashboard): retire the GLP-1 tile | Clean delete; drift-guard updated; iOS audit clean |
|
||||
| `cad53a68` | chore(insights): retire the weekly-report surface | Clean delete; one "AI" remnant cleaned elsewhere |
|
||||
| `52edf85f` | chore(insights): retire the InsightAdvisorCard surface | Clean delete |
|
||||
| `8c81af10` | chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail | Clean delete; iOS DTO byte-stable |
|
||||
| `8c8d6dc2` | chore(medications): drop the Bestand section from GLP-1 detail | Clean delete; iOS DTO byte-stable |
|
||||
| `6f6992c6` | refactor(medications): unify medication-list row shape | Clean; primitive carved out |
|
||||
| `155b529d` | fix(insights): match HealthScore card height to the hero column | Clean; DOM-class assertions, no pixel checks |
|
||||
| `4c6d8779` | refactor(coach): consolidate launch button to inline + layout-FAB shape | Clean; fixes duplicate-FAB a11y issue |
|
||||
| `d286220b` | perf(charts): wire chart-skeleton loading state across dynamic imports | Body empty (**M3**); clean diff |
|
||||
| `1b0e81ae` | fix(targets): make the coach launch an icon-only affordance | **H3** — test update split into a separate commit |
|
||||
| `66e13845` | refactor(coach): narrow launch-scope metric type to the source union | Clean; type-safety win |
|
||||
| `ca381957` | fix(coach): align mobile sheet height to the responsive-sheet convention | Clean |
|
||||
| `7d38a54d` | fix(medications): align side-effects card to the surface convention | Clean (test-only update, paired with `88085615`) |
|
||||
| `9a020f21` | feat(insights): explain the HealthScore delta on tap | **H6** — Marc-name in commit body |
|
||||
| `88085615` | fix(medications): shorten side-effects add CTA across locales | Clean (locale half of FB-F1) |
|
||||
| `235e52cb` | refactor(charts): single HealthChartDynamic re-export | **H2** — content is Coach drawer + mobile rail tray, NOT charts |
|
||||
| `8f3bfc37` | refactor(charts): collapse health-chart dynamic imports onto re-export | Body empty (**M3**); likely the actual charts-rewrite the H2 subject claims |
|
||||
| `5109e930` | refactor(medications): collapse detail-page chrome to one heading scale | **H4** — "wave-4b" comment carried forward |
|
||||
| `0e7c97c5` | fix(insights): align briefing empty-state CTA variant | **H1** — body claims only CTA variant; diff lands FB-K1/K2 trend-row |
|
||||
| `8c89ddac` | refactor(insights): consolidate sub-page data-fetch and empty state | Body empty (**M3**); clean shared hook |
|
||||
| `8f7cbd49` | fix(insights): document the missing sleep status slot | **H5** — "AI assessment" in new comment |
|
||||
| `b0ef80dc` | perf(notifications): cache the dispatch-localised user lookup | Body empty (**M3**) but file header carries rationale; clean LRU + 5 tests |
|
||||
| `ebf83b1e` | feat(perf): wire bundle analyzer and web-vitals beacon | **C3, M3, L3** — unauthenticated beacon, empty body |
|
||||
| `75773ca0` | i18n: add the lastYear coach window key | **L1** — scope parens dropped |
|
||||
| `5570971f` | test(targets): update coach CTA assertion to the icon-only shape | Clean test-only follow-up |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 3 |
|
||||
| High | 6 |
|
||||
| Medium | 6 |
|
||||
| Low | 3 |
|
||||
|
||||
### Top-3 fix recommendations (must clear before the v1.4.28 tag)
|
||||
|
||||
1. **R4-CODE-C1** — drop `take: limit` from the `/api/measurements` aggregation branch (or push aggregation into SQL). The chart silently truncates high-density data on > 90-day windows; FB-D2 is half-fixed without it. Add a 30 000-row regression seed.
|
||||
2. **R4-CODE-C2** — gate the aggregation branch on an explicit `aggregate` param. Without it, any future iOS update that adds date filtering on a > 90-day drill-down silently breaks the locked-contract response shape.
|
||||
3. **R4-CODE-C3** — restrict the web-vitals beacon to known metric names + add a same-origin Referer guard. Currently any external page can pollute the wide-event log with arbitrary strings.
|
||||
|
||||
### Go / no-go
|
||||
|
||||
**No-go on the v1.4.28 tag** until the three Critical findings are addressed. R4-CODE-C1 alone is a regression on the exact perf surface FB-D2 was meant to fix — shipping it would leave the maintainer with a chart that paints fewer points than v1.4.27 on the > 90-day range. R4-CODE-C2 and R4-CODE-C3 are smaller in user-visible blast radius but R4-CODE-C2 violates the v1.5 iOS-safety underlying premise that the kickoff calls out explicitly.
|
||||
|
||||
The High-severity items (H1, H2 mislabelled commits; H3 test-then-fix split; H4-H5 forbidden vocabulary; H6 PII) do not block the tag individually but each warrants a touch-up commit before merge: the vocab + PII items via a `chore(docs): release-notes editorial scrub` pass over commit messages and code comments, and a `Note:` in the release closure noting the H1/H2 subject swap so the v1.4.27 → v1.4.28 changelog reflects what landed where.
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-dead-code.md
|
||||
purpose: R4 dead-code scan — orphans introduced or surfaced by the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 dead-code
|
||||
---
|
||||
|
||||
# R4 — dead-code scan for v1.4.28
|
||||
|
||||
The v1.4.28 sweep landed three surface retirements (`Glp1Tile`, `InsightAdvisorCard`, `WeeklyReportView`) plus two GLP-1 detail-page disclosures (`IntakeHistoryList`, `InventorySection`). The R4-v1.4.27 single-commit recommendation also landed — the 14 orphan exported functions/consts from `v1427-r4-dead-code.md §2`, the `__testables` block in `glp1-snapshot.ts`, the legacy `getInsightsSystemPrompt` helper and the `InsightsOutput` interface are all gone from `src/`. `MedicationComplianceChart.compareBaseline` was re-wired with an explicit `void compareBaseline` discard plus a v1.4.27 RC3 caption (Section 7.1 of the predecessor resolved).
|
||||
|
||||
What v1.4.28 left behind: a small set of orphans surfaced by the retire moves. The `DrugLevelChart.compact` rendering path lost its only mount (the tile is gone, the standalone medication-detail page mounts without `compact`). `shotPhaseAt` in `glp1-pk.ts` has tests but no production consumer. Two component files (`trends-row.tsx`, `vo2-max-chart-row.tsx`) still declare local `ChartSkeleton` constants identical to the shared one the v1.4.28 H4 fix introduced. Five admin/monitoring routes remain orphan (unchanged decision from v1.4.27 §1.1). The `OpenAPI` route description still names the retired weekly-report block.
|
||||
|
||||
No High-tier findings.
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
| Severity | Items | Decision required |
|
||||
|---|---|---|
|
||||
| Medium | **3** | Wire vs drop — each is a measurable LOC reduction |
|
||||
| Low | **9** | Drop-or-keep per item, one-to-three lines each |
|
||||
| Informational | **3** | Pre-existing or intentional; no action |
|
||||
|
||||
---
|
||||
|
||||
## Medium findings (3)
|
||||
|
||||
### M1 — `DrugLevelChart.compact` mode is unconsumed
|
||||
|
||||
`src/components/medications/DrugLevelChart.tsx:123` declares the `compact?: boolean` prop; lines 226, 237, 244, 254, 270, 293, 319, 331 branch on it; lines 115–122 + 228–236 + 461 carry the doc comments. The only consumer was the GLP-1 dashboard tile (retired in `8e5f71b1`). The standalone mount at `src/app/medications/[id]/history/page.tsx:90` does not pass `compact`. `grep -rn "compact=" src/app | grep -i drug` returns zero.
|
||||
|
||||
`compact` mode also pulled in the `windowHoursBefore` override (`DrugLevelChart.tsx:133`), which has the same zero consumers post-retire.
|
||||
|
||||
**Decision:** drop the `compact` branch + `windowHoursBefore` prop + their three doc-comment blocks. Estimated diff: roughly `-90 / +0` LOC across `DrugLevelChart.tsx`. Risk: low — verified no test exercises `compact={true}`.
|
||||
|
||||
### M2 — `shotPhaseAt` is test-only after the tile retire
|
||||
|
||||
`src/lib/medications/glp1-pk.ts:303` exports `shotPhaseAt(drug, doses, asOf)`. Grep for the symbol returns only the declaration plus four test assertions in `__tests__/glp1-pk.test.ts:155–171`. The doc comment at line 292 names "the GLP-1 tile" as the consumer — that tile is gone. The `ShotPhase` type at line 301 is consumed only inside the same module.
|
||||
|
||||
**Decision:** drop `shotPhaseAt` + `ShotPhase` from the module + the matching test block. Sibling exports `computeOneCompartment` + `RESEARCH_MODE_DISCLAIMER_VERSION` + `PkSample` + `OneCompartmentOptions` still serve `DrugLevelChart.tsx` and `/api/auth/me/research-mode/route.ts` — keep them. Estimated diff: roughly `-60 / +0` LOC.
|
||||
|
||||
### M3 — five admin/monitoring routes still orphan (unchanged from v1.4.27 §1.1)
|
||||
|
||||
Re-verified at HEAD:
|
||||
|
||||
| Endpoint | src callers | tests | README | CHANGELOG |
|
||||
|---|---|---|---|---|
|
||||
| `/api/admin/ai-settings` | 0 | 0 | L362–363 | L752, L3261 |
|
||||
| `/api/admin/backup/test` | 0 | 0 | L368 | L752 |
|
||||
| `/api/admin/status-overview` | 0 | self-tests | L367 | L753 |
|
||||
| `/api/monitoring/glitchtip/test` | 0 | self-tests | L381 | — |
|
||||
| `/api/monitoring/umami/test` | 0 | self-tests | L382 | — |
|
||||
|
||||
The v1.4.27 fix-plan parked these in the v1.4.28 backlog (`.planning/v1428-backlog.md:8`). v1.4.28 did not surface a consumer for any of them. Per-endpoint maintainer call still pending: wire-a-consumer (admin status page mount, settings AI panel) vs delete-route-plus-README-line.
|
||||
|
||||
**Decision:** carry forward to v1.4.29 unless the maintainer pulls one into v1.4.28. The status-overview endpoint already has a candidate consumer at `src/app/api/admin/backups/route.ts:63` (comment-only reference); the GeoLite2 v1.4.27 work added a new admin system-status page in `82a23069` that *could* mount `/api/admin/status-overview` if that's the intent.
|
||||
|
||||
---
|
||||
|
||||
## Low findings (9)
|
||||
|
||||
### L1 — interface-only `export` keywords (6 symbols)
|
||||
|
||||
Six v1.4.28-new interfaces are referenced only inside their declaring module — same pattern as the v1.4.27 §8 zod-derived contract types but **not** zod-backed; these are component-prop / hook-result shapes. Each is a one-word edit (`export interface` → `interface`):
|
||||
|
||||
| Symbol | File:line |
|
||||
|---|---|
|
||||
| `TimeoutEnvelope<T>` | `src/lib/insights/with-timeout.ts:21` |
|
||||
| `MetricEmptyStateProps` | `src/components/insights/metric-empty-state.tsx:36` |
|
||||
| `MobileRailTrayProps` | `src/components/insights/coach-panel/mobile-rail-tray.tsx:33` |
|
||||
| `AggregatedRow` | `src/lib/measurements/range-aggregation.ts:58` |
|
||||
| `UseInsightsAnalyticsResult` | `src/hooks/use-insights-analytics.ts:39` |
|
||||
| `MedicationCardHeaderProps` | `src/components/medications/MedicationCardHeader.tsx:29` |
|
||||
|
||||
`DAILY_AGGREGATE_THRESHOLD_DAYS` and `WEEKLY_AGGREGATE_THRESHOLD_DAYS` (range-aggregation.ts:23–24) read as "non-test" hits but the only external consumer is the test file itself, so they're true library-level constants. **Keep** the export keyword on those two — tests are first-class consumers for tuning thresholds.
|
||||
|
||||
**Decision:** optional. Drop-the-export pass is a separate hygiene commit; can ride with the M1/M2 sweep.
|
||||
|
||||
### L2 — duplicate local `ChartSkeleton` in two components
|
||||
|
||||
`src/components/insights/trends-row.tsx:34–36` and `src/components/insights/vo2-max-chart-row.tsx:29–31` each declare a local `const ChartSkeleton = () => (...)` with the same `bg-muted/40 h-[220px]` body. The shared primitive at `src/components/charts/chart-skeleton.tsx:29` (added by v1.4.28 `d286220b` "wire chart-skeleton loading state across dynamic imports") is the canonical version — it paints a richer skeleton with header chrome + sized chart band.
|
||||
|
||||
These two are not strictly orphans (the local consts are used inside the same file), but they are the only two `next/dynamic` mounts that did NOT pick up the v1.4.28 skeleton fix. Wiring them through `<ChartSkeleton />` from `@/components/charts/chart-skeleton` would close the L2 finding *and* match the layout-stable behaviour the H4 fix was meant to deliver everywhere.
|
||||
|
||||
**Decision:** import the shared `<ChartSkeleton>` in both files; drop the local constants. Estimated diff: roughly `-12 / +4` LOC.
|
||||
|
||||
### L3 — stale "GLP-1 tile" comment in `glp1-pk.ts`
|
||||
|
||||
`src/lib/medications/glp1-pk.ts:292` — "Surfaces the 'shot phase' chip on the GLP-1 tile (research §2.4)". The tile is gone. If M2 lands the comment goes with it; otherwise trim the line.
|
||||
|
||||
### L4 — stale "compact mode … GLP-1 tile" comments in `DrugLevelChart.tsx`
|
||||
|
||||
Three blocks reference the retired tile:
|
||||
|
||||
| Line | Excerpt |
|
||||
|---|---|
|
||||
| 115 | "v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1 dashboard tile…)" |
|
||||
| 228 | "Compact mode mounts the chart inside a host card (the GLP-1 tile) …" |
|
||||
| 234 | "… made the GLP-1 tile feel oversized relative to the neighbouring trend cards …" |
|
||||
|
||||
If M1 lands these go with the `compact` branch. If M1 is deferred, trim "the GLP-1 tile" → "a host card" so the comments don't lie about live surfaces.
|
||||
|
||||
### L5 — stale "weekly report" in OpenAPI route description
|
||||
|
||||
`src/lib/openapi/routes.ts:757` — `/api/insights/comprehensive` description still reads "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations." The `weeklyReport` block was stripped from `src/lib/ai/schema.ts` in v1.4.28; the route no longer emits it. The generated OpenAPI client and the docs site will surface a description that contradicts the schema.
|
||||
|
||||
**Decision:** drop "optional weekly report + " from the description. One-line edit.
|
||||
|
||||
### L6 — `/api/dashboard/glp1` references in two safety-net comments
|
||||
|
||||
The route was deleted in v1.4.28. Two comments still cite it as a representative example of the "no-scope route ⇒ any-token" rule:
|
||||
|
||||
- `src/lib/api-handler.ts:323` — naming four routes in a v1.4.25 W10 reconcile note
|
||||
- `src/lib/__tests__/require-auth-bearer.test.ts:205` — same wording
|
||||
|
||||
Both are intentional historical context (the W10 reconcile **did** include `/api/dashboard/glp1`). The example is still semantically valid — the rule applies to any scope-less route — but the cited example no longer exists. Either swap the example for a live scope-less route or strike the parenthetical list.
|
||||
|
||||
### L7 — stale `feedback_charts_visual_identity.md` ChartSkeleton justification
|
||||
|
||||
`src/components/insights/trends-row.tsx:32` block-comments the local `ChartSkeleton` with "cleared against `feedback_charts_visual_identity.md` — placeholder only, no chart visual change." If L2 lands, the new shared `<ChartSkeleton>` is richer than the local one (header chrome + sized chart band) — that's a visible delta and the comment becomes wrong. The feedback memo's actual rule is "no token reshuffle, no Recharts swap"; the skeleton change is layout-only and does not touch the rendered chart.
|
||||
|
||||
**Decision:** when L2 lands, either drop the comment or rewrite it as "skeleton chrome aligned with the dashboard's shared `<ChartSkeleton>` — `feedback_charts_visual_identity.md` covers chart-body identity, which is unchanged."
|
||||
|
||||
### L8 — `void compareBaseline` discard is now load-bearing test surface
|
||||
|
||||
Resolved in v1.4.27 RC3 (`compareBaseline` is destructured with an explicit `void` discard plus a doc comment at lines 196–202 explaining the prop is intentionally accepted-and-ignored). No further action — flagged only because the v1.4.27 R4 §7.1 "maintainer decision" landed and the file proves the resolution.
|
||||
|
||||
### L9 — `MEDICATION_CATEGORIES` import-only consumer
|
||||
|
||||
`src/lib/medication-category.ts:4` exports `MEDICATION_CATEGORIES`. The blob scan reports an internal-file-only match. Spot-check returns one cross-file consumer in `src/components/medications/category-select.tsx` that the substring scanner missed because the import collapsed into a named-import block. **No action** — false positive in the heuristic.
|
||||
|
||||
---
|
||||
|
||||
## Informational (3)
|
||||
|
||||
### I1 — v1.4.27 R4 §2 14 orphans all swept
|
||||
|
||||
Confirmed at HEAD: every symbol from the v1.4.27 R4 §2 list (`countMessages`, `buildGeneratePrompts`, `_resetSafetyContractsCacheForTests`, `forEachLocaleAndRule`, `getPulseRange`, `trendLinePoints`, `CHART_MINI_HEIGHT_PX`, `formatDateWithWeekday`, `resolveGeneralStatusLocale`, `isSubPageSlug`, `recordHeartbeat`, `EVENT_TYPE_LABELS`, `cleanupExpiredRateLimits`, `sleepStageEnum`) returns zero grep hits in `src/`. The `getInsightsSystemPrompt` helper + `InsightsOutput` interface (§6.1 + §8.1) plus the `glp1-snapshot.ts` `__testables` block (§3.2) are also gone. The v1.4.27 R4 sweep landed in full.
|
||||
|
||||
### I2 — i18n keys clean (`pnpm test … i18n-locale-integrity` 26/26)
|
||||
|
||||
Re-run at HEAD passes. The new `insights.coach.window.lastYear` key (added in `75773ca0` per the v1.4.27 §4 cross-reference) closes the only missing-key gap from v1.4.27. The custom blob scanner I ran on top reports 69 candidate "dead" keys, but cross-checking against the existing integrity test (which uses the template-prefix-aware logic) confirms each candidate has either a `t("ns.key")` consumer or a template prefix usage the coarse scan misses (e.g. `t(\`insights.recommendation.${key}\`)`). **No dead i18n keys at HEAD.**
|
||||
|
||||
### I3 — `@deprecated` markers still zero
|
||||
|
||||
`grep -rn "@deprecated" src` returns zero hits. No new deferred markers introduced in v1.4.28.
|
||||
|
||||
---
|
||||
|
||||
## Orphan-by-orphan table
|
||||
|
||||
| ID | Symbol / surface | File:line | Consumers | Severity | Decision |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | `DrugLevelChart.compact` + `windowHoursBefore` | `src/components/medications/DrugLevelChart.tsx:123,133` | 0 prod, 0 tests | Medium | DROP |
|
||||
| M2 | `shotPhaseAt`, `ShotPhase` | `src/lib/medications/glp1-pk.ts:301,303` | 0 prod, 4 test assertions | Medium | DROP (with tests) |
|
||||
| M3 | 5 admin/monitoring routes | per table above | README-only | Medium | Defer to v1.4.29 |
|
||||
| L1 | `TimeoutEnvelope<T>` | `src/lib/insights/with-timeout.ts:21` | declaring-file only | Low | Drop `export` |
|
||||
| L1 | `MetricEmptyStateProps` | `src/components/insights/metric-empty-state.tsx:36` | declaring-file only | Low | Drop `export` |
|
||||
| L1 | `MobileRailTrayProps` | `src/components/insights/coach-panel/mobile-rail-tray.tsx:33` | declaring-file only | Low | Drop `export` |
|
||||
| L1 | `AggregatedRow` | `src/lib/measurements/range-aggregation.ts:58` | declaring-file only | Low | Drop `export` |
|
||||
| L1 | `UseInsightsAnalyticsResult` | `src/hooks/use-insights-analytics.ts:39` | declaring-file only | Low | Drop `export` |
|
||||
| L1 | `MedicationCardHeaderProps` | `src/components/medications/MedicationCardHeader.tsx:29` | declaring-file only | Low | Drop `export` |
|
||||
| L2 | local `ChartSkeleton` (trends-row) | `src/components/insights/trends-row.tsx:34` | same file | Low | Wire to shared |
|
||||
| L2 | local `ChartSkeleton` (vo2-max-chart-row) | `src/components/insights/vo2-max-chart-row.tsx:29` | same file | Low | Wire to shared |
|
||||
|
||||
(L3–L9 are comment-level findings, listed in the next section.)
|
||||
|
||||
---
|
||||
|
||||
## Stale-comment list
|
||||
|
||||
| File:line | Comment excerpt | Recommendation |
|
||||
|---|---|---|
|
||||
| `src/lib/medications/glp1-pk.ts:292` | "Surfaces the 'shot phase' chip on the GLP-1 tile (research §2.4)" | Drop with M2, else trim "the GLP-1 tile" |
|
||||
| `src/components/medications/DrugLevelChart.tsx:115` | "v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1 dashboard tile…)" | Drop with M1, else trim |
|
||||
| `src/components/medications/DrugLevelChart.tsx:228` | "Compact mode mounts the chart inside a host card (the GLP-1 tile)…" | Drop with M1, else trim |
|
||||
| `src/components/medications/DrugLevelChart.tsx:234` | "made the GLP-1 tile feel oversized relative to the neighbouring trend cards" | Drop with M1, else trim |
|
||||
| `src/components/medications/DrugLevelChart.tsx:461` | "Chart-area height in CSS pixels; compact mode passes 160." | Drop with M1 |
|
||||
| `src/lib/openapi/routes.ts:757` | "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations." | Drop "optional weekly report + " |
|
||||
| `src/lib/api-handler.ts:323` | "`/api/dashboard/glp1`) 403'd every narrow-scope token even though those routes did not intend to restrict by scope" | Swap example or strike route name |
|
||||
| `src/lib/__tests__/require-auth-bearer.test.ts:205` | "`/api/dashboard/glp1` — all four were called without a scope" | Swap example or strike route name |
|
||||
| `src/components/insights/trends-row.tsx:32` | "cleared against `feedback_charts_visual_identity.md` — placeholder only, no chart visual change" | Rewrite when L2 lands |
|
||||
|
||||
Total stale comments: **9**.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
v1.4.28 cleaned up cleanly — the v1.4.27 R4 §2 sweep landed in full (14 orphan exports + the `__testables` block + the legacy `getInsightsSystemPrompt` helper + `InsightsOutput` are all gone) and the three retired surfaces (`Glp1Tile`, `InsightAdvisorCard`, `WeeklyReportView`) plus the two GLP-1 detail disclosures (`IntakeHistoryList`, `InventorySection`) took their direct supporting code with them. The i18n surface remains clean (`pnpm test … i18n-locale-integrity` 26/26) and no `@deprecated` markers slipped in.
|
||||
|
||||
What remains is residue. **M1 + M2** are the only real wins: `DrugLevelChart.compact` mode plus `shotPhaseAt` lost their only mount when the tile was retired, so each is a measurable LOC reduction. **M3** is unchanged from v1.4.27 — five admin/monitoring routes still need a per-endpoint maintainer call. **L1** is six new `export interface` declarations that could shed the `export` keyword (matching the v1.4.27 §8 hygiene pattern). **L2** is two duplicate local `ChartSkeleton` constants that could ride the shared primitive the v1.4.28 H4 fix introduced. **L3–L9** are nine stale comments — three of them disappear automatically if M1 + M2 land.
|
||||
|
||||
Recommended single commit if R4 is pulled into v1.4.28: M1 + M2 + L2 + the six L1 single-word edits + the L3/L4/L5 comment trims that survive after M1/M2 land. Estimated diff: roughly **-180 / +6** LOC. Risk: low — verified zero non-test consumers for every dropped symbol. M3 stays deferred; L6–L9 are optional polish.
|
||||
|
||||
Output:
|
||||
|
||||
- Orphan count (Medium + Low symbols/surfaces): **11** (3 Medium + 8 Low; L8 already resolved, L9 false positive)
|
||||
- Top sweep candidate: **M1** (`DrugLevelChart.compact` mode + `windowHoursBefore` — biggest LOC reduction, removes the tightest coupling to the retired tile)
|
||||
- Stale comments: **9**
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-design.md
|
||||
purpose: R4 design review — 7-axis pass across the v1.4.28 user-facing diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 design
|
||||
---
|
||||
|
||||
# v1.4.28 — R4 design review
|
||||
|
||||
Read-only 7-axis pass across the 11 user-facing surfaces from
|
||||
`.planning/v1428-feedback-2026-05-15.md` (themes F, G, H, I, K, L, M).
|
||||
Diff scope: 30 commits since `v1.4.27`. Axes: hierarchy, contrast
|
||||
(WCAG 2.1 AA), spacing, typography, responsive (320 / 768 / 1024 / 1440
|
||||
px), a11y, motion.
|
||||
|
||||
R1 prerequisites `v1428-r1-ui-inventory.md` and `v1428-r1-competitive.md`
|
||||
are not yet on disk; review reads components directly and benchmarks
|
||||
against `v1427-r4-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
### Critical
|
||||
|
||||
None. The maintainer's eight Critical feedback items (bug-class, scope
|
||||
reduction) are bug + scope work, not design. No regressions on the 7 axes
|
||||
that would block tag.
|
||||
|
||||
### High
|
||||
|
||||
**D-H1 — Target-card Coach button under WCAG 2.5.5 tap-target floor**
|
||||
File: `src/components/targets/target-coach-button.tsx:65-86`
|
||||
Axis: a11y / responsive
|
||||
FB-L1 collapses the affordance to `<Button size="icon" />`. The shadcn
|
||||
`icon` variant is `size-10` (40 px); the project floor (Phase A5,
|
||||
medication-history button, Coach drawer cluster) is 44 px. The labelled
|
||||
pill it replaced cleared the floor on width; the collapse fell under it.
|
||||
Recommendation: pass `size="icon-lg"` (already in the button CVA) or
|
||||
override with `min-h-11 min-w-11`. Glyph stays `size-4`; hit target
|
||||
lifts to 44 px without visual change.
|
||||
|
||||
**D-H2 — Health-score delta `?` trigger under WCAG 2.5.5**
|
||||
File: `src/components/insights/health-score-delta-explainer.tsx:62-81`
|
||||
Axis: a11y / responsive
|
||||
Trigger is `h-4 w-4` (16 px) hosting a `h-3 w-3` glyph. WCAG floor is
|
||||
44 px. The file comment claims the focus ring is the touch target —
|
||||
visual-only. Phone tap surface is 16 × 16 next to the dense sub-bars
|
||||
block.
|
||||
Recommendation: keep the visible glyph 12-16 px but inflate the hit
|
||||
target via `p-2` on the button + `-m-2` on the parent so optical
|
||||
chip stays small while touch surface lifts to 32-44 px.
|
||||
|
||||
**D-H3 — Mobile delta-explainer trigger wraps the button in a clickable span**
|
||||
File: `src/components/insights/health-score-delta-explainer.tsx:83-115`
|
||||
Axis: a11y
|
||||
Mobile branch wraps the `<button>` in `<span onClick onKeyDown>`. The
|
||||
inner button swallows clicks before the span sees them; the span's
|
||||
handler only fires on the 2 px gap. SR announces two interactive
|
||||
elements.
|
||||
Recommendation: drop the `<span>`; move `onClick` / `onKeyDown` onto
|
||||
the button directly. `type="button"` + existing `aria-label` cover a11y.
|
||||
|
||||
**D-H4 — Delta line has no `aria-describedby` thread to the explainer**
|
||||
File: `src/components/insights/health-score-card.tsx:330-364`
|
||||
Axis: a11y
|
||||
The delta `<p>` and the `<HealthScoreDeltaExplainer>` are inline
|
||||
siblings; no `aria-describedby` ties the digit to the explanation. SR
|
||||
announces "−3 vs last week, button, What does this delta mean" — the
|
||||
connection is visual-only.
|
||||
Recommendation: thread a `useId()` from the explainer into the delta
|
||||
`<span>`'s `aria-describedby`; mirror the Radix tooltip pattern already
|
||||
used in `<TrendCard>`.
|
||||
|
||||
**D-H5 — Trends row mood mini retains a `<Card>` hairline border**
|
||||
File: `src/components/insights/trends-row.tsx:124-191` +
|
||||
`src/components/charts/mood-chart.tsx:529-540`
|
||||
Axis: visual hierarchy / spacing rhythm
|
||||
FB-K1/K2 lands `auto-rows-fr` + a fixed chart-slot wrapper, and the
|
||||
mood mini collapses to `gap-1 py-2 shadow-none`. But the `<Card>`
|
||||
primitive still paints `border bg-card`; BP/weight minis use no
|
||||
`<Card>` at all. Result: mood ships a 1-px hairline that BP/weight
|
||||
don't. Heights now match within ~2 px; the border is visible at 320 px
|
||||
single-column and on retina.
|
||||
Recommendation: drop the `<Card>` wrapper on the mood-mini path (bare
|
||||
`<div>` with the same gap rhythm), or lift a shared chart-slot
|
||||
primitive both minis consume.
|
||||
|
||||
**D-H6 — Med-list two-line shape wraps to 3+ lines at 320 px when state badges present**
|
||||
File: `src/components/medications/MedicationCardHeader.tsx:44-63`
|
||||
Axis: responsive / spacing rhythm
|
||||
Line 2 is `flex flex-wrap` with category `<Badge>` + state badges
|
||||
(without-notification, paused, inactive). At 320 px, any state badge
|
||||
pushes the row to 3 lines. FB-G1's "two-line, no exceptions" contract
|
||||
breaks for ~20 % of configured drugs.
|
||||
Recommendation: drop state badges to a separate `<div>` below the
|
||||
category badge, OR collapse to a single status pill (most users have
|
||||
at most one).
|
||||
|
||||
**D-H7 — Side-effects date column overspec by 40 px on narrow viewports**
|
||||
File: `src/components/medications/SideEffectsSection.tsx:280-311`
|
||||
Axis: responsive
|
||||
Date stamp is `w-[5.5rem]` (88 px); the maintainer's "15.05" example
|
||||
fits in 48 px. The left slot (category + entry + severity) is wrap-prone
|
||||
at 320 px; recovering 32 px helps the wrap rhythm.
|
||||
Recommendation: narrow to `w-14` (56 px) — fits the longest short-date
|
||||
("15. Mai") with 1.5ch slack.
|
||||
|
||||
**D-H8 — Medication-detail sibling stride drops to 14 px after the heading collapse**
|
||||
File: `src/components/medications/medication-detail-section.tsx:65-78` +
|
||||
`src/app/medications/[id]/history/page.tsx:57-130`
|
||||
Axis: spacing rhythm / visual hierarchy
|
||||
Page wraps sections in `space-y-4` (16 px); each section has a 1 px
|
||||
border. Effective gap is 14 px against dense `text-xs` content — reads
|
||||
tight after the heading collapse.
|
||||
Recommendation: lift the page wrapper to `space-y-6` (24 px) to match
|
||||
the `/insights` sub-page stride. Heading collapse stays.
|
||||
|
||||
### Medium
|
||||
|
||||
**D-M1 — Empty-state path stacks two muted descriptions**
|
||||
File: `src/components/insights/sub-page-shell.tsx:85-117` +
|
||||
`src/app/insights/blutdruck/page.tsx:49-69`
|
||||
Axis: visual hierarchy
|
||||
Sub-pages pass `description={...}` to `<SubPageShell>` on both branches;
|
||||
the empty-state branch also paints `<MetricEmptyState>`'s own
|
||||
description. Two stacked muted-foreground lines read as duplication
|
||||
even though copy is intentionally distinct.
|
||||
Recommendation: drop `description` from `<SubPageShell>` on the
|
||||
empty-state branch and let `<MetricEmptyState>` own the muted line.
|
||||
|
||||
**D-M2 — Layout Coach FAB gradient text fails WCAG AA contrast**
|
||||
File: `src/components/insights/layout-coach-fab.tsx:32-50`
|
||||
Axis: contrast
|
||||
Gradient is `from-dracula-purple to-dracula-pink` with `text-white`.
|
||||
Dracula-purple (`#bd93f9`) / white ≈ 2.3:1, dracula-pink (`#ff79c6`) /
|
||||
white ≈ 2.5:1. AA needs 4.5:1 for body, 3:1 for ≥ 18 px semibold.
|
||||
Recommendation: bump the label to `text-base font-semibold` (lifts via
|
||||
large-text exception) OR deepen the gradient toward `dracula-purple/90`
|
||||
to `dracula-purple` — keeps brand grammar, clears 3:1.
|
||||
|
||||
**D-M3 — Coach launch on `md` (768-1023 px) is FAB-only**
|
||||
File: `src/components/insights/coach-launch-button.tsx:56-71` +
|
||||
`src/components/insights/layout-coach-fab.tsx:40-46`
|
||||
Axis: responsive
|
||||
Inline pill is `hidden lg:inline-flex`; FAB is `lg:hidden`. Tablet
|
||||
mounts the FAB but hides the sub-page action-row pill. Inv-5 → 3
|
||||
shapes lands 2 shapes with a mid-tier gap.
|
||||
Recommendation: product-lead call. Either keep current (tablet =
|
||||
phone) or lift the inline pill to `md:inline-flex` (tablet = desktop).
|
||||
|
||||
**D-M4 — Briefing CTA filled-variant change conflicts with dashboard convention**
|
||||
File: `src/components/insights/daily-briefing.tsx:336-360`
|
||||
Axis: visual hierarchy
|
||||
BK-M2 switches the briefing empty-state CTA from `outline` to filled
|
||||
default. Comment cites "match the dashboard empty-state CTA shape" —
|
||||
but dashboard `<EmptyState>` consumers paint `outline size="sm"`. The
|
||||
change inverts the convention rather than aligning, and pulls focus
|
||||
from the hero "Ask the coach" outline button directly above.
|
||||
Recommendation: re-anchor with maintainer. If "primary filled" is the
|
||||
target, sweep dashboard tiles. If "outline" is the target, revert.
|
||||
v1.4.27 R4 design §M4 concluded outline.
|
||||
|
||||
**D-M5 — Mood-chart mini border applies to standalone consumption too**
|
||||
File: `src/components/charts/mood-chart.tsx:529-545`
|
||||
Axis: visual hierarchy
|
||||
Same `<Card>` border issue as D-H5 but in the standalone consumption
|
||||
path (sub-page mood mounts).
|
||||
Recommendation: see D-H5. Lifting the shared chart-slot wrapper fixes
|
||||
both.
|
||||
|
||||
**D-M6 — Tab-strip pills read hollow next to the regenerate icon at `<sm`**
|
||||
File: `src/components/insights/insights-tab-strip.tsx:177-237`
|
||||
Axis: spacing rhythm
|
||||
Pills are `min-h-11 text-xs`; the regenerate button is `h-11 w-11`
|
||||
solid. Weight balance off on phone.
|
||||
Recommendation: bump pill `text-xs` to `text-sm` on `<sm` only. Defer
|
||||
if symmetry isn't a product ask.
|
||||
|
||||
**D-M7 — Trend-annotation confidence chip reads as a disconnected footnote**
|
||||
File: `src/components/insights/trend-annotation.tsx:71-103`
|
||||
Axis: visual hierarchy
|
||||
Body clamps at 3 lines; badge sits below as a sibling `<p>`. Truncated
|
||||
"…" + badge on a separate line reads as orphan footnote.
|
||||
Recommendation: lift the badge to an inline tail of the prose using
|
||||
`<Badge>`'s built-in `inline-flex items-center`.
|
||||
|
||||
**D-M8 — Med-card schedule dose hidden on `<sm`**
|
||||
File: `src/components/medications/medication-card.tsx:499-517`
|
||||
Axis: responsive / typography
|
||||
Next-intake line wraps the dose in `hidden sm:inline`. Mobile users
|
||||
lose the dose on the schedule row, even though the unified header
|
||||
already shows it.
|
||||
Recommendation: drop the `hidden sm:inline` on the dose span.
|
||||
|
||||
### Low
|
||||
|
||||
**D-L1 — Delta-explainer popover `max-w-xs` is tight for future copy**
|
||||
File: `src/components/insights/health-score-delta-explainer.tsx:120-138`
|
||||
Axis: typography
|
||||
3-sentence copy fits at every locale; future edits could overshoot.
|
||||
Recommendation: bump to `max-w-sm` preemptively.
|
||||
|
||||
**D-L2 — Sub-page shell `focusOnMount` default-off skips SR heading announce**
|
||||
File: `src/components/insights/sub-page-shell.tsx:54-83`
|
||||
Axis: a11y
|
||||
Trade-off documented (mobile soft-keyboard collision). Tab-strip pill
|
||||
clicks announce the new pathname; gap is contained.
|
||||
Recommendation: no code change. Existing file comment covers it.
|
||||
|
||||
**D-L3 — `<ResponsiveSheet>` Close affordance verification**
|
||||
File: `src/components/insights/health-score-delta-explainer.tsx:97-114`
|
||||
Axis: a11y
|
||||
Mobile sheet closes via drag + tap-outside; spot-check the primitive
|
||||
ships a visible Close.
|
||||
Recommendation: confirm primitive; thread `footer` Close if missing.
|
||||
|
||||
**D-L4 — Target Coach button paints both `aria-label` and `title`**
|
||||
File: `src/components/targets/target-coach-button.tsx:79-86`
|
||||
Axis: a11y
|
||||
SR may double-announce on touch; known Radix-tooltip-adjacent issue
|
||||
across the app.
|
||||
Recommendation: defer.
|
||||
|
||||
**D-L5 — HealthScore progress bar lacks `motion-reduce` gate**
|
||||
File: `src/components/insights/health-score-card.tsx:317-321`
|
||||
Axis: motion
|
||||
`transition-all` on the inner bar animates width + band colour with no
|
||||
`motion-reduce:transition-none`.
|
||||
Recommendation: append `motion-reduce:transition-none`.
|
||||
|
||||
---
|
||||
|
||||
## Per-surface assessment table
|
||||
|
||||
| # | Surface | Hierarchy | Contrast | Spacing | Typography | Responsive | a11y | Motion | Verdict |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | HealthScore card height (FB-H1/H2/H3) | pass | pass | pass | pass | pass | pass | warn (D-L5) | ship |
|
||||
| 2 | HealthScore delta `?` explainer (FB-I1) | pass | pass | pass | warn | warn (D-H2) | fail (D-H2/H3/H4) | pass | fix-before-ship |
|
||||
| 3 | Trends row equal-height (FB-K1/K2) | warn (D-H5) | pass | warn (D-H5) | pass | warn (D-H5) | pass | pass | fix-before-ship |
|
||||
| 4 | Medication-list row shape (FB-G1) | pass | pass | warn (D-H6) | pass | fail (D-H6) | pass | pass | fix-before-ship |
|
||||
| 5 | Medication-detail section header (FB-F3/F4) | pass | pass | warn (D-H8) | pass | pass | pass | pass | ship |
|
||||
| 6 | Side-effects card alignment (FB-F1/F2) | pass | pass | pass | pass | warn (D-H7) | pass | pass | ship |
|
||||
| 7 | Targets-page Coach icon (FB-L1) | pass | pass | pass | pass | fail (D-H1) | fail (D-H1) | pass | fix-before-ship |
|
||||
| 8 | Coach launch consolidation (Inv-5) | pass | warn (D-M2) | pass | pass | warn (D-M3) | pass | pass | ship |
|
||||
| 9 | Sub-page descriptions (BK-M10) | warn (D-M1) | pass | pass | pass | pass | pass | pass | ship |
|
||||
| 10 | Briefing CTA variant (BK-M2) | warn (D-M4) | pass | pass | pass | pass | pass | pass | re-anchor |
|
||||
| 11 | Insights tab-strip (FB-D3 adjacent) | pass | pass | warn (D-M6) | pass | pass | pass | pass | ship |
|
||||
|
||||
Verdict legend: `ship` = no blocker, accept Low/Medium findings as v1.4.29
|
||||
backlog; `fix-before-ship` = a High finding on the surface blocks tag until
|
||||
addressed; `re-anchor` = needs maintainer call before either ship or fix.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The v1.4.28 user-facing diff is design-positive overall: HealthScore
|
||||
height contract (FB-H1/H2/H3), medication-list unification (FB-G1),
|
||||
section header collapse (FB-F3/F4), and Coach launch consolidation
|
||||
(Inv-5) all land cleanly on the happy path.
|
||||
|
||||
Eight High findings cluster on three surfaces:
|
||||
1. Delta `?` explainer (D-H2, D-H3, D-H4) — tap-target lift, span-wrapper
|
||||
drop, `aria-describedby` thread.
|
||||
2. Targets Coach icon (D-H1) — `size="icon-lg"` to clear the 44 px floor.
|
||||
3. Trends row (D-H5) — mood mini's residual `<Card>` border breaks the
|
||||
rhythm at 320 px.
|
||||
|
||||
D-H6 (FB-G1 state-badge wrap) and D-H7 (FB-F2 date-column overspec) are
|
||||
narrow-viewport regressions, sub-30-line fixes each. D-H8 is a
|
||||
spacing-ladder adjustment that complements the heading collapse.
|
||||
|
||||
Two Medium items ask for product-lead anchoring: D-M3 (tablet Coach
|
||||
shape) and D-M4 (empty-state CTA convention).
|
||||
|
||||
Go / no-go: **GO** to ship v1.4.28, with the High findings landed as
|
||||
touch-disjoint commits before tag. Medium/Low → v1.4.29 backlog. No
|
||||
Critical design regressions relative to v1.4.27.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-i18n.md
|
||||
purpose: R4 i18n runtime probe — locale × route raw-key fallbacks + new-key coverage across the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 i18n
|
||||
---
|
||||
|
||||
# v1.4.28 R4 — i18n runtime probe
|
||||
|
||||
Scope: 30 commits since `v1.4.27` against the six bundled locales (de, en, fr, es, it, pl).
|
||||
Pure file-based audit. The locale-integrity test (`src/lib/__tests__/i18n-locale-integrity.test.ts`)
|
||||
and the drift-guard test (`src/__tests__/i18n-drift-guard.test.ts`) both ran green —
|
||||
key parity, no-empty values, and the v1.4.27 B6 anchored key sets are intact. The
|
||||
findings below are therefore content-level, not structural.
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical — raw-key fallback in production
|
||||
|
||||
None. No `useTranslations()` consumer references a key that is missing from any
|
||||
of the six bundles. All retired keys (`dashboard.glp1.*`, `insights.advisorTitle`,
|
||||
`insights.aiOverviewTitle`, `insights.weeklyReport.*`, `insights.hero.weeklyReport*`,
|
||||
`medications.intakeHistory.*`, `medications.inventory.*`) are gone from every
|
||||
locale and the source tree carries zero consumers for any of them.
|
||||
|
||||
### High — EN value leaked into a non-EN locale on a user-facing surface
|
||||
|
||||
- **HI-01** — `messages.duplicateTimestamp` ships English copy in fr / es / it / pl.
|
||||
Locale: fr, es, it, pl.
|
||||
Key: `measurements.duplicateTimestamp` (the prompt called this
|
||||
`errors.duplicateTimestamp`; actual namespace is `measurements.*` inside the
|
||||
`MeasurementForm` namespace at line 321 of each bundle).
|
||||
Consumer: `src/components/measurements/measurement-list.tsx:263` —
|
||||
`setEditError(t("measurements.duplicateTimestamp"))`, surfaced on the
|
||||
row-edit Sheet whenever the PUT `/api/measurements/[id]` handler now
|
||||
returns the v1.4.28 409 with `meta.errorCode = "measurement.duplicate_timestamp"`.
|
||||
Value in fr / es / it / pl is the literal EN sentence
|
||||
`"A measurement of this type already exists at the selected timestamp."`.
|
||||
Recommendation: translate the value in the four affected bundles. This is a
|
||||
new v1.4.28 key (commit `538b44f7`) that was added to every locale with the
|
||||
same EN string. The four translations are blocker-grade for a release that
|
||||
declares itself locale-complete because the banner fires on a routine
|
||||
user gesture (re-saving a measurement onto an existing timestamp).
|
||||
|
||||
- **HI-02** — `insights.sleep.description` ships English copy in fr / es / it / pl.
|
||||
Locale: fr, es, it, pl.
|
||||
Key: `insights.sleep.description`.
|
||||
Consumers: `src/app/insights/schlaf/page.tsx:39` and `:61` —
|
||||
the `<SubPageShell description={…}>` and the empty-state header copy on
|
||||
`/insights/schlaf`.
|
||||
Value in fr / es / it / pl is the literal EN sentence
|
||||
`"Stage breakdown and nightly duration, drawn from Apple Health."`.
|
||||
Pre-existing leak (not introduced by v1.4.28) but the v1.4.28 commit
|
||||
`8f7cbd49` re-touches the schlaf page so the leak now sits one diff away
|
||||
from the maintainer's eye. Recommendation: ship the four translations in
|
||||
the v1.4.28 window. Cheap fix, single-line per bundle.
|
||||
|
||||
### Medium — EN-leak in non-EN locales on lower-traffic surfaces
|
||||
|
||||
- **MD-01** — `insights.sleep.headlineTitle`, `insights.sleep.headlineCaption`,
|
||||
`insights.sleep.compositionTitle`, `insights.sleep.compositionSubtitle`,
|
||||
`insights.sleep.compositionAriaLabel`, `insights.sleep.stages.{deep,rem,
|
||||
core,asleep,awake,inBed,unavailable}`, `insights.sleep.empty.{title,
|
||||
description,action}` all carry English values in fr / es / it / pl.
|
||||
Locales: fr, es, it, pl.
|
||||
Consumers: `src/components/insights/sleep-overview.tsx` (lines 89–140)
|
||||
and `src/components/insights/sleep-stage-stacked-bar.tsx` (lines 151–210).
|
||||
These render the `<SleepOverview>` + stage-stacked bar on `/insights/schlaf`.
|
||||
Pre-existing leak. Same scope-of-fix as HI-02 — once the maintainer touches
|
||||
this block, the whole sleep section should be translated in one pass.
|
||||
Recommendation: fold into the v1.4.28 fr/es/it/pl sweep alongside HI-01 + HI-02.
|
||||
|
||||
- **MD-02** — `insights.confidence` and `insights.cached` ship English copy
|
||||
in fr / es / it / pl (`"Confidence"`, `"Cached"`).
|
||||
Consumers: every `<InsightStatusCard>` mounted from the six sub-page
|
||||
shells (`src/components/insights/insight-status-card.tsx`). User-visible
|
||||
on every per-metric sub-page.
|
||||
Recommendation: translate. Three-word strings; one-shot fix.
|
||||
|
||||
- **MD-03** — `measurements.*` error-form block on the measurement create / edit
|
||||
Sheet carries English copy across fr / es / it / pl: `notesPlaceholder`,
|
||||
`formReset`, `saveError`, `deleteError`, `invalidValue`,
|
||||
`invalidTimestamp`, `loginRequired`, `allTypes`, `filterByType`,
|
||||
`measurementCount`, `emptyTitle`, `emptyDescription`, `systolicLabel`,
|
||||
`diastolicLabel`, `pulseLabel`, `valueWithUnit`, `glucoseContext*`, plus the
|
||||
Apple-Health `type*` labels. v1.4.28's HI-01 (duplicateTimestamp) is the
|
||||
newest member of this leak surface, but the whole row should be addressed
|
||||
together because the user sees them side by side on the same Sheet.
|
||||
Pre-existing.
|
||||
Recommendation: out of v1.4.28 R4 scope — flag for a dedicated fr / es / it / pl
|
||||
measurement-form translation pass. Tracked separately so HI-01 alone doesn't
|
||||
drag the whole block in.
|
||||
|
||||
### Low — orphan, placeholder, or dead-letter
|
||||
|
||||
- **LO-01** — `insights.healthScore.deltaExplainer.description` (`"Change vs
|
||||
last week: {delta}"` + parity translations) is present and translated in
|
||||
every bundle but has no consumer.
|
||||
The component (`src/components/insights/health-score-delta-explainer.tsx`)
|
||||
only reads `trigger`, `title`, and `body`. The fourth key is dead translation
|
||||
weight from commit `9a020f21`.
|
||||
Recommendation: either wire `description` into the popover body or retire the
|
||||
key during the v1.4.28 closure window. Six-locale orphan, no user impact.
|
||||
|
||||
- **LO-02** — `insights.coach.window.windowLabel` (`"Window"`) and
|
||||
`insights.coach.window.settingsAriaLabel` (`"Coach settings"`) at lines 1059–1060
|
||||
remain English in fr / es / it / pl.
|
||||
Sibling of the new BK-i18n `lastYear` key that DID get translated in commit
|
||||
`75773ca0`. The two neighbour keys were left at EN.
|
||||
Surface: coach drawer settings sheet (mobile + desktop). Pre-existing.
|
||||
Recommendation: opportunistic fix while the `lastYear` diff is still
|
||||
in-window — three locales × two strings.
|
||||
|
||||
## Coverage matrix — new v1.4.28 keys × six locales
|
||||
|
||||
Legend: `T` = translated (locale-native value), `EN` = English value leaked,
|
||||
`✓` = present and translated, `–` = key absent.
|
||||
|
||||
| Key | de | en | fr | es | it | pl |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `insights.healthScore.deltaExplainer.trigger` | T | ✓ | T | T | T | T |
|
||||
| `insights.healthScore.deltaExplainer.title` | T | ✓ | T | T | T | T |
|
||||
| `insights.healthScore.deltaExplainer.description` | T | ✓ | T | T | T | T |
|
||||
| `insights.healthScore.deltaExplainer.body` | T | ✓ | T | T | T | T |
|
||||
| `insights.coach.window.lastYear` | T | ✓ | T | T | T | T |
|
||||
| `measurements.duplicateTimestamp` | T | ✓ | EN | EN | EN | EN |
|
||||
| `medications.sideEffects.addCta` (shortened in commit `88085615`) | T | ✓ | T | T | T | T |
|
||||
| `insights.emptyState.{bloodPressure,weight,pulse,bmi,mood,medication,sleep}.{title,description,cta}` (BK-M10, landed in v1.4.27) | T | ✓ | T | T | T | T |
|
||||
|
||||
Retired-key check (must be absent from every bundle):
|
||||
|
||||
| Retired key | de | en | fr | es | it | pl |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `dashboard.glp1.*` | gone | gone | gone | gone | gone | gone |
|
||||
| `insights.advisorTitle` | gone | gone | gone | gone | gone | gone |
|
||||
| `insights.aiOverviewTitle` | gone | gone | gone | gone | gone | gone |
|
||||
| `insights.weeklyReport.*` | gone | gone | gone | gone | gone | gone |
|
||||
| `insights.hero.weeklyReport*` | gone | gone | gone | gone | gone | gone |
|
||||
| `medications.intakeHistory.*` (nested) | gone | gone | gone | gone | gone | gone |
|
||||
| `medications.inventory.*` | gone | gone | gone | gone | gone | gone |
|
||||
|
||||
Caveat on `medications.intakeHistory`: a top-level scalar
|
||||
`"intakeHistory": "Intake history"` (the row-aria label) survives in every
|
||||
bundle and has two live consumers (`src/components/medications/medication-card.tsx:399`
|
||||
and `glp1-medication-card.tsx:268`). That's the row-aria string, not the
|
||||
retired nested-section namespace. Both are correctly translated in de
|
||||
(`"Einnahme-Verlauf"`); the four non-DE/EN bundles carry EN copy
|
||||
(`"Intake history"`) — same scope as MD-03 and tracked separately.
|
||||
|
||||
Consumer-search confirmation: zero remaining `t("dashboard.glp1.*")`,
|
||||
`t("insights.advisorTitle")`, `t("insights.aiOverviewTitle")`,
|
||||
`t("insights.weeklyReport.*")`, `t("insights.hero.weeklyReport*")`,
|
||||
`t("medications.intakeHistory.<sub>")`, `t("medications.inventory.*")` calls
|
||||
across `src/**/*.{ts,tsx}` (excluding test files and Prisma-generated code).
|
||||
|
||||
## Test-suite verdict
|
||||
|
||||
- `pnpm vitest run src/__tests__/i18n-drift-guard.test.ts src/lib/__tests__/i18n-locale-integrity.test.ts`
|
||||
→ 57/57 passing, 172 ms.
|
||||
- Drift-guard locks `admin.carrier`, `insights.emptyState.*`,
|
||||
`notifications.{admin,user}.*`, and `insights.personalRecord.*` to every locale.
|
||||
None of the v1.4.28 retirements collide with these locked sets.
|
||||
- Locale-integrity asserts EN-locale key parity, no-empty values,
|
||||
no `TODO/FIXME` placeholders, no duplicate keys at parse time, and the
|
||||
no-key-as-value sentinel. The EN-leak findings above all pass because each
|
||||
leaked value is a real English sentence, not the key's last segment.
|
||||
|
||||
## Summary
|
||||
|
||||
- Critical: 0
|
||||
- High: 2 (`measurements.duplicateTimestamp` × 4 locales is v1.4.28-introduced;
|
||||
`insights.sleep.description` × 4 locales is pre-existing but re-touched by the
|
||||
v1.4.28 schlaf-page diff).
|
||||
- Medium: 3 (`insights.sleep.*` body strings, `insights.confidence` + `cached`,
|
||||
the measurements-form error block).
|
||||
- Low: 2 (orphan `deltaExplainer.description`, `coach.window` siblings).
|
||||
- Coverage holes: fr / es / it / pl for the new `measurements.duplicateTimestamp`
|
||||
key. Every other v1.4.28 key set is at full translation parity across the six locales.
|
||||
- Verdict: **go with caveats**. The release is safe to ship — drift-guard +
|
||||
locale-integrity tests are both green and no consumer hits a raw-key fallback.
|
||||
Recommend treating HI-01 + HI-02 as v1.4.28-window patches before tagging,
|
||||
since `measurements.duplicateTimestamp` will reach the four affected locales
|
||||
the first time a non-DE/EN user re-edits a measurement onto an existing
|
||||
timestamp, and the schlaf-page header is now one diff away from the
|
||||
maintainer's eye.
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-ios-contracts.md
|
||||
purpose: R4 iOS-contract verification — every API + DTO + Prisma field reachable from the iOS client, checked against the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 iOS-contract
|
||||
---
|
||||
|
||||
# R4 iOS-contract review — v1.4.28
|
||||
|
||||
## Scope
|
||||
|
||||
Read-only review of every commit on `develop` since the `v1.4.27` tag,
|
||||
filtered to the API + Prisma + Zod surface the iOS native client at
|
||||
`/Users/marc/Projects/healthlog-iOS/` consumes. Cross-references
|
||||
against the locked-contracts pack at `.planning/v15-ios-handoff/`
|
||||
(03-api-contracts, 04-data-model, 08-locked-contracts).
|
||||
|
||||
The v1.4.28 R1.4 inventory pre-product (`v1428-r1-ios-contracts.md`)
|
||||
was not on disk at review time — this reviewer enumerated the iOS-
|
||||
consumed surface directly from the Swift sources and the v1.5 handoff
|
||||
pack.
|
||||
|
||||
`git diff v1.4.27..HEAD --stat -- src/app/api/ prisma/ src/lib/validations/`
|
||||
returns seven entries across six files; the iOS-facing slice is small.
|
||||
No `prisma/schema.prisma` diff, no enum diff, no migration.
|
||||
|
||||
---
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
### Critical (iOS contract breaking — release-blocker)
|
||||
|
||||
None.
|
||||
|
||||
### High
|
||||
|
||||
None.
|
||||
|
||||
### Medium
|
||||
|
||||
**M-1 — `/api/measurements` aggregate branch returns a divergent row
|
||||
shape.** The diff at `src/app/api/measurements/route.ts` introduces a
|
||||
server-side bucket path that fires when the caller supplies BOTH
|
||||
`from` AND `to` AND the requested `aggregate` grain is `daily` or
|
||||
`weekly` (or the window is wide enough that `pickAggregateGrain` picks
|
||||
one). In that branch the response row carries
|
||||
`{ type, value, measuredAt, count }` — no `id`, no `userId`, no
|
||||
`source`, no `glucoseContext`, no `externalId`, no `createdAt`, no
|
||||
`updatedAt`. The iOS `MeasurementWireDTO` codes the omitted fields as
|
||||
optional, so the decoder would not crash, but the rows would be
|
||||
unidentifiable and an attempt to `delete(id:)` or `update(id:,patch:)`
|
||||
afterwards would target an empty string.
|
||||
|
||||
**Net iOS exposure: zero.** `MeasurementsRepository.recent(limit:)`
|
||||
calls `/api/measurements` with `("limit", String(limit))` only — no
|
||||
`from`, no `to`. iOS lands in the unchanged raw branch and reads
|
||||
`{ measurements, meta: { total, limit, offset } }` byte-stable against
|
||||
v1.4.27. The aggregate branch is a web-only path (used by
|
||||
`recharts`-backed dashboard tiles).
|
||||
|
||||
Flagged Medium rather than Low because the divergent shape under one
|
||||
endpoint is a future-proofing risk — if iOS ever adopts `from`/`to`
|
||||
range queries via `/api/measurements` (instead of the dedicated
|
||||
`/api/measurements/series`), the implementer will likely assume one
|
||||
schema. Recommend the server team document the branched shape in
|
||||
`docs/api/openapi.yaml` and add a Zod-strict response schema entry.
|
||||
|
||||
### Low
|
||||
|
||||
**L-1 — `aggregate` query param is undocumented in
|
||||
`03-api-contracts.md` § Measurements.** The handoff pack reads
|
||||
`listMeasurementsSchema` with the v1.4.25 shape (no `aggregate` key,
|
||||
`limit` cap at 500). v1.4.28 lifts `limit` to 5000 when a windowed
|
||||
read is in play and adds `aggregate: "raw"|"daily"|"weekly"`. Both are
|
||||
additive (optional, default-preserving), so no iOS code path breaks,
|
||||
but the contract pack should be re-tagged at v1.4.28 once the release
|
||||
ships. No release-blocker.
|
||||
|
||||
**L-2 — `PATCH /api/measurements/[id]` adds 409 `errorCode:
|
||||
"measurement.duplicate_timestamp"`.** Additive. iOS error-envelope
|
||||
table already maps 409 to "Refresh state and retry once" — the new
|
||||
`errorCode` slot is an opt-in detail that iOS reads via the existing
|
||||
`error.meta.errorCode` pathway. Surface a localised toast in a future
|
||||
iOS release; no blocker today.
|
||||
|
||||
**L-3 — `aggregate` enum naming uses the lowercase token convention
|
||||
already locked by GROUND RULE 11 (severity enums) but the iOS Swift
|
||||
side has no consumer yet.** Mention only.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint-by-endpoint verification
|
||||
|
||||
| Endpoint | v1.4.28 diff | iOS consumer | Wire-shape impact |
|
||||
|---|---|---|---|
|
||||
| `PATCH /api/measurements/[id]` | + 409 path on Prisma `P2002` | `MeasurementsRepository.update(id:patch:)` returns `MeasurementWireDTO` | 200 happy path unchanged — same `apiSuccess(measurement)`. 422 unchanged. New 409 is additive with `errorCode`; iOS treats any 409 as retriable per the error table. **No break.** |
|
||||
| `GET /api/measurements` | + optional `aggregate` query param, + raised `limit` cap from 500 → 5000, + server-side bucket branch when `from && to && grain !== "raw"` | `MeasurementsRepository.recent(limit:)` calls with `limit` only (no `from`/`to`) | Raw branch byte-stable. Aggregate branch never reachable from iOS at v1.4.28. **No break.** (See M-1 for future-proofing.) |
|
||||
| `GET /api/measurements/[id]` | untouched in v1.4.28 | — | n/a |
|
||||
| `GET /api/measurements/series` | untouched | `MeasurementsRepository.series(kind:days:)` | n/a |
|
||||
| `POST /api/measurements` | untouched | `MeasurementsRepository.create` | n/a |
|
||||
| `POST /api/measurements/batch` | untouched | `MeasurementBatchUploader` | n/a |
|
||||
| `DELETE /api/measurements/by-external-ids` | untouched | `MeasurementsRepository.deleteByExternalIDs` | n/a |
|
||||
| `POST /api/medications/[id]/intake` (FB-E1 NEEDS-MIGRATION) | endpoint + `intakeSchema` untouched; only the web `<IntakeHistoryList>` mount retired | `MedicationsRepository.recordIntake(medicationID:body:)` | **Byte-stable.** iOS continues to POST + GET the paginated history per `PaginatedIntakeEnvelope`. Endpoint stays live. |
|
||||
| `GET /api/medications/[id]/glp1` (FB-E2 NEEDS-MIGRATION) | endpoint untouched; only the web `<InventorySection>` mount retired | `MedicationsRepository.glp1Details(medicationID:)` returns `Glp1DetailsDTO` with `inventory: Glp1InventoryDTO?` | **Byte-stable.** `Glp1InventoryDTO` slot on the GET response is unaffected — the chore commits removed only web mounts under `src/app/medications/...`. iOS still reads `pensRemaining`, `dosesRemaining`, `weeksOfSupply`, `lowStock`. |
|
||||
| `POST /api/medications/[id]/glp1` | untouched | n/a (iOS does not POST today) | n/a |
|
||||
| `POST /api/insights/generate` (FB-J2 NEEDS-MIGRATION) | comment-only diff (GROUND RULE renumber: 14 → 13 in source comments; the `weeklyReport`-block-mention comment retired). No behaviour change | `DashboardRepository.regenerateInsights()` POSTs here for the hero strip 24h SWR refresh | **Byte-stable.** Endpoint stays live. iOS's `AIInsightResponse` decoder is unaffected (the iOS model never referenced `weeklyReport`). |
|
||||
| `GET /api/insights/cards` | untouched | iOS reads via the comprehensive payload | n/a |
|
||||
| `GET /api/insights/comprehensive` | untouched | `DashboardRepository.comprehensive(window:)` decodes `ComprehensiveDigest` | n/a |
|
||||
| `POST /api/insights/chat` (Coach SSE) | untouched | iOS Coach repository | n/a |
|
||||
| `GET /api/dashboard/glp1` | **route file deleted (203 LoC)** | grep confirms zero iOS callers — `DashboardRepository.swift` calls `/api/dashboard/summary` + `/api/dashboard/widgets` only | **No break.** Endpoint was server-rendered for the web tile only. |
|
||||
| `GET /api/dashboard/summary` | untouched | `DashboardRepository.summary()` | n/a |
|
||||
| `GET/PUT /api/dashboard/widgets` | untouched | `DashboardRepository.widgets()` / `saveWidgets` | n/a |
|
||||
| `POST /api/internal/web-vitals` | **new route** | n/a — internal beacon, no auth, web-only client | additive, no iOS reach |
|
||||
| `GET /api/analytics` | untouched in iOS scope (the `summaries` sub-page type shim under `src/types/analytics.ts` is web-internal) | iOS reads `data.healthScore` from this payload | n/a |
|
||||
| `POST /api/auth/*`, `GET /api/auth/me/*` | untouched | iOS auth + profile flows | n/a |
|
||||
| `POST /api/workouts/batch`, `POST /api/workouts/*` | untouched | iOS `WorkoutsBatchUploader` | n/a |
|
||||
| `GET /api/notifications/*`, devices, APNs | untouched | iOS push enrolment | n/a |
|
||||
| `GET /api/personal-records` | untouched | iOS personal-records reader | n/a |
|
||||
| `GET /api/withings/*` | untouched | iOS opens connect URL in SFSafariViewController | n/a |
|
||||
| `GET /api/version`, `GET /api/health` | untouched | iOS Settings → diagnostics | n/a |
|
||||
|
||||
---
|
||||
|
||||
## Prisma + Zod diff verification
|
||||
|
||||
### Prisma
|
||||
|
||||
```
|
||||
$ git diff v1.4.27..HEAD --stat -- prisma/
|
||||
(no output)
|
||||
```
|
||||
|
||||
No schema diff. No migration. No column add, rename, drop, type change.
|
||||
Every Prisma field the iOS client reads — `Measurement.{ id, userId,
|
||||
type, value, measuredAt, notes, source, glucoseContext, externalId,
|
||||
createdAt, updatedAt }`, `Medication.{ id, name, dose, treatmentClass,
|
||||
dosesPerUnit, category, active, notificationsEnabled, schedules,
|
||||
lastTakenAt, todayEventCount }`, `MedicationIntake.{ id, medicationId,
|
||||
scheduledAt, scheduledFor, takenAt, status, snoozedUntil, skipped,
|
||||
injectionSite }`, `MedicationInventoryItem.{ dosesTotal, printedExpiry,
|
||||
purchasedAt, markAsFirstUseAt, markAsUsedUp, notes }`, `User.{ id,
|
||||
username, email, role, heightCm, dateOfBirth, gender, timezone,
|
||||
glucoseUnit, sourcePriorityJson, researchModeAcknowledgedVersion,
|
||||
onboardingCompletedAt, onboardingTourCompleted, lastReportPracticeName,
|
||||
insightsPrivacyMode }`, `Workout.{ id, source, externalId, sportType,
|
||||
startedAt, endedAt, ...batch fields }`, `PersonalRecord.{ id,
|
||||
metricType, direction, value, unit, achievedAt, previousBest }`,
|
||||
`AuditLog.action` — is byte-stable.
|
||||
|
||||
### Zod
|
||||
|
||||
```
|
||||
$ git diff v1.4.27..HEAD --stat -- src/lib/validations/
|
||||
src/lib/validations/measurement.ts | 10 +-
|
||||
```
|
||||
|
||||
Single file. Two additive lines:
|
||||
|
||||
1. `limit` cap raised `500` → `5000` on `listMeasurementsSchema`. The
|
||||
cap is a server-side max; the iOS client passes `limit=50` or
|
||||
`limit=400` and is unaffected.
|
||||
2. New optional `aggregate: z.enum(["raw","daily","weekly"]).optional()`
|
||||
on `listMeasurementsSchema`. Optional + no `.default()` — clients
|
||||
omitting it (every iOS path today) preserve the v1.4.27 behaviour.
|
||||
|
||||
Both modifications are strictly additive on the request side. The
|
||||
response shape is also byte-stable in the iOS-reachable branch (raw).
|
||||
|
||||
No diff to `auth.ts`, `medication.ts`, `coach-prefs.ts`,
|
||||
`source-priority.ts`, `workout.ts`, `notifications.ts`,
|
||||
`recommendation-feedback.ts`, `backup.ts`, `doctor-report-prefs.ts`,
|
||||
`onboarding.ts`. Every locked iOS-facing Zod schema is byte-stable.
|
||||
|
||||
---
|
||||
|
||||
## NEEDS-MIGRATION items per kickoff prompt
|
||||
|
||||
| ID | Endpoint stays live? | Wire shape stays byte-stable? | Web mount retired? | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| FB-E1 — `GET /api/medications/[id]/intake` | yes | yes | yes (`<IntakeHistoryList>` removed in `8c81af10`) | **Safe.** iOS continues to read the paginated envelope. |
|
||||
| FB-E2 — `GET /api/medications/[id]/glp1` | yes | yes (`Glp1InventoryDTO` slot preserved on the response) | yes (`<InventorySection>` removed in `8c8d6dc2`) | **Safe.** |
|
||||
| FB-J2 — `POST /api/insights/generate` | yes | yes (comment-only diff) | n/a (the web "Insights aktualisieren" affordance was retired as part of FB-J1 in `52edf85f`; the route itself still serves the hero strip + iOS Daily Briefing) | **Safe.** |
|
||||
|
||||
---
|
||||
|
||||
## Cross-checks
|
||||
|
||||
- **Locked GROUND RULES 1-15** (`08-locked-contracts.md` § 1) — all
|
||||
safety-contract YAML files untouched in the v1.4.28 diff
|
||||
(`git diff v1.4.27..HEAD --stat -- src/lib/ai/prompts/ | grep safety-contracts`
|
||||
is empty). PROMPT_VERSION sentinel: not regressed.
|
||||
- **Batch endpoint contracts** (`08-locked-contracts.md` § 2) —
|
||||
`/api/measurements/batch` + `/api/workouts/batch` untouched. The
|
||||
500-row cap, 60/min rate limit, idempotency window all unchanged.
|
||||
- **OpenAPI 3.1 hard-flip** (`08-locked-contracts.md` § 3) — the new
|
||||
`aggregate` query param + the lifted `limit` cap are not yet
|
||||
reflected in `docs/api/openapi.yaml`. Recommend the release closure
|
||||
step run `pnpm openapi:check` and absorb the regenerated YAML into
|
||||
the same commit as the route change (CI's hard-fail gate on
|
||||
`openapi-drift` will catch the omission anyway).
|
||||
- **Source-priority two-axis** (§ 4) — `sourcePriorityJson` column
|
||||
name untouched. `pickCanonicalSource()` path untouched.
|
||||
- **RESEARCH_MODE_DISCLAIMER_VERSION byte-compare** (§ 6) — the
|
||||
constant in `src/lib/medications/glp1-pk.ts` is untouched; the
|
||||
research-mode acknowledgment endpoint is untouched.
|
||||
- **Withings webhook path-segment secret** (§ 8) — untouched.
|
||||
- **Refusal-probe matrix** (§ 7) — six YAML files untouched.
|
||||
|
||||
---
|
||||
|
||||
## Summary — go/no-go for the iOS contract
|
||||
|
||||
**GO.** No iOS-breaking change lands in the v1.4.28 diff against
|
||||
v1.4.27. Every endpoint the iOS native client reads or writes is
|
||||
either untouched or strictly additive (new optional query param, new
|
||||
optional 409 path, new optional `errorCode` slot). The retired
|
||||
`/api/dashboard/glp1` route has zero iOS callers (confirmed by grep
|
||||
across `/Users/marc/Projects/healthlog-iOS/HealthLogIOS/`). The
|
||||
deleted `weeklyReport` schema slot has zero iOS references. The
|
||||
divergent shape under the new aggregate-branch on `/api/measurements`
|
||||
is unreachable from any iOS code path today.
|
||||
|
||||
Two follow-ups for the release pipeline, neither a blocker:
|
||||
|
||||
1. **OpenAPI regen.** `pnpm openapi:check` should ship the new
|
||||
`aggregate` enum + raised `limit` bound into `docs/api/openapi.yaml`
|
||||
in the same commit, or the iOS Swift codegen consumer drifts.
|
||||
2. **Handoff pack version bump.** The v1.5 handoff pack reads
|
||||
`version_anchor: v1.4.25` on every locked-contract page; the new
|
||||
`aggregate` + `limit` + duplicate-timestamp 409 should be folded
|
||||
into a v1.4.28 retag of `03-api-contracts.md` § Measurements
|
||||
(additive paragraph, not a rewrite).
|
||||
|
||||
iOS contract conformance: **clean**.
|
||||
|
||||
---
|
||||
|
||||
## Tally
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 1 (future-proofing only — aggregate-branch divergent shape) |
|
||||
| Low | 3 (handoff-pack retag, 409 errorCode opt-in, aggregate enum doc) |
|
||||
| **Total** | **4** |
|
||||
|
||||
iOS-breaking change: **no.** Release verdict: **go.**
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-product-lead.md
|
||||
purpose: R4 product-lead — strategic alignment, Marc-Voice, PII compliance, forbidden vocabulary across the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 product-lead
|
||||
---
|
||||
|
||||
# R4 product-lead review — v1.4.28
|
||||
|
||||
Read-only audit of the 30 commits between `948fcd93` (kickoff prompt) and the current `develop` tip (`5570971f`). Scope per the marathon kickoff: strategic alignment with the maintainer's directive, Marc-Voice in commit messages + new strings, PII compliance, forbidden-vocabulary check, CHANGELOG voice readiness, hand-off state to v1.4.29.
|
||||
|
||||
Net diff: 123 files, +3 808 / -8 227 lines. The directive "less scope, more depth than v1.4.27" landed as a net deletion — six "remove from code entirely" themes did land as clean delete commits, no half-measures. That part of the strategic brief is honoured.
|
||||
|
||||
The release artifacts that R5 owns (CHANGELOG section, version bump in `package.json`, `.planning/v1429-backlog.md`, `.planning/round-5-release-closure-report.md`, the consolidated `.planning/v1428-fix-plan.md` that R2 owns) are absent — `package.json` still reads `"1.4.27"`. R5 has not yet run; this review is taken as a pre-tag gate.
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
### Critical — block the v1.4.28 tag until fixed
|
||||
|
||||
**P-CRIT-1 — Maintainer name leaks in commit `9a020f21` body.** Two PII violations in the body of `feat(insights): explain the HealthScore delta on tap`:
|
||||
|
||||
- "FB-I1 — Marc asked for a tappable `?` glyph next to the delta line"
|
||||
- "Body copy is a three-sentence Marc-Voice read"
|
||||
|
||||
The "FB-I1 — Marc asked" line is the worst kind of PII leak — it surfaces the maintainer by first name in a commit body that ships to the public mirror the moment `develop` pushes. "Marc-Voice" as a self-referential phrase is the same category. Both must be scrubbed before the v1.4.28 tag.
|
||||
|
||||
Recommended replacement for the body's opening paragraph:
|
||||
|
||||
```
|
||||
The "vs last week" delta line on the HealthScore card has been
|
||||
shipping a digit without explaining what the comparison covers,
|
||||
why the digit moved, or what the user can do next. FB-I1 — the
|
||||
maintainer asked for a tappable `?` glyph next to the delta
|
||||
line that opens a short read explaining the three things in
|
||||
one shot.
|
||||
```
|
||||
|
||||
And for the body-copy paragraph: replace "Marc-Voice read" with "maintainer-voice read" or simply "three-sentence read".
|
||||
|
||||
Fix shape: amend impossible (the kickoff forbids `--amend`), so the conventional path is a follow-up `docs(release): scrub PII from v1.4.28 commit bodies` commit that documents the violation in the closure report — or, the safer path, rewrite the offending commit body on a `release-prep` branch via `git filter-repo` before tag. Either way, the v1.4.28 release closure report must call out the slip so the v1.4.29 kickoff gates on a stricter pre-commit grep.
|
||||
|
||||
**P-CRIT-2 — Forbidden vocabulary in commit `cad53a68` body.** `chore(insights): retire the weekly-report surface` ships "the AI prompt rule + OUTPUT FORMAT block" in the body paragraph. "AI" is on the forbidden roots list with no documented exemption for commit prose. The same commit also introduces a new comment in the test file (`weekly-report-banner.test.tsx`):
|
||||
|
||||
```
|
||||
+ // v1.4.28 retired the weekly-report path. The banner, the report
|
||||
+ // route, the AI schema slot and the i18n keys are gone; the hero
|
||||
+ // strip never paints the banner under any prop combination now.
|
||||
```
|
||||
|
||||
Two violations from one commit. Recommended replacement for the body sentence: "the schema slot and OUTPUT FORMAT block" (the "AI" qualifier is redundant — the schema/format block already implies the assistant pipeline). The test-file comment should drop "AI" the same way: "the schema slot and the i18n keys are gone".
|
||||
|
||||
**P-CRIT-3 — Forbidden vocabulary in commit `8f7cbd49` new comment block.** `fix(insights): document the missing sleep status slot` introduces a 12-line JSX block comment that contains:
|
||||
|
||||
- "a written per-section AI assessment" (line 65) — "AI" forbidden
|
||||
- "No per-section assessment yet — we render" (line 69) — "we" violates the kickoff's no "we / let me / unfortunately" rule
|
||||
|
||||
The recommended rewrite drops both:
|
||||
|
||||
```
|
||||
v1.4.28 BK-UI-StatusSchlaf — the six sibling sub-pages all
|
||||
mount `<InsightStatusCard>` underneath the chart so the user
|
||||
gets a written per-section assessment. Sleep has no
|
||||
`/api/insights/sleep-status` route yet (the v1.4.23 schema
|
||||
landed the data but the assessment-generation pass was
|
||||
deferred to the v1.5 iOS sprint where the Apple-Health sleep
|
||||
snapshot will inform the prompt). The structural slot renders
|
||||
so the surface stays parity with the siblings; the route +
|
||||
hook key will wire in v1.5.
|
||||
```
|
||||
|
||||
**P-CRIT-4 — Health-figure leak in commit `59ef95f2` body.** `fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive` ships "≈ 1.1 for a 33-point delta". A 33-point BP delta is plausibly a real reading from the maintainer's data set. The kickoff's PII rubric forbids "health figures, target ranges, measurement counts" in user-facing artifacts. The number is illustrative of the math but reads as concrete because the bug only manifests on a real account's data.
|
||||
|
||||
Safer replacement: "a small float (≈ 1.1 for a low-double-digit delta)" or simply "a small float that the TrendCard's downstream formatter pipeline rendered as a date-shaped artefact".
|
||||
|
||||
**P-CRIT-5 — Missing release artifacts.** Five Done-when criteria from the kickoff are absent at the time of this review:
|
||||
|
||||
- `package.json` still reads `"1.4.27"` (must bump to `1.4.28`)
|
||||
- `.planning/v1428-fix-plan.md` (kickoff says R2 writes this; not present)
|
||||
- `.planning/v1429-backlog.md` (Done-when criterion; not present)
|
||||
- `.planning/round-5-release-closure-report.md` (Done-when criterion; not present)
|
||||
- CHANGELOG.md section for `[1.4.28]` (not present)
|
||||
|
||||
R5 needs all five before the squash-merge into `main` and the tag. Until R5 ships them, the release is not tag-ready regardless of the other Critical findings.
|
||||
|
||||
### High — must address before R5 ships the CHANGELOG
|
||||
|
||||
**P-HIGH-1 — Forbidden "wave" leak in commit `5109e930`.** `refactor(medications): collapse detail-page chrome to one heading scale` introduces a new JSX comment "classes to match the surrounding wave-4b sections". "wave" is on the forbidden list. The same commit edits a pre-existing JSDoc that already mentions "wave-4b" twice — that pre-existing copy should be scrubbed in the same pass, not preserved. Recommended replacement: "the surrounding section chrome" (drop the version-marker qualifier entirely; the surrounding context already establishes what the chrome is).
|
||||
|
||||
This finding pairs with simplifier F-M7 from the v1.4.28 backlog ("stale v1.4.x version markers in code comments"). v1.4.28 had an explicit comment-scrub backlog item that did not land here.
|
||||
|
||||
**P-HIGH-2 — Eight commits ship without a body where v1.4.27 cadence would demand one.** The release-marathon convention from v1.4.20 onward is "subject + 2-4 paragraph body explaining the why and the iOS contract impact for any commit > 50 LOC". Eight v1.4.28 commits break this:
|
||||
|
||||
| Commit | Subject | LOC |
|
||||
|---|---|---|
|
||||
| 235e52cb | refactor(charts): single HealthChartDynamic re-export | +237 / -63 |
|
||||
| 8c89ddac | refactor(insights): consolidate sub-page data-fetch and empty state | +277 / -186 |
|
||||
| b0ef80dc | perf(notifications): cache the dispatch-localised user lookup | +225 / -9 |
|
||||
| ebf83b1e | feat(perf): wire bundle analyzer and web-vitals beacon | +274 / -1 |
|
||||
| d286220b | perf(charts): wire chart-skeleton loading state across dynamic imports | +76 / -9 |
|
||||
| 8f3bfc37 | refactor(charts): collapse health-chart dynamic imports onto re-export | +54 / -73 |
|
||||
| 75773ca0 | i18n: add the lastYear coach window key | +6 |
|
||||
| 8f7cbd49 | fix(insights): document the missing sleep status slot | +12 |
|
||||
|
||||
The two i18n / small-diff commits (75773ca0, 8f7cbd49) are defensible terse; the other six are substantial work with no narrative. v1.4.27's commit cadence had every >50 LOC commit shipping the standard "what / why / iOS contract" three-paragraph body. R5 will lift these subjects into the CHANGELOG and discover they have no body context to crib from — the editorial pass will read thin.
|
||||
|
||||
Mitigation: R5 backfills the missing context from the diffs themselves when writing CHANGELOG bullets. Either reshape future contributors' brief to enforce the body convention, or accept that some commits are mechanical enough to carry only a subject and pin that decision in the next kickoff.
|
||||
|
||||
**P-HIGH-3 — No R1/R2/R3 round-report planning artifacts.** Per the kickoff, R1 (5 contributors) writes `.planning/research/v1428-r1-*.md`, R2 writes `.planning/v1428-fix-plan.md`, R3 contributors write `.planning/round-3-<bucket>-report.md`, R4 reviewers write `.planning/research/v1428-r4-*.md`. None of the R1/R2/R3 files exist. Only R4 (this file + sibling R4 outputs not yet visible) is mid-flight.
|
||||
|
||||
The work in the diff is real; the planning paper-trail did not follow. For a v1.4.x release this is recoverable — the v1.4.27 closure report quoted commits directly to reconstruct what landed. But the kickoff's explicit Done-when "v1429-backlog.md exists with any items deferred to next cycle (with reason per item)" depends on R5 doing the synthesis from raw commit logs instead of from the round reports the convention assumes.
|
||||
|
||||
**P-HIGH-4 — Strategic alignment: 7 of 8 Critical feedback items dispatched, 1 deferred without note.** Cross-checking the v1.4.28 feedback Critical list against the diff:
|
||||
|
||||
| Feedback ID | Item | Status |
|
||||
|---|---|---|
|
||||
| FB-A1 | Retire Mounjaro / GLP-1 dashboard tile | Landed (`8e5f71b1`) |
|
||||
| FB-A2 | Retire `DrugLevelChart` from dashboard | Landed (folded into `8e5f71b1`) |
|
||||
| FB-B1 | Workout edit / save error | Partial — `538b44f7` fixes the 409 duplicate-timestamp path which the maintainer's reproduce likely hit, but no commit explicitly cites FB-B1 as the close-out |
|
||||
| FB-C1 | BD-Zielbereich "1.1." rendering bug | Landed (`59ef95f2`) |
|
||||
| FB-D1 | General perceived slowness | Partial — `b00be286`, `0d591ac9`, `b0ef80dc`, `d286220b`, `ebf83b1e` are all perf-related; no consolidated perf-summary commit ties them to FB-D1 |
|
||||
| FB-D2 | `/insights/puls` chart hang | Landed (`b00be286` + `0d591ac9`) |
|
||||
| FB-J1 | Retire `InsightAdvisorCard` | Landed (`52edf85f`) |
|
||||
| FB-J2 | Retire "Insights aktualisieren" affordance | Partial — `52edf85f`'s body says "the InsightsTabStrip regenerate icon stays untouched as the sole power-user refresh affordance" which contradicts FB-J2's directive "the whole bottom area collapses" |
|
||||
|
||||
FB-J2 is the alignment break. The maintainer asked for the regeneration affordance to retire with the advisor card; the commit body explicitly keeps the tab-strip regenerate icon. This is either a misread of the feedback or a defensible scope-narrowing the maintainer would accept — but the closure report needs to call it out and surface it for v1.4.29 if it's the latter.
|
||||
|
||||
### Medium — fix in R5 editorial pass
|
||||
|
||||
**P-MED-1 — Voice slip in commit `b00be286` body uses upstream pagination metric "tens of thousands of rows per type per page navigation".** The phrase reads as marketing-leaning ("tens of thousands") where a flat figure ("the row count grew unbounded with no client-side cap") would carry the same information in Marc-Voice. Same shape in `0d591ac9`: "spun for up to 90 s before the user saw the cached fallback" — defensible because the 90 s is the deterministic upper bound (3 retries × 30 s React-Query default backoff + 20 s upstream timeout), but reads dramatic. R5 should soften both phrases when lifting into CHANGELOG bullets.
|
||||
|
||||
**P-MED-2 — Commit `cad53a68` body uses "Coach now owns the sole hero-row action" — clean Marc-Voice; flagged here only because R5 should mirror the phrasing in the CHANGELOG headline. Good shape to copy forward.
|
||||
|
||||
**P-MED-3 — "Marc-Voice" appears in commit `9a020f21` body as a method-marker (see P-CRIT-1). It also appears in `cad53a68`-adjacent code comments? No — confirmed only the one commit body. Scope of the scrub is bounded to that single commit.
|
||||
|
||||
**P-MED-4 — Subject-line voice consistency.** All 30 subjects are well-shaped (terse, English, conventional-commit prefix). One micro-nit: `i18n: add the lastYear coach window key` (`75773ca0`) drops the parenthetical scope where every other commit in the set uses `prefix(scope):`. Recommended: `i18n(coach): add the lastYear window key` for grep symmetry.
|
||||
|
||||
### Low — nice-to-have for v1.4.29 kickoff
|
||||
|
||||
**P-LOW-1 — Backlog file shape for v1.4.29.** R5 will write `.planning/v1429-backlog.md`. The v1.4.28 backlog (`v1428-backlog.md`) is the right shape to mirror: source-bucket grouping, severity-tagged items, verbatim deferral rationale. The v1.4.27-deferred items that did not land in v1.4.28 (six admin tables CF-77, DateTimeInput rewrite CF-78, RHF migration CF-79, six other CF-8x items, the eight design Mediums M5-M12, simplifier F-H1 / F-M-series, senior-dev MED-3 / MED-4 / drift cleanup) all roll forward to v1.4.29. R5 has the source files; the synthesis is mechanical.
|
||||
|
||||
**P-LOW-2 — Closure report should pin which Critical items were partial vs full close (FB-B1, FB-D1, FB-J2 above) so v1.4.29 starts with a clear remaining list.
|
||||
|
||||
## Commit-by-commit voice + PII + forbidden-word check
|
||||
|
||||
Order chronological (oldest first). "Body" column flags missing-body where >50 LOC commits demand one per v1.4.27 cadence.
|
||||
|
||||
| # | Hash | Subject | Body? | Voice | PII | Forbidden | Verdict |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | 538b44f7 | fix(api): return 409 on duplicate-timestamp measurement edit | yes | clean | clean | clean | OK |
|
||||
| 2 | b00be286 | perf(charts): bound health-chart fetches to the active range window | yes | "tens of thousands" reads dramatic | clean | clean | OK — P-MED-1 |
|
||||
| 3 | 0d591ac9 | fix(insights): cap status-card provider calls at 20s with graceful fallback | yes | "spun for up to 90s" reads dramatic | clean | clean | OK — P-MED-1 |
|
||||
| 4 | ac80c099 | fix(insights): unstick scroll on tab-strip and mother-page navigation | yes | clean | clean | clean | OK |
|
||||
| 5 | 59ef95f2 | fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive | yes | clean | **"33-point delta"** | clean | **P-CRIT-4** |
|
||||
| 6 | 8e5f71b1 | chore(dashboard): retire the GLP-1 tile | yes | clean | clean | clean | OK |
|
||||
| 7 | cad53a68 | chore(insights): retire the weekly-report surface | yes | clean | clean | **"AI prompt rule" + comment "AI schema slot"** | **P-CRIT-2** |
|
||||
| 8 | 52edf85f | chore(insights): retire the InsightAdvisorCard surface | yes | clean | clean | clean | OK — but FB-J2 partial (P-HIGH-4) |
|
||||
| 9 | 8c81af10 | chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail | yes | clean | clean | clean | OK |
|
||||
| 10 | 8c8d6dc2 | chore(medications): drop the Bestand section from GLP-1 detail | yes | clean | clean | clean | OK |
|
||||
| 11 | 6f6992c6 | refactor(medications): unify medication-list row shape | yes | clean | clean | clean | OK |
|
||||
| 12 | 155b529d | fix(insights): match HealthScore card height to the hero column | yes | clean | clean | clean | OK |
|
||||
| 13 | 4c6d8779 | refactor(coach): consolidate launch button to inline + layout-FAB shape | yes | clean | clean | clean | OK |
|
||||
| 14 | d286220b | perf(charts): wire chart-skeleton loading state across dynamic imports | **no** | terse-only | clean | clean | P-HIGH-2 |
|
||||
| 15 | 1b0e81ae | fix(targets): make the coach launch an icon-only affordance | yes | clean | clean | clean | OK |
|
||||
| 16 | 66e13845 | refactor(coach): narrow launch-scope metric type to the source union | yes | clean | clean | clean | OK |
|
||||
| 17 | ca381957 | fix(coach): align mobile sheet height to the responsive-sheet convention | yes | clean | clean | clean | OK |
|
||||
| 18 | 7d38a54d | fix(medications): align side-effects card to the surface convention | yes | clean | clean | clean | OK |
|
||||
| 19 | 9a020f21 | feat(insights): explain the HealthScore delta on tap | yes | clean | **"Marc asked"; "Marc-Voice read"** | clean | **P-CRIT-1** |
|
||||
| 20 | 88085615 | fix(medications): shorten side-effects add CTA across locales | yes | clean | clean | clean | OK |
|
||||
| 21 | 235e52cb | refactor(charts): single HealthChartDynamic re-export | **no** | terse-only | clean | clean | P-HIGH-2 |
|
||||
| 22 | 8f3bfc37 | refactor(charts): collapse health-chart dynamic imports onto re-export | **no** | terse-only | clean | clean | P-HIGH-2 (small enough to skirt) |
|
||||
| 23 | 5109e930 | refactor(medications): collapse detail-page chrome to one heading scale | yes | clean | clean | **new "wave-4b" comment** | **P-HIGH-1** |
|
||||
| 24 | 0e7c97c5 | fix(insights): align briefing empty-state CTA variant | yes | clean | clean | clean | OK |
|
||||
| 25 | 8c89ddac | refactor(insights): consolidate sub-page data-fetch and empty state | **no** | terse-only | clean | clean | P-HIGH-2 |
|
||||
| 26 | 8f7cbd49 | fix(insights): document the missing sleep status slot | no body | clean subject | clean | **comment "AI assessment" × 2 + "we render"** | **P-CRIT-3** |
|
||||
| 27 | b0ef80dc | perf(notifications): cache the dispatch-localised user lookup | **no** | terse-only | clean | clean | P-HIGH-2 |
|
||||
| 28 | ebf83b1e | feat(perf): wire bundle analyzer and web-vitals beacon | **no** | terse-only | clean | clean | P-HIGH-2 |
|
||||
| 29 | 75773ca0 | i18n: add the lastYear coach window key | no body | minor scope-tag slip | clean | clean | OK — P-MED-4 |
|
||||
| 30 | 5570971f | test(targets): update coach CTA assertion to the icon-only shape | yes | clean | clean | clean | OK |
|
||||
|
||||
Tally: 30 commits / 4 Critical violations across 4 commits / 1 High forbidden-vocab violation in 1 commit / 6 commits missing the body cadence / 1 strategic alignment partial on FB-J2.
|
||||
|
||||
## Locale-string changes audit
|
||||
|
||||
Six locale bundles (`messages/{de,en,es,fr,it,pl}.json`) diffed against the kickoff baseline. Net: -546 / +72 lines. Substantial retirement footprint.
|
||||
|
||||
**Retired keys (clean closure of scope-reduction directives):**
|
||||
- `dashboard.glp1.*` — entire block (11 keys × 6 locales = 66 strings) retired with FB-A1 / FB-A2
|
||||
- `medications.intakeHistoryTitle`, `newIntake`, `editIntake`, `intakeScheduledFor`, `intakeTakenAt`, `intakeStatus`, `intakeSource`, `intakeStatusTaken`, `intakeStatusSkipped`, `intakeStatusMissed`, `intakeDeleteConfirm`, `intakeDeleteDescription`, `intakeCount` — retired with FB-E1
|
||||
- `medications.glp1DoseHistory`, `glp1DoseHistoryEmpty`, `glp1DoseSince`, `glp1Inventory`, `glp1InventoryLow`, `medications.inventory.*` block — retired with FB-E1 / FB-E2
|
||||
- `charts.avgAllTimeShort` — retired with the BD-Zielbereich rewrite (FB-C1 / FB-C2)
|
||||
- `insights.advisorTitle`, `aiOverviewTitle` — retired with FB-J1
|
||||
- `insights.heroActionWeeklyReport`, `insights.heroBanner.*`, `insights.report.*` block — retired with the weekly-report path retire (FB-H3 / FB-N)
|
||||
|
||||
**New keys (additions):**
|
||||
- `measurements.duplicateTimestamp` — new 409 path message. Marc-Voice clean. Six locales.
|
||||
- `insights.healthScore.deltaExplainer.{trigger,title,description,body}` — FB-I1 delta tooltip. Body copy ships native in all six locales (no English fallback). Marc-Voice clean. The body sentence is plain, professional, surfaces what the score combines + which window + what action raises it. No forbidden vocab. No PII.
|
||||
- `insights.coach.window.lastYear` — completes the BK-i18n carry-over item from `v1428-backlog.md`. EN "year so far"; DE/FR/ES/IT/PL native. No forbidden vocab.
|
||||
- `medications.sideEffects.addCta` — shortened from "Log side effect" to a single verb across all six locales ("Log" / "Erfassen" / "Consigner" / "Registrar" / "Registra" / "Dodaj"). Closes FB-F1.
|
||||
|
||||
**Forbidden-vocab scan on additions:** Zero violations in the new locale keys. The two earlier-documented exemptions (`settings.ai.providerOptions.anthropic` + `settings.ai.activeProviderOptions.anthropic`) are not touched in this diff and continue to apply.
|
||||
|
||||
**PII scan on additions:** Zero. The delta-explainer body uses generic copy ("BP, weight, mood and meds") without any maintainer-specific values.
|
||||
|
||||
**i18n compliance overall:** Clean. The locale changes alone are the cleanest part of the v1.4.28 diff.
|
||||
|
||||
## CHANGELOG voice notes for R5
|
||||
|
||||
When R5 lifts the 30 commits into a `[1.4.28]` section, lean on this pattern from v1.4.27's opening paragraph:
|
||||
|
||||
> Mobile capability + maintainer-finding cleanup. The headline is …
|
||||
|
||||
A v1.4.28 opening could read:
|
||||
|
||||
> Bug fixes + scope reduction. The headline is six "remove from
|
||||
> code entirely" deletions across the dashboard, insights and
|
||||
> medication-detail surfaces — the GLP-1 tile, the dashboard
|
||||
> DrugLevelChart mount, the InsightAdvisorCard, the weekly-report
|
||||
> path, and the Dosis-Historie + Bestand sections on the GLP-1
|
||||
> medication card all retire in one cycle. Four Critical bugs
|
||||
> close — the duplicate-timestamp 409, the BD-Zielbereich tile
|
||||
> renumber, the pulse-insights chart hang, and the insights tab-
|
||||
> strip scroll lock — alongside HealthScore card height parity
|
||||
> with the hero column, an opt-in delta-explainer popover, a
|
||||
> consolidated medication-list row shape and a single heading
|
||||
> scale across the medications detail page. Performance
|
||||
> instrumentation lands behind a `feat(perf)` commit that wires
|
||||
> bundle-analyzer + a web-vitals beacon; no premature
|
||||
> optimisations. iOS contracts untouched.
|
||||
|
||||
Use "the maintainer" not "Marc" in any CHANGELOG narrative. The "Marc-Voice" phrase belongs only in `.planning/` and never in shipped artifacts.
|
||||
|
||||
## Next-version readiness
|
||||
|
||||
State at review time:
|
||||
|
||||
- `develop` ahead of `main` by 30 commits + the 25 commits between v1.4.27 tag and the kickoff (the planning-doc lineage)
|
||||
- `package.json` reads `1.4.27` — version bump pending
|
||||
- `CHANGELOG.md` has no v1.4.28 section — editorial pending
|
||||
- `.planning/v1428-fix-plan.md`, `.planning/v1429-backlog.md`, `.planning/round-5-release-closure-report.md` all missing
|
||||
- Coolify deploy targets unchanged from v1.4.27 (apps01 `pg8wggwogo8c4gc4ks0kk4ss`, edge01 `ck8cs4osswg8w440gskw08w8`)
|
||||
- iOS contracts intact per spot-check on the iOS-facing endpoints (the commit bodies that touch `/api/*` explicitly call out "iOS contract: additive" or "iOS contract: none")
|
||||
|
||||
Items v1.4.29 inherits regardless of this review:
|
||||
|
||||
- The v1.4.28 backlog items that did not land (most of `v1428-backlog.md` — six admin tables, DateTimeInput, RHF migration, FB-J2 regenerate-icon question, FB-I1 was the only Medium that closed, all 12 design Mediums M1-M12 with M2 being the only one closed via `0e7c97c5`)
|
||||
- The closure report's "what landed vs deferred" matrix that this review pre-computed in P-HIGH-4
|
||||
|
||||
## Summary + go/no-go on the release tag
|
||||
|
||||
**No-go on v1.4.28 tag** until the four Critical findings close. The maintainer name in `9a020f21` is the highest-impact item — once on `develop` pushed to GitHub it is mirrored publicly and the PII directive's spirit ("Marc's name … must NOT appear in commits") is broken. The two forbidden-vocab "AI" mentions and the "33-point delta" health figure are second-tier but each violates a non-negotiable from the kickoff.
|
||||
|
||||
Go-path:
|
||||
|
||||
1. Decide between `git filter-repo`-based history rewrite on a `release-prep` branch (clean fix; force-push to `develop` requires maintainer sign-off, which the kickoff's "destructive operations" rule defers to the maintainer) versus a follow-up `docs(release): document v1.4.28 commit-body PII scrub` commit that logs the violation in the closure report and tightens the v1.4.29 pre-commit grep gate (recoverable but does not erase the leak).
|
||||
2. Fix P-CRIT-3 in-place via a new commit that rewrites the sleep-status comment block (file content, not commit history — safe).
|
||||
3. R5 writes the missing artifacts (CHANGELOG, version bump, closure report, v1429-backlog.md, fix-plan retroactive).
|
||||
4. R5 PR `develop → main`, squash subject `chore(release): v1.4.28`, tag.
|
||||
|
||||
If the maintainer accepts a "log-and-tighten-gates-going-forward" close on P-CRIT-1 and P-CRIT-4 (the PII slips) and a follow-up code-comment scrub commit for P-CRIT-2 and P-CRIT-3 (the vocab slips), v1.4.28 can ship today. The convention violations land in the closure report as known leaks, and the v1.4.29 kickoff inherits a stricter pre-commit grep that prevents recurrence.
|
||||
|
||||
If the maintainer holds the line strictly, the four Critical findings demand a history rewrite before the tag — at which point the squash-merge into `main` produces a single clean release commit with the offending bodies pre-scrubbed, and the leaks never reach `main` or the GHCR-published image's git provenance.
|
||||
|
||||
The strategic alignment is otherwise strong: every "remove from code entirely" directive landed cleanly, 7 of 8 Critical feedback items dispatched, perf-foundation work (bundle-analyzer, web-vitals beacon, dispatch-cache, payload budgets) sets the stage for v1.4.29 to measure-then-act. The diff is exactly the "less scope, more depth" shape the kickoff asked for. Once the four Critical text-level violations close, the tag is ready.
|
||||
|
||||
---
|
||||
|
||||
**Five-line summary**
|
||||
|
||||
- Severity counts: 5 Critical / 4 High / 4 Medium / 2 Low (15 findings total across 30 commits)
|
||||
- Top violation: maintainer-name PII leak in commit `9a020f21` body ("FB-I1 — Marc asked …" + "Marc-Voice read")
|
||||
- Strategic alignment: 7 of 8 Critical feedback items closed; FB-J2 partial (tab-strip regenerate icon kept against the directive); six "remove from code" themes all landed
|
||||
- Forbidden-vocab leaks: 3 commits ship "AI" / "we" / "wave" in commit body or new code comments; locale strings clean
|
||||
- **No-go on the v1.4.28 tag** until the four Critical findings close (P-CRIT-1 maintainer name, P-CRIT-2 weekly-report retire vocab, P-CRIT-3 sleep-status comment vocab, P-CRIT-4 health-figure leak) plus P-CRIT-5 release artifacts (package.json bump + CHANGELOG + closure report + v1429-backlog.md)
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-security.md
|
||||
purpose: R4 security review — endpoint authn/authz, input validation, output sanitisation across the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 security
|
||||
---
|
||||
|
||||
# v1.4.28 — security review
|
||||
|
||||
Scope: every code-touching commit between the `v1.4.27` tag and HEAD on `develop` (40 commits, ~126 files, +4 355 / -8 227). Read-only audit; no patches written. The review focuses on the surfaces the kickoff prompt called out, plus an opportunistic sweep for prompt-injection, secrets, and ambient-authority regressions.
|
||||
|
||||
The in-flight release is heavy on deletes (retired weekly-report surface, retired advisor card, retired GLP-1 dashboard tile) plus tight scope additions (web-vitals beacon, range-aware measurement listing, dispatch-localised LRU, status-card timeout envelope, P2002 catch on measurement edit). No new auth primitives, no new session/cookie code, no migrations on the auth lane.
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
### Critical
|
||||
|
||||
None. No release-blocker discovered.
|
||||
|
||||
### High
|
||||
|
||||
**H-1 — Web-vitals beacon has no rate-limit and no origin check.** `src/app/api/internal/web-vitals/route.ts:45` accepts any POST, parses JSON, and writes a wide-event line per request. The route comment explicitly says rate-limiting is "deliberately none" because a real client sends at most six beacons per page load. Attacker model: an unauthenticated peer floods `/api/internal/web-vitals` with bogus payloads (1 000 req/s from a single IP) — the route accepts everything, the wide-event pipeline emits one log line per call, the GlitchTip / log sink absorbs the cost. Same-origin policy does not stop a hostile script outside the browser (curl, headless workers). Fix shape: keep the beacon free of auth, but wrap the route with `checkRateLimit(\`web-vitals:${ip}\`, 60, 60_000)` (same shape as `deploy-webhook`) and drop the body if it does not look like a `Metric` shape (the `name` field is constrained to a six-value enum upstream). Returning 200 on the throttle hit keeps the beacon shape contract intact for legitimate clients.
|
||||
|
||||
### Medium
|
||||
|
||||
**M-1 — Measurement list `limit` ceiling 5 000 is large enough to leak through to the iOS path.** `src/lib/validations/measurement.ts:249` lifts the per-request `limit` cap to 5 000 specifically for the range-aware path. The Zod schema does not couple `limit` to the presence of `from`/`to`, so a caller can ask for `?limit=5000` without supplying a range and pull 5 000 raw rows on the legacy code path too. The cap is still bounded — it is not a SQL injection (Prisma parameterises) and ownership is enforced via `userId: user.id` on the `where` — but the size of the response (50 KB+ of JSON per request) is now a cheap DoS amplifier for any logged-in account. Fix shape: tighten the schema with `.refine((d) => d.limit <= 500 || (d.from && d.to), ...)` so the 5 000 ceiling only unlocks when a range is supplied, or split the ceiling into two named tiers (`MAX_LIMIT_DEFAULT = 500`, `MAX_LIMIT_RANGE = 5000`).
|
||||
|
||||
**M-2 — `dispatch-localised` LRU eviction is FIFO, not true LRU, and read-on-eviction is non-atomic.** `src/lib/notifications/dispatch-localised.ts:122` evicts the head when the map hits 1 000 entries; the read path on a fresh hit does `delete` + re-`set` to mark "fresh". Two concerns: (a) The cache is shared across every user inside the worker process — a high-volume admin alert job dispatching to 1 001 distinct users could thrash the head entry, but the worst case is a re-fetched Prisma row, not a cache poisoning vector. (b) The eviction at line 124 reads `firstKey = localeCache.keys().next().value` and deletes it; if `localeCache.size >= LOCALE_CACHE_MAX` was lucky-true and the entry was already evicted by a concurrent call, `firstKey` is `undefined` and the `delete` is a no-op — accepted, no race condition that lets a stale locale leak across users (each entry is keyed by `userId`). The 30-second TTL window is also small enough that a user-changed locale converges quickly. Treat this as a soft-Medium because the trade-off is documented. Fix shape: a comment is enough; if proven hot, swap to a real LRU library (e.g. `lru-cache`) — not a release blocker.
|
||||
|
||||
**M-3 — Coach SSE `enforceBudget` is the only daily cap; no per-minute rate-limit.** `src/app/api/insights/chat/route.ts:11` documents the budget-then-refusal pipeline. The 25 000 tokens-per-user-per-day cap (`src/lib/ai/coach/budget.ts:20`) is the only quantitative gate. A logged-in user can still mount a tight loop that hammers the SSE endpoint until they exhaust the budget; the provider chain absorbs the latency, the wide-event log gets a row per request, and the encrypted persistence layer writes a `MessagePair` per turn. Authentication still bounds the blast radius (cookie / Bearer required), but a compromised session could cost the operator several US dollars in tokens before the day's cap hits. Existing posture is intentional per the kickoff brief; v1.4.28 does not change it. Fix shape: add a per-user-per-minute soft cap (e.g. `checkRateLimit(\`coach:${userId}\`, 6, 60_000)` — six turns per minute is more than any human conversation) on a future iteration; not a v1.4.28 blocker.
|
||||
|
||||
### Low
|
||||
|
||||
**L-1 — `insights/generate` rate-limit envelope shares wording with the user-visible error path.** `src/app/api/insights/generate/route.ts:238` returns `\`Maximum ${limit} insight generations per hour.\`` on the 429. The string is English-only — the iOS client (the only consumer of `/api/insights/generate` after the advisor card retired) cannot route it through the i18n surface, so it surfaces verbatim. No information leak, but the message exposes the operator's configured `INSIGHTS_RATE_LIMIT_PER_HOUR` value to the client. Trade-off accepted in v1.4.16; flagging for completeness.
|
||||
|
||||
**L-2 — Measurement PUT 409 returns a fixed string; PUT body validation rejects negative values via `min(0)`, but legacy values may already exist in DB.** `src/app/api/measurements/[id]/route.ts:88` returns `"A measurement with this timestamp already exists"` — clean, no leak. The `updateMeasurementSchema` (`src/lib/validations/measurement.ts:222`) caps `value` at `[0, 500000]`. Range validation is the create-path job; the update path intentionally relaxes the per-type range check to support legacy edits. Acceptable.
|
||||
|
||||
**L-3 — `glp1` POST audit-log details echo the request fields including the raw `note` text.** `src/app/api/medications/[id]/glp1/route.ts:170` writes `details: { doseValue, doseUnit }` (good) but the `inventory` branch at line 200 logs `delta` and `reason` (where `reason` is user-supplied free text). Free-text logging is bounded by the Zod cap on `reason` (verify in `glp1PostBodySchema`); confirmed bounded. No leak, but if any reviewer later changes the cap upward, the audit-log retention window will store user input verbatim. Note for future hardening: redact or hash free-text fields the same way the auth lane already redacts.
|
||||
|
||||
**L-4 — Web-vitals route returns 200 on every shape, including malformed bodies.** `src/app/api/internal/web-vitals/route.ts:50-55` silently accepts non-JSON. This is the documented design (beacons must never retry), but it also means the route is a confirmed null-result endpoint for an attacker probing for "live" routes. Combined with H-1, the route is the easiest "I exist" oracle on the API surface. Trade-off acceptable; folded into H-1's fix shape (rate-limit the IP).
|
||||
|
||||
## Endpoint-by-endpoint review table
|
||||
|
||||
| Endpoint | Auth | Authz / ownership | Input validation | Output sanitisation | Rate-limit | Verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `PUT /api/measurements/[id]` | `requireAuth()` | `existing.userId !== user.id` → 404 | `updateMeasurementSchema` Zod | P2002 → fixed-string 409 with `errorCode`; no stack | none (per-record edit, low-burst) | clean |
|
||||
| `GET /api/measurements` | `requireAuth()` | `where: { userId }` | `listMeasurementsSchema` Zod (from/to ISO with offset, limit ≤5000) | typed JSON, no leak | none on read | **M-1** on `limit` |
|
||||
| `POST /api/measurements` | `requireAuth()` | userId pinned on create | `createMeasurementSchema` Zod, range refines | P2002 → 409 fixed string | `withIdempotency` dedupe | clean |
|
||||
| `POST /api/insights/generate` | `requireAuth()` | userId pinned | body `{ force }` parsed lenient | error categorised (401/403/429/5xx); body excerpts truncated to 500 chars in wide-event meta (operator-side only) | `checkRateLimit(insights:${userId}, 10/h)` | clean |
|
||||
| `POST /api/medications/[id]/intake` | `requireAuth()` | `assertMedicationOwnership(id, user.id)` | `intakeSchema` Zod | event JSON only; no internal stack | `withIdempotency` + 60 s server-side dedup | clean |
|
||||
| `GET /api/medications/[id]/intake` | `requireAuth()` | `where: { medicationId, userId }` | `listIntakeEventsSchema` Zod | event JSON only | none on read | clean |
|
||||
| `GET /api/medications/[id]/glp1` | `requireAuth()` | `medication.userId !== user.id` → 404 | n/a (read) | iOS-shaped DTO | none on read | clean |
|
||||
| `POST /api/medications/[id]/glp1` | `requireAuth()` | `assertMedicationOwnership` | `glp1PostBodySchema` (XOR refinement) | created-row echo, no stack | `checkRateLimit(medication-glp1:post:${user.id}, 30/min)` | clean |
|
||||
| `POST /api/internal/web-vitals` | none (intentional) | n/a | typed-shape probe, all fields optional | always 200, no body except `{ ok: true }` | none | **H-1** |
|
||||
| `POST /api/insights/chat` (Coach SSE) | `requireAuth()` (cookie or Bearer) | userId pinned; `coachChatRequestSchema` | refusal pattern + Zod | SSE frames; refusal handled before any provider | `enforceBudget` daily 25 k tokens, no per-minute | **M-3** |
|
||||
| `POST /api/internal/deploy-webhook` | `hasValidSecret(request)` | n/a | `safeJson` + payload normaliser | normaliser-output JSON, no echo | `checkRateLimit(deploy-webhook:${ip}, 60/min)` | clean (existing) |
|
||||
|
||||
## Cross-cutting checks
|
||||
|
||||
**Input validation across the diff.** Every changed handler still routes user input through a Zod schema before the Prisma call. The new range-aware `listMeasurementsSchema` parses `from` / `to` via `z.iso.datetime({ offset: true }).transform((s) => new Date(s))` — invalid strings fail the schema with a 422 before the DB sees them. The `aggregate` enum is locked to `["raw", "daily", "weekly"]`. Both `from` and `to` are `Date` instances by the time they reach Prisma, so SQL injection is structurally impossible (Prisma parameterises every `gte` / `lte`). No string concatenation into a query, no raw SQL, no dynamic table names anywhere in the diff.
|
||||
|
||||
**Take-cap enforcement at the query.** Spot-checked seven `*-status.ts` files (`pulse`, `bmi`, `general`, `blood-pressure`, `mood`, `weight`, `medication-compliance`). Every changed read site applies a `take:` directly on the Prisma `findMany` call. The largest cap (`general-status.ts:156` — `take: 5000`) is on a multi-type union; the others are `1095` (3 years of daily weight / BP), `365` (1 year of pulse / BMI input), or `90` (mood enrichment). Downstream `applyPayloadBudget` trims further. The kickoff brief asked for "cap enforced at the query, not after-the-fact" — confirmed.
|
||||
|
||||
**Provider timeouts.** `src/lib/insights/with-timeout.ts` lands a clean envelope with `STATUS_PROVIDER_TIMEOUT_MS = 20_000`. Both `timedOut` and the upstream catch branch resolve with the fallback — the fallback is intentionally not persisted to the cache (commented at `pulse-status.ts:307`), which prevents cache poisoning from a single stalled upstream call. No leak of the upstream provider's error body to the client.
|
||||
|
||||
**Prompt injection on Coach SSE.** Untouched by v1.4.28 — `detectRefusal()` still runs before the provider chain (`src/app/api/insights/chat/route.ts:14`). The new range-aware aggregator passes through ISO `Date` instances and integer counts to the prompt builder, so it cannot smuggle injection material via the `from`/`to` fields. The `notes` field on a measurement is capped at 25 characters by the create schema and the update schema; the value column is numeric. The insights snapshot summary text strips chart tokens via `stripChartTokens` before being shown to the model. No new attack surface.
|
||||
|
||||
**SSRF / open redirect.** No new outbound HTTP from any changed handler. The web-vitals route is in-process; the dispatch-localised LRU resolves through Prisma. The insights generate route only fetches via the configured provider chain, which is operator-controlled through Settings. No URL parameters parsed into `fetch()` anywhere in the diff.
|
||||
|
||||
**Session / cookie handling.** Unchanged. `getSession()` in `requireAuth` flow is identical to v1.4.27. No new cookies set, no new SameSite changes, no new CSRF token surface (HealthLog is bearer + cookie session, no CSRF by design — documented at `api-handler.ts:32`).
|
||||
|
||||
**Secrets in commits or `.env`.** Quick scan over the diff returned one occurrence of `secret = "test-secret-key"` in `src/lib/auth/__tests__/hmac.test.ts` (existing, test-only). No `.env` content committed; the working tree `.env` is untracked and the `.env.example` does not carry live values. No new env variables documented in the v1.4.28 diff.
|
||||
|
||||
**iOS contract additive-only check.** The R1.4 audit was not yet on disk at the time of this review, so the iOS contract review was done against the v1.5 iOS handoff pack and the `glp1` / `intake` route shapes. The `GET /api/measurements/[id]`, `PUT`, and `DELETE` shapes are unchanged from v1.4.27 — the new P2002 catch is additive (it converts a former 500 into a clean 409 + `errorCode`). The `GET /api/medications/[id]/glp1` response shape is unchanged. The `POST /api/medications/[id]/intake` body and response shape are unchanged. The new `aggregate` query parameter on `GET /api/measurements` is opt-in — iOS clients that do not pass `from` / `to` / `aggregate` see the historical wire shape (the `if (from && to)` branch at `route.ts:62` is the only one that returns the new `{ type, value, measuredAt, count }` shape). No iOS contract regression on the security lane.
|
||||
|
||||
**Audit-log writes.** Every changed write path retains its `auditLog(...)` call (`measurement.update` / `medication.intake` / `medication.glp1.update` / `insights.generate`). The `evictPerStatusInsightCache` keeps the `insights.generate` row and only deletes the `insights.<scope>-status.<locale>` cache entries — no audit-log loss.
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 1 (H-1, web-vitals rate-limit) |
|
||||
| Medium | 3 (M-1 limit ceiling, M-2 LRU FIFO, M-3 Coach per-minute) |
|
||||
| Low | 4 |
|
||||
|
||||
**Top risk:** the new `/api/internal/web-vitals` route is unauthenticated, un-rate-limited, and emits one wide-event line per request. A trivial flood from a single peer amplifies into log-pipeline cost. Recommend folding a per-IP `checkRateLimit(...)` into the handler before tag — same shape as `deploy-webhook`. Fix is contained, additive, and does not change the beacon's public contract.
|
||||
|
||||
**Go / no-go:** **go**, with H-1 landed as a follow-up commit on `develop` before the release-PR opens. The four Mediums + four Lows are documented; they do not block the v1.4.28 tag. M-3 (Coach per-minute) is the longest-standing posture decision and stays as-is per the kickoff brief.
|
||||
|
||||
Hand-off to R4 reconcile: stage H-1 as a `fix(api): rate-limit the web-vitals beacon` commit. Schema-side tightening for M-1 can ride in the same commit if time permits — otherwise carry to v1.4.29 backlog with `M-1: gate the 5 000 limit ceiling behind from/to presence`.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-senior-dev.md
|
||||
purpose: R4 senior-dev review — architecture, schema, edge cases, race conditions across the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 senior-dev
|
||||
---
|
||||
|
||||
# v1.4.28 R4 — senior-dev review
|
||||
|
||||
Scope: 30 commits since `v1.4.27` on `develop` (`538b44f7` → `5570971f`), 126 files, +4355 / −8227. Architectural and correctness eye. Read-only.
|
||||
|
||||
Plan and r1-performance documents referenced in the brief (`.planning/v1428-fix-plan.md`, `.planning/research/v1428-r1-performance.md`) are not present in the tree at review time — calibration runs against the marathon-kickoff (`v1428-marathon-kickoff.md`) and the feedback intake (`v1428-feedback-2026-05-15.md`) instead. Findings reference the actual diffs.
|
||||
|
||||
---
|
||||
|
||||
## Severity-grouped findings
|
||||
|
||||
### Critical
|
||||
|
||||
| ID | Severity | File:line | Architectural concern | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **SD-C1** | Critical | (no commit since v1.4.27) | **FB-B1 not addressed.** Maintainer feedback flagged "Sport edit + save → error" as Critical. The only save-path fix in the diff is `538b44f7` on `/api/measurements/[id]` (P2002 → 409). There is no PUT / PATCH `/api/workouts/[id]` route in the tree, and no commit since v1.4.27 touched the workout edit path. If FB-B1 actually meant the workout-detail edit Sheet, the bug is unrepaired. If it meant the measurement edit Sheet, the fix is real but the release note should call that out so the maintainer knows what got addressed. | Reproduce FB-B1 against `healthlog.bombeck.io` before tag. If the error is measurement-edit, document the mapping (FB-B1 == 409 fix) in the closure report. If it is workout-edit, ship the analogous fix on whichever route the workout-detail surface POSTs to (likely an as-yet unaudited handler). |
|
||||
| **SD-C2** | Critical | `src/app/api/internal/web-vitals/route.ts:1` (`ebf83b1e`) | Beacon route is publicly reachable, unauthenticated, **no rate limit, no CORS gate**. Comment claims "internal-only by name; CSP + same-origin keeps cross-site out" — false; any client can POST. Every accepted payload calls `annotate(...)` and lands in the wide-event pipeline. A drive-by flood writes unbounded entries into Sentry / log storage and costs money. | Gate with `apiHandler` auth (Web Vitals only fire from authenticated pages) or wire the existing rate-limiter (~30 req/min/IP) plus an origin allow-list. Pure beacon does not mean ungoverned ingestion. |
|
||||
|
||||
### High
|
||||
|
||||
| ID | Severity | File:line | Architectural concern | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **SD-H1** | High | `src/components/charts/health-chart.tsx:519-538` (`b00be286`) | **Behaviour change on the "All time" range.** Pre-fix the chart walked the full measurement history; `computeWindowTrend` ran split-half delta across the user's true lifetime. Post-fix `rangePoints === 0` silently defaults to a 365-day window. A user with three years of weight sees "All time" trend = trailing 12 months. Commit body never acknowledges this. | (1) When `rangePoints === 0` pass `from` = the user's earliest measurement via a tiny `findFirst`; weekly aggregation collapses the payload. (2) Or relabel `charts.pointsAllLabel` to "Last 12 months" so the UI matches the data. Either decision is fine; the silent narrowing is not. |
|
||||
| **SD-H2** | High | `src/lib/notifications/dispatch-localised.ts:84-131` (`b0ef80dc`) | **LRU TTL has no proactive sweep — expiry is passive.** The cache only checks `expiresAt > Date.now()` on read. A worker that dispatches to user `A` once then to thousands of distinct users never re-reads `A`; the stale entry sits in the Map until FIFO eviction at the 1000-entry cap. Memory bound is fine. The real risk: comment claims "next read after 30 s re-validates" but reads, not wall-clock, drive validation. Doc misleads future maintainers. | Optional: a 60-s `setInterval` sweep is 10 LOC. Or fix the comment to "passive expiry on read". |
|
||||
| **SD-H3** | High | `src/hooks/use-insight-status.ts:85` (commit `0d591ac9`) | `retry: 0` on every `useInsightStatus` query is paired with the deterministic-fallback shape from the status routes — that pairing is sound. But **any genuine 5xx from the route itself** (Prisma down, route throws before the timeout wraps the provider) now surfaces as a hard error with zero retries. Previously React-Query's default 3 retries would absorb a transient Prisma blip. The status card user experience for real outages got worse, not better. | Set `retry: (failureCount, error) => failureCount < 2 && /* error is not the deterministic-fallback envelope */` — or, simpler, keep `retry: 0` but make the route catch every error path and always return the deterministic envelope (so the 200 status code carries the failure signal in the body). The current shape does both: 200 + envelope on provider timeout, 5xx + throw on Prisma error. Pick one. |
|
||||
| **SD-H4** | High | `235e52cb` / `0e7c97c5` | **Two mislabelled commits.** `235e52cb` says "single HealthChartDynamic re-export" but the diff carves out `<MobileRailTray>`. `0e7c97c5` says "align briefing empty-state CTA variant" but the diff is the trends-row equal-height contract (the CTA flip is one line). The real chart-re-export work is in `8f3bfc37`. Functionally correct; auditability is the cost — `git log --grep` misses the change locations. | Rewrite the two commit messages before tag (interactive rebase on develop). |
|
||||
| **SD-H5** | High | `src/components/insights/metric-empty-state.tsx:60-72` | The doc-comment promises "The Coach launch always renders (BK-MED-4)". After `4c6d8779` consolidated the FAB into `<LayoutCoachFab>`, `<CoachLaunchButton>` renders **nothing on phone-class viewports** (the inline pill is `hidden lg:inline-flex`). The empty state on phone therefore has no inline Coach affordance — only the global FAB at the layout root. Functionally the FAB is always visible, but the empty state's own copy claims a per-card affordance. | Either drop the `<CoachLaunchButton>` mount from `<MetricEmptyState>` (acknowledge the FAB is the answer on phone, the inline pill on desktop) — or change `<CoachLaunchButton>` to render the inline pill at every breakpoint when it is mounted inside an empty state (use a `prominent` prop). Current state is "renders nothing on phone, the comment lies about it." |
|
||||
|
||||
### Medium
|
||||
|
||||
| ID | Severity | File:line | Architectural concern | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **SD-M1** | Medium | `src/components/insights/health-score-delta-explainer.tsx:84-95` | The mobile branch wraps the inner `<button>` trigger in a non-focusable `<span onClick onKeyDown>`. The keydown is dead (the span has no `tabIndex` so it never holds focus); the click works only because the inner button's default click bubbles to the span. Net effect: a redundant outer wrapper that mistakes itself for keyboard-handler scaffolding. | Replace the span wrapper with a direct `<button onClick={() => setOpen(true)}>` and drop the cloned trigger pattern. The popover branch's `<PopoverTrigger asChild>` already does the right thing; do the equivalent on the sheet branch by lifting `setOpen` into the inner button. |
|
||||
| **SD-M2** | Medium | `src/components/charts/health-chart-dynamic.tsx:1-29` (commit `8f3bfc37`) | `<HealthChartDynamic>` re-export collapses six call sites onto one boundary — good. But the dynamic-import inference loses the explicit props contract: callers now pass `<HealthChartDynamic types={...} title={...} ... />` whose typing relies on `dynamic()` round-tripping the inner module's component type. If a future refactor renames a `<HealthChart>` prop, six call sites get a type error from the inferred type — fine — but a wrong-type assignment at the call site shows up as the dynamic loader's generic prop typing first, which can be confusing. | Add an explicit `ComponentProps<typeof HealthChart>` re-export alongside the dynamic — `export type HealthChartDynamicProps = ComponentProps<typeof HealthChart>` — so callers can spread typed props through. Cheap forward-compat. |
|
||||
| **SD-M3** | Medium | `src/lib/insights/with-timeout.ts:26-64` | The helper resolves with `{ timedOut: true, value: fallback }` on **both** timeout and upstream rejection. That is intentional (commented "the caller does not have to learn another failure mode") but means the `timedOut` field overloads two distinct failure modes. The status routes treat them identically; future callers that need to distinguish "took too long" vs "actively errored" cannot. | Optional. Either keep the current shape (and document the overload explicitly) or widen the envelope to `{ outcome: "ok" \| "timeout" \| "rejected"; value: T }`. Tests pin the current shape so either way the change is contained. |
|
||||
| **SD-M4** | Medium | `src/components/insights/sub-page-shell.tsx:64-82` (commit `ac80c099`) | The `rAF`-deferred scroll-reset effect depends on `[focusOnMount]` and cleans up via `cancelAnimationFrame`. Mount-time race: on a fast back-navigation from sub-page → `/insights`, the sub-page's `<SubPageShell>` unmounts (cancel fires), then `/insights` page mounts and schedules its own rAF. Cleanup-vs-schedule is single-threaded so this works. The subtler issue: `focusOnMount` is a prop, so any caller that re-renders the shell with `focusOnMount={true}` triggers a re-schedule even on data fetches mid-page-life. Today no caller flips this mid-session, but the effect would fire a `scrollTo({top: 0})` on every change — visible jump. | Decouple: scroll-reset effect on `[]`, focus effect on `[focusOnMount]`. Two effects, two intents. Current single effect mixes them. |
|
||||
| **SD-M5** | Medium | `src/app/api/measurements/route.ts:56-93` (commit `b00be286`) | Server-side aggregation only triggers when **both** `from` AND `to` are present AND the window crosses the threshold ladder. An iOS or third-party client that passes only `from` (or only `to`) goes through the legacy raw path with the new `limit=5000` cap. A scripted client that passes `from` = "1970-01-01" and `to` = "2099-01-01" gets `aggregate=weekly` (good) but a client that passes `from` = "1970-01-01" with no `to` gets raw + 5000-row cap → silent truncation of decades of history. | Default `to` to `now` server-side when missing (one-liner). The legacy contract truncated to 500 rows; the new contract should at least truncate to "the bounded window the client asked for". Document the additivity claim — the iOS contract notes "the iOS client omits all three params" which is fine — but the partial-param case is undefined behaviour today. |
|
||||
| **SD-M6** | Medium | `src/lib/insights/coach-launch-context.tsx:35-46` (commit `66e13845`) | Narrowing `CoachLaunchScope.metric` from `string` to `CoachScopeSource` is the right call. **But the field is unused.** Every `askCoach()` call site in the diff passes only `prefill`. The drawer never consumes `scope.metric` from the launch context (sources rail uses its own scope state). The narrowed type is dead. | Either wire a real consumer (the drawer reads `coachScope.metric` and pre-narrows the sources rail) or remove the field entirely. Currently it is "documented intent" that the type system enforces — fine, but dead code accrues if v1.4.29 doesn't follow through. Mark it `@deprecated` if unused at tag time. |
|
||||
| **SD-M7** | Medium | `src/components/insights/__tests__/trends-row.test.tsx:81-90` (commit `0e7c97c5`) | The `next/dynamic` stub in the test renders a single `<div data-slot="trends-row-chart-stub">` for both `<HealthChart>` and `<MoodChart>`. The new assertion `expect(slots.length).toBe(3)` counts `data-slot="trends-row-chart-slot"` wrappers — but the inner chart stub paints uniformly across BP/weight/mood, so a regression that swapped mood's wrapper out wouldn't be caught (the mood `<MoodChart>` has its own dynamic import; the stub catches both via the `vi.mock("next/dynamic")` global). The unit-test masks the behaviour change of the mood chart inheriting the same wrapper. | Acknowledge this as a snapshot-level guarantee, not a behavioural one. Add a Playwright spec that asserts the chart-band top edge is within 4 px across the three tiles in the live row. Pin the trends-row visual contract end-to-end, not just by class name. |
|
||||
|
||||
### Low
|
||||
|
||||
| ID | Severity | File:line | Architectural concern | Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| **SD-L1** | Low | `src/components/charts/health-chart.tsx:549-558` | New `staleTime: 60_000` + `gcTime: 5min` on the chart query is fine. Comparison-overlay re-keys via `effectiveCompareBaseline` — up to 48 cache entries per Insights surface across charts × ranges × overlays. Bounded but worth a comment. | No action — `gcTime` will prune. |
|
||||
| **SD-L2** | Low | `src/lib/measurements/range-aggregation.ts:115-127` | `bucketStartFor(d, "weekly")` mutates the constructed `monday` Date via `setUTCDate`. The historical DST off-by-one pattern. | Cosmetic. Replace with `new Date(monday.getTime() - (day - 1) * 86_400_000)`. |
|
||||
| **SD-L3** | Low | `src/components/insights/__tests__/trends-row.test.tsx:73-79` | `expect(html).toMatch(/\bauto-rows-fr\b/)` matches any occurrence of the class anywhere in SSR output, even in text content. Fragile. | Anchor on the container's class attribute. |
|
||||
|
||||
---
|
||||
|
||||
## Per-new-primitive assessment
|
||||
|
||||
| Primitive | File | Verdict | Notes |
|
||||
|---|---|---|---|
|
||||
| `<MetricEmptyState>` | `src/components/insights/metric-empty-state.tsx` | Solid carve-out | One-line ergonomic wrapper around `<EmptyState>` + `<CoachLaunchButton>`. Six sub-pages adopt it cleanly. Doc-comment claims a guarantee (Coach always renders) the underlying button does not honour on phone (see SD-H5). Otherwise correct. |
|
||||
| `<HealthChartDynamic>` | `src/components/charts/health-chart-dynamic.tsx` | Solid carve-out | Six call-sites collapse onto one re-export with the right `ssr: false` + `<ChartSkeleton />` loading state. Re-export inferred props (see SD-M2) — minor. |
|
||||
| `<MobileRailTray>` | `src/components/insights/coach-panel/mobile-rail-tray.tsx` | Solid carve-out | Pure presentational shell, takes pre-rendered rail nodes, owns no state. Coach-drawer net LOC drops from ~80 inline-Sheet repetitions to one component call. Commit mislabelled (SD-H4). |
|
||||
| `useInsightsAnalytics` | `src/hooks/use-insights-analytics.ts` | Solid carve-out | Five sub-pages drop 16 LOC each onto a 35-LOC shared hook with `queryKey: queryKeys.analytics()` (consistent with the dashboard cache) and an `isEmpty` derived flag that doesn't flash before data lands. Mood + medication kept their bespoke fetch — correct call, doc notes the reason. |
|
||||
| `<HealthScoreDeltaExplainer>` | `src/components/insights/health-score-delta-explainer.tsx` | Functional, two polish gaps | Right architectural choice: popover on md+, sheet on phone via existing `<ResponsiveSheet>`. Six locales native translations. Mobile keyboard accessibility path is non-idiomatic (SD-M1). |
|
||||
| `withTimeout` + `STATUS_PROVIDER_TIMEOUT_MS` | `src/lib/insights/with-timeout.ts` | Right shape, one overload | 64-LOC promise race with typed envelope. Wired into seven status routes. Conflates `timedOut` for "took >20s" and "rejected before timeout" (SD-M3). |
|
||||
| `aggregateRows` / `pickAggregateGrain` / `rangeLengthDays` | `src/lib/measurements/range-aggregation.ts` | Right shape, weekly-bucket mutation | Server-side range aggregation behind threshold ladders. Daily threshold 90 d, weekly 365 d. Linear in-memory aggregation acceptable given the bounded `take`. Weekly-bucket mutation pattern (SD-L2) is cosmetic. |
|
||||
| `<LayoutCoachFab>` | `src/components/insights/layout-coach-fab.tsx` | Solid carve-out | Fixes a real a11y-tree duplicate (multiple FAB nodes on phone, each from a sub-page's `<CoachLaunchButton>`). Mounted once at the Insights layout root. The trade-off is that `<CoachLaunchButton>` now renders nothing on phone (SD-H5). |
|
||||
| `<MedicationCardHeader>` / `<MedicationDetailSection>` | commits `6f6992c6` / `5109e930` | Out-of-scope here | Visual carve-outs; refer to UI-conformity reviewer. |
|
||||
|
||||
---
|
||||
|
||||
## Schema-migration audit
|
||||
|
||||
`git diff v1.4.27..HEAD -- prisma/` returns **empty**. Zero Prisma schema or migration changes in this release. Consistent with the "polish cycle" framing in the kickoff. No iOS-contract risk from this surface.
|
||||
|
||||
The new `aggregate` query param on `GET /api/measurements` is additive (Zod schema accepts a missing field; the route's aggregation branch only engages when both `from` and `to` are present). The new 409 path on `PATCH /api/measurements/{id}` is additive (replaces a 500). Both are safe for the iOS client per commit-body claims; spot-checked.
|
||||
|
||||
---
|
||||
|
||||
## React-Query consistency
|
||||
|
||||
- `queryKeys` factory used by `useInsightStatus` and `useInsightsAnalytics`. The new chart-data `queryKey` in `b00be286` is a raw literal array (`["chart-data", types, rangePoints, ..., userTimezone, fetchWindow.from, fetchWindow.to]`) — not routed through `queryKeys`. Six other chart-data keys in the codebase use the same pattern (pre-existing), so this is consistent with the surrounding code but inconsistent with the broader `queryKeys` convention. Worth a v1.4.29 cleanup pass.
|
||||
- `staleTime` defaults are aligned: 60 s across chart-data, insight-status, analytics. `gcTime` is only set on the chart-data query (5 min). Defaults elsewhere = 5 min Tanstack default. Consistent.
|
||||
- `retry: 0` on `useInsightStatus` documented. No other query in the diff disables retries. Pairs with the deterministic-fallback shape on the route side (see SD-H3).
|
||||
- No queryKey collisions detected in this diff. The `["analytics"]` key is shared across the dashboard, the Insights mother page, and the new hook — by design, so the cache unifies. Unwrap shape (`.data` on the envelope) is consistent across the three callers per `feedback_react_query_key_collision.md`.
|
||||
|
||||
---
|
||||
|
||||
## Sentry / logger / observability
|
||||
|
||||
- `annotate(...)` instrumentation lands on the new web-vitals route — names follow the `web_vitals.*` namespace convention. Good.
|
||||
- `getEvent()?.addWarning(...)` on the dispatch-localised translator-fallback path is preserved. Cache-miss DB errors emit a warning then fall back to `defaultLocale`. Consistent.
|
||||
- Range-aggregation downsampling path emits `annotate({ meta: { aggregate: grain } })` so downstream analytics can split aggregated vs raw queries. Good.
|
||||
- **Missing**: the `withTimeout` envelope's `timedOut === true` path on the status routes does **not** emit a Sentry breadcrumb or warning. If 50% of pulse-status calls are timing out, ops has no signal. The routes silently return the no-key fallback text. Recommend wiring `annotate({ meta: { "insight_status.timeout": true, "insight_status.metric": "pulse" } })` on the timeout branch in each of the seven `*-status.ts` routes.
|
||||
- **Missing**: the new 409 path on `PATCH /api/measurements/{id}` does not annotate the violation. The legacy 500 at least logged the exception. Recommend `annotate({ meta: { "measurement.duplicate_timestamp": true } })` on the catch.
|
||||
|
||||
---
|
||||
|
||||
## Race-condition checks
|
||||
|
||||
| Surface | Concern | Verdict |
|
||||
|---|---|---|
|
||||
| `<SubPageShell>` rAF scroll-reset (`ac80c099`) | sub-page unmount → mother-page mount cancel/schedule | Safe — single-threaded JS, cleanup runs before the next mount's effect. See SD-M4 for the effect-deps tweak. |
|
||||
| `<LayoutCoachFab>` mount + `<CoachLaunchButton>` mount | duplicate FAB in a11y tree | Resolved by the consolidation. Verified: `<CoachLaunchButton>` returns the inline pill only, FAB now lives once at layout root. |
|
||||
| `withTimeout` promise race | double-resolve | Internal `settled` flag guards against. Clean. |
|
||||
| LRU cache concurrent reads | two parallel `resolveRecipientLocale("u-1")` cache-miss calls | Both fire `prisma.user.findUnique`, both `localeCache.set(...)`. Second write overwrites first — same value, no harm, one wasted query. Acceptable. |
|
||||
| Parallel `git add` race documented in `.planning/round-3c-coach-report.md` | working-tree contamination from concurrent commits | Mitigated by explicit-path `git add` per the report. No lost work in the diff; tree is clean against `git log --stat`. |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Severity counts**: 2 Critical, 5 High, 7 Medium, 4 Low.
|
||||
|
||||
**Top architectural concern**: the unauthenticated, unrate-limited `/api/internal/web-vitals` beacon (SD-C2) and the unaddressed FB-B1 workout-edit save error (SD-C1). The first is a real cost-of-ingestion risk; the second is a maintainer-flagged Critical bug whose mapping to a shipped fix is ambiguous.
|
||||
|
||||
**Architectural posture**: the new primitives (`<MetricEmptyState>`, `<HealthChartDynamic>`, `<MobileRailTray>`, `useInsightsAnalytics`, `<HealthScoreDeltaExplainer>`, `<LayoutCoachFab>`) are all legitimate carve-outs from real duplication. The performance fix (bounded fetch window + server-side aggregation) is the right shape; the "All" range semantic narrowing (SD-H1) is the one decision the diff makes silently. The LRU cache pattern is sound; the `withTimeout` helper is reusable. Zero Prisma migrations — clean polish-cycle posture. Two mislabelled commits (SD-H4) cost auditability but no behaviour.
|
||||
|
||||
**Recommendation**: **conditional go**. Resolve SD-C1 (reproduce + map or fix FB-B1) and SD-C2 (gate the web-vitals route) before tag. Address SD-H1 (decide the "All" range semantics) before the CHANGELOG goes out — the maintainer may opt for the 365-d narrative or the true-all-time fix. Defer SD-H2/H3/H4/H5 to v1.4.28-final or v1.4.29 depending on cycle headroom. Mediums and Lows ride along.
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-simplifier.md
|
||||
purpose: R4 simplifier review — dead code, duplications, missed consolidations across the v1.4.28 diff
|
||||
created: 2026-05-16
|
||||
contributor: R4 simplifier
|
||||
---
|
||||
|
||||
# R4 simplifier review — v1.4.28
|
||||
|
||||
Read-only review of the 50-commit diff since `v1.4.27`. Focus: the six new primitives introduced by the round (`<MetricEmptyState>`, `<HealthChartDynamic>`, `<MobileRailTray>`, `useInsightsAnalytics`, `<ChartSkeleton>`, `<HealthScoreDeltaExplainer>`), the seven Insights sub-pages, the Coach-launch consolidation (target: 3 shapes), the retire-from-code directives (FB-A1, FB-A2, FB-E1, FB-E2, FB-J1, FB-J2), and locale-bundle hygiene.
|
||||
|
||||
Tally:
|
||||
|
||||
| Tier | Count |
|
||||
|---|---|
|
||||
| High | 2 |
|
||||
| Medium | 5 |
|
||||
| Low | 3 |
|
||||
| Dead-code candidates (i18n orphans) | ~49 keys × 6 locales |
|
||||
|
||||
No Critical findings. The new primitives landed in the right places; the surviving pattern duplications all sit one tier below the maintainer's Critical / High floor.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### S-H1 — `<HealthChartDynamic>` re-export not consumed by two surviving call sites
|
||||
|
||||
- **file:line** — `src/components/insights/trends-row.tsx:33-44`, `src/components/insights/vo2-max-chart-row.tsx:29-39`
|
||||
- **What's redundant** — `8f3bfc37` migrated five `dynamic(() => import("@/components/charts/health-chart"))` call sites (`src/app/page.tsx` + the four sub-pages) onto the shared `<HealthChartDynamic>` re-export, and the docstring on `src/components/charts/health-chart-dynamic.tsx:8-22` claims "six call sites". Two consumers were missed: `trends-row.tsx` and `vo2-max-chart-row.tsx`. Both still hand-roll the same `dynamic(...)` block plus a local degenerate `ChartSkeleton` (`<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />`) that bypasses the canonical `<ChartSkeleton>` primitive landed in `d286220b` (proper border, aria-busy, sr-only label, header-row mirror, chart-height CSS variable contract).
|
||||
- **How to simplify** — Replace each `const HealthChart = dynamic(...)` with `import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"` and rename the JSX consumer. The prop shapes already match — `vo2-max-chart-row.tsx:208-216` forwards `chartKey | types | title | colors | unit | compareBaseline | userTimezone`, all of which pass straight through `dynamic()`'s inferred type. Delete the local `ChartSkeleton` constants. Net: ~25 LOC removed across the two files; both surfaces inherit the better loading shell automatically.
|
||||
- **Effort** — S (≤30 min, mechanical).
|
||||
- **In-scope-for-v1.4.28** — yes. Same primitive, same primary intent the round's R3d already executed; finishing the migration prevents the docstring from becoming a lie before the round closes.
|
||||
|
||||
### S-H2 — Insights mother page still hand-rolls the analytics query the new hook replaced
|
||||
|
||||
- **file:line** — `src/app/insights/page.tsx:135-144`
|
||||
- **What's redundant** — `useInsightsAnalytics()` (`src/hooks/use-insights-analytics.ts`) consolidated the React-Query block five sub-pages duplicated. The mother page (`/insights`) still inlines its own `useQuery({ queryKey: ["analytics"], queryFn: ... })` block with a private `AnalyticsData` interface, even though the hook reads the exact same endpoint into the exact same cache slot. The hook uses `queryKeys.analytics()`; the mother page hard-codes `["analytics"]` — minor consistency drift and a future query-key-collision exposure of the type `feedback_react_query_key_collision.md` warns against.
|
||||
- **How to simplify** — Either (a) consume `useInsightsAnalytics()` directly and pick the metric the page is actually gating on, or (b) add a typed mother-page variant that returns the full `AnalyticsData` payload (the hook returns `SubPageAnalyticsData` — the mother page reads more fields). Option (b) is the safer call because the mother page's gate is "any data at all" not a per-metric gate.
|
||||
- **Effort** — S.
|
||||
- **In-scope-for-v1.4.28** — yes; one file, no contract change.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### S-M1 — `MoodChart` dynamic-import duplicated three times
|
||||
|
||||
- **file:line** — `src/components/insights/trends-row.tsx:46-52`, `src/app/page.tsx:48-54`, `src/app/insights/stimmung/page.tsx:29-35`
|
||||
- **What's redundant** — Three callers each declare `dynamic(() => import("@/components/charts/mood-chart").then(...))` with `{ ssr: false, loading: () => <ChartSkeleton /> }`. Same primitive opportunity that `<HealthChartDynamic>` already addresses for `<HealthChart>`.
|
||||
- **How to simplify** — Add a sibling `<MoodChartDynamic>` re-export next to `health-chart-dynamic.tsx`. Three call sites collapse to a one-line import.
|
||||
- **Effort** — S.
|
||||
- **In-scope-for-v1.4.28** — defer-to-v1.4.29 unless the round is already touching `MoodChart` for FB-K. The `<HealthChartDynamic>` precedent makes this a four-line file and a three-line per-caller change, so opportunistic if R3d revisits.
|
||||
|
||||
### S-M2 — Inline `["analytics"]` literal cache key vs `queryKeys.analytics()`
|
||||
|
||||
- **file:line** — `src/app/insights/page.tsx:136`, plus the hook at `src/hooks/use-insights-analytics.ts:58`
|
||||
- **What's redundant** — Two writers of the same cache slot; one uses the helper, one hard-codes the array. The `queryKeys` registry exists precisely to prevent this. Folded into S-H2; pulling out for severity granularity because the literal also exists independent of the hook migration.
|
||||
- **How to simplify** — Replace `queryKey: ["analytics"]` with `queryKey: queryKeys.analytics()`.
|
||||
- **Effort** — S (one line).
|
||||
- **In-scope-for-v1.4.28** — yes.
|
||||
|
||||
### S-M3 — Local `ChartSkeleton` constants shadow the canonical primitive
|
||||
|
||||
- **file:line** — `src/components/insights/trends-row.tsx:34-36`, `src/components/insights/vo2-max-chart-row.tsx:29-31`
|
||||
- **What's redundant** — Both files define `const ChartSkeleton = () => <div className="..." />` with a 220 px fixed height + a single `bg-muted/40` div. The shared `<ChartSkeleton>` (`src/components/charts/chart-skeleton.tsx`) honours the chart-height CSS variable, paints a layout-stable card chrome, exposes `aria-busy + role="status"`, and includes the sr-only "Loading chart…" label. The local versions are strictly worse on a11y, layout-stability, and motion-reduce conformance.
|
||||
- **How to simplify** — Delete both local constants and pass `() => <ChartSkeleton />` from the shared module (the same shape the dashboard adopts at `src/app/page.tsx:53`). Falls out of S-H1 once the call sites migrate to `<HealthChartDynamic>`.
|
||||
- **Effort** — S (paired with S-H1).
|
||||
- **In-scope-for-v1.4.28** — yes.
|
||||
|
||||
### S-M4 — ~49 confirmed orphan i18n keys across six locales
|
||||
|
||||
- **file:line** — `messages/{de,en,es,fr,it,pl}.json` — see appendix at bottom.
|
||||
- **What's redundant** — A 78-key orphan scan against the live source produces 29 false positives (dynamic key lookups like `medications.site${suffix}` or `medications.sideEffects.entries.${entryI18nKey(row.entry)}` or `onboarding.shell.step${n}of4`) and ~49 confirmed orphans with zero runtime consumer. The biggest cluster (18 keys) sits in the `insights.*` namespace and traces back to the J1/J2 InsightAdvisorCard retire (`startAnalysis`, `refreshAnalysis`, `heroFinding{Positive,Neutral,Attention,Warning}`, `keyTakeaway`, `findingsTitle`, `recommendationsTitle`, `correlationsTitle`, `dataFoundation`, `dataGaps`, `heroRegenerate`, `recommendation.*`, `generalStatusBadge.critical`). Smaller clusters in `insights.coach.*` (11 keys — settings-evidence + rate-limit + sources-rail copy with no current consumer), sub-page empty-state drafts (7 keys), `medications.*` intake-history copy (6 keys orphaned by the FB-E1/E2 retire — `invalidTimestamp`, `loginRequiredIntakes`, `noIntakesYet`, `pageInfo`, `previousPage`, `nextPage`), `medications.source{Web,Api,Reminder,Import}` (4 keys; `measurements.source*` is the real consumer), `admin.feedback.tab*` (4 keys), confidence-band placeholders (3 keys), and `format.timeShort` + `format.dateTime` (2 keys).
|
||||
- **How to simplify** — Sweep ~49 keys × 6 locales in one mechanical commit. Pairs with the FB-J1/J2 + FB-E1/E2 retire buckets the round already executed — the test guard at `src/app/__tests__/insights-polish.test.ts` confirms no source consumer remains.
|
||||
- **Effort** — S (one batch edit, contained to `messages/*.json`).
|
||||
- **In-scope-for-v1.4.28** — yes. The R4 i18n reviewer will likely flag the same orphans; closing them now keeps the i18n / dead-code reviewer reports clean.
|
||||
|
||||
### S-M5 — Commit-subject vs commit-content mislabel for `235e52cb` (post-hoc only)
|
||||
|
||||
- **file:line** — git history `235e52cb` (subject: "refactor(charts): single HealthChartDynamic re-export"; actual content: MobileRailTray carve-out + tests).
|
||||
- **What's redundant** — The mislabel is metadata-only; no source duplication landed. `8f3bfc37` ("refactor(charts): collapse health-chart dynamic imports onto re-export") shipped the actual `HealthChartDynamic` migration. The audit confirms `235e52cb` did NOT duplicate the MobileRailTray work — the file `src/components/insights/coach-panel/mobile-rail-tray.tsx` exists once, with one consumer (`coach-drawer.tsx:33`). Same for `8f3bfc37` — no shadow copy of the migration in the diff.
|
||||
- **How to simplify** — No code change. Flag the mislabel in the release closure report so future bisects against `235e52cb` searching the diff for "HealthChartDynamic" don't waste cycles.
|
||||
- **Effort** — S (documentation, not code).
|
||||
- **In-scope-for-v1.4.28** — yes (closure report note).
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### S-L1 — `schlaf` page documents the missing `<InsightStatusCard>` slot but the test doesn't pin the deferral
|
||||
|
||||
- **file:line** — `src/app/insights/schlaf/page.tsx:64-75`
|
||||
- **What's redundant** — A 12-line comment block explains that sleep has no `/api/insights/sleep-status` route yet and the section is intentionally absent for parity-with-deferred-pending-v1.5. Useful as a marker, but neither `src/app/__tests__/insights-polish.test.ts` nor the sub-page test pins "no `<InsightStatusCard>` is mounted on schlaf yet" with a comment or `it.skip("...")` slot. Future contributors may delete the comment as stale.
|
||||
- **How to simplify** — Either (a) collapse the comment to a one-line marker referencing the v1.5 ticket, or (b) add a pinned test assertion that schlaf does not mount `<InsightStatusCard>` yet, with the `// remove this assertion in v1.5` marker. Choice (b) earns its keep — it converts the comment into a guard.
|
||||
- **Effort** — S.
|
||||
- **In-scope-for-v1.4.28** — defer-to-v1.4.29.
|
||||
|
||||
### S-L2 — `<HealthChartDynamic>` docstring says "six call sites" — drift after migration
|
||||
|
||||
- **file:line** — `src/components/charts/health-chart-dynamic.tsx:11-17`
|
||||
- **What's redundant** — The docstring lists the six pre-migration call sites by name. Once S-H1 consumes the primitive from `trends-row.tsx` + `vo2-max-chart-row.tsx`, the count is eight, not six. Future contributors reading this for context will be slightly off-by-two.
|
||||
- **How to simplify** — Update the docstring count when S-H1 lands. One-line edit.
|
||||
- **Effort** — S (paired with S-H1).
|
||||
- **In-scope-for-v1.4.28** — yes (only if S-H1 is in-scope).
|
||||
|
||||
### S-L3 — `daily-briefing.tsx:337-342` carries a multi-line BK-M2 commentary that would read cleaner as a one-liner
|
||||
|
||||
- **file:line** — `src/components/insights/daily-briefing.tsx:336-342`
|
||||
- **What's redundant** — A six-line comment block explains the variant flip from outline to default. The maintainer's "feedback_marc_voice_english.md" prefers terse code comments. The change itself is one line of code (`variant="outline"` → no prop); the commentary outweighs the change.
|
||||
- **How to simplify** — Collapse to "BK-M2 — empty-state CTAs render as the primary affordance." or remove entirely once the test pinning the data-slot lands (it already does at `daily-briefing-empty-cta`).
|
||||
- **Effort** — S.
|
||||
- **In-scope-for-v1.4.28** — defer-to-v1.4.29 (cosmetic).
|
||||
|
||||
---
|
||||
|
||||
## Pattern-by-pattern table
|
||||
|
||||
| Pattern | Expected consumers | Actual consumers | Survivors |
|
||||
|---|---|---|---|
|
||||
| `<HealthChartDynamic>` re-export | every site that dyn-imports `health-chart` | 6 (dashboard + 4 sub-pages + sleep-duration-chart) | 2 survivors: `trends-row.tsx`, `vo2-max-chart-row.tsx` |
|
||||
| Local `ChartSkeleton` shadow | 0 (use canonical primitive) | 2 (trends-row, vo2-max-chart-row) | 2 — same files as above |
|
||||
| `<MoodChart>` `dynamic()` | 1 (shared `<MoodChartDynamic>` re-export, future) | 3 (dashboard, trends-row, stimmung) | 3 — no consolidation yet |
|
||||
| `<MedicationComplianceChart>` `dynamic()` | 1 | 1 (dashboard only) | 0 — not worth consolidating |
|
||||
| `useInsightsAnalytics()` hook | 5 hard-data sub-pages | 5 (puls, blutdruck, gewicht, bmi, schlaf) | 0 (mother page S-H2 is one tier up) |
|
||||
| `<MetricEmptyState>` primitive | every empty-state on insights sub-pages | 6 (5 hard-data + stimmung) | 1 survivor: `medikamente/page.tsx:126` uses `<EmptyState>` directly — appropriate (event-driven gate, different prop shape including `ctaSize="lg"`) |
|
||||
| `<HealthScoreDeltaExplainer>` | 1 (HealthScoreCard) | 1 | 0 — clean |
|
||||
| `<MobileRailTray>` | 1 (CoachDrawer) | 1 | 0 — clean |
|
||||
| `<ChartSkeleton>` canonical | every chart loading slot | 5 explicit + transitively via `<HealthChartDynamic>` | 2 local shadows (S-M3) |
|
||||
| Coach-launch shapes | 3 target (FAB / inline / per-card icon) | 3 (`coach-launch-fab`, `coach-launch-inline`, `target-coach-cta`) | 0 — lands at 3 as planned |
|
||||
| Inline `dynamic(@/components/charts/health-chart)` calls | 0 (all behind `<HealthChartDynamic>`) | 2 | 2 — same S-H1 callers |
|
||||
| Inline `useQuery({ queryKey: ["analytics"] })` blocks | 0 (all behind hook) | 1 (mother page) | 1 — S-H2 |
|
||||
| `messages/*.json` duplicate top-level keys | 0 | 0 | clean — all 6 locales pass `json.object_pairs_hook` duplicate check |
|
||||
| `messages/*.json` cross-locale key parity | 2278 keys × 6 locales matching | 2278 × 6 (perfect parity) | 0 — full parity |
|
||||
| Orphan i18n keys (no runtime consumer) | 0 | ~49 confirmed | ~49 — S-M4 |
|
||||
| FB-A1 GLP-1 tile retire | 0 references | 0 references | clean |
|
||||
| FB-A2 DrugLevelChart dashboard mount retire | 0 dashboard references | 0 dashboard mounts (component still consumed by `/medications/[id]/history`) | clean |
|
||||
| FB-E1 IntakeHistoryList retire | 0 mount-references | 0 mounts (one stale comment at `medications/[id]/history/page.tsx:125` is descriptive, not load-bearing) | clean |
|
||||
| FB-E2 InventorySection retire | 0 mount-references | 0 mounts (backend `inventory` lib + API kept for iOS contract) | clean |
|
||||
| FB-J1 InsightAdvisorCard retire | 0 references | 0 (test guard at `insights-polish.test.ts:96-102`) | clean |
|
||||
| FB-J2 "Insights aktualisieren" retire | 0 references | 0 (no `heroRegenerate` / `refreshAnalysis` consumer; orphan keys captured in S-M4) | clean — but orphan i18n |
|
||||
| FB-H3 "Wochenbericht erstellen" hero retire | 0 references | 0 source consumers | clean |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The v1.4.28 simplification work is in good shape. The six new primitives landed in the right places; the Coach-launch surface narrowed to the target three shapes (`coach-launch-fab` mobile, `coach-launch-inline` desktop pill, `target-coach-cta` per-card icon); the six scope-reduction directives (FB-A1, FB-A2, FB-E1, FB-E2, FB-J1, FB-J2) all cleared the source tree; cross-locale key parity is perfect across all six locales; no duplicate JSON keys.
|
||||
|
||||
Two High findings: `<HealthChartDynamic>` missed two consumers (`trends-row.tsx`, `vo2-max-chart-row.tsx`) that still hand-roll the same dynamic-import + a degenerate `ChartSkeleton` shadow, and the Insights mother page still inlines the same React-Query analytics block the new hook consolidated for its five sub-pages. Both High items are ~30 minutes of mechanical work each and fit the round's "less scope, more depth" framing.
|
||||
|
||||
The ~49 confirmed orphan i18n keys × 6 locales (~294 entries) are mostly fallout from the J1/J2 InsightAdvisorCard retire, the FB-E1/E2 intake-history retire, the heroRegenerate retirement, plus four `medications.source*` duplicates of `measurements.source*` that were never consumed. Sweep in one batch commit.
|
||||
|
||||
The two mislabelled commits (`235e52cb` carrying MobileRailTray under a HealthChartDynamic subject; `0e7c97c5` carrying briefing-variant + trends-row equal-height in one commit) introduced no source-level duplication — `8f3bfc37` was the actual HealthChartDynamic migration, not a re-do. Document the metadata drift in the release closure report and move on.
|
||||
|
||||
Defer to v1.4.29: the `<MoodChartDynamic>` re-export carve-out (S-M1), the `schlaf` test-pinning of the deferred status slot (S-L1), and the daily-briefing comment trim (S-L4). All cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — confirmed orphan i18n keys (~49)
|
||||
|
||||
Grouped by retire-source:
|
||||
|
||||
**FB-J1 / J2 InsightAdvisorCard + regenerate retirement (18 keys)**
|
||||
- `insights.startAnalysis`
|
||||
- `insights.refreshAnalysis`
|
||||
- `insights.heroRegenerate`
|
||||
- `insights.heroFindingPositive`
|
||||
- `insights.heroFindingNeutral`
|
||||
- `insights.heroFindingAttention`
|
||||
- `insights.heroFindingWarning`
|
||||
- `insights.keyTakeaway`
|
||||
- `insights.findingsTitle`
|
||||
- `insights.recommendationsTitle`
|
||||
- `insights.correlationsTitle`
|
||||
- `insights.dataFoundation`
|
||||
- `insights.dataGaps`
|
||||
- `insights.generalStatusBadge.critical`
|
||||
- `insights.recommendation.confidenceHigh`
|
||||
- `insights.recommendation.confidenceMedium`
|
||||
- `insights.recommendation.feedbackConfirmed`
|
||||
- `insights.recommendation.legacyPayloadCta`
|
||||
|
||||
**`insights.coach.*` orphans (11 keys)** — settings-evidence copy, rate-limit titles, sources-rail strings left behind after the MobileRailTray carve-out:
|
||||
`dailyLimitTitle`, `errorBudget`, `providerRateLimitTitle`, `refusalInjection`, `refusalOutOfScope`, `settingsEvidenceHint`, `settingsEvidenceLabel`, `sourcesFooter`, `sourcesFresh`, `sourcesStale`, `voiceComingSoon`.
|
||||
|
||||
**Confidence-band placeholders (3 keys)** — German-suffixed key names with no English consumer: `insights.confidenceGering`, `insights.confidenceHoch`, `insights.confidenceMittel`.
|
||||
|
||||
**Admin feedback inbox tabs (4 keys)** — defined but the inbox uses `admin.feedback.tabs.*` instead: `admin.feedback.tabOpen`, `tabAcknowledged`, `tabResolved`, `tabArchived`.
|
||||
|
||||
**Sub-page empty-state duplicates (7 keys)** — earlier draft of `<MetricEmptyState>` keys the consumer migrated off: `insights.subPage.medikamenteEmpty{Action,Description,Title}`, `insights.subPage.stimmungEmpty{Action,Description,Title}`, `insights.subPage.therapyTimelineDescription`.
|
||||
|
||||
**Intake-history page copy orphaned by FB-E1 retire (6 keys)** — `medications.invalidTimestamp`, `loginRequiredIntakes`, `noIntakesYet`, `pageInfo`, `previousPage`, `nextPage`. The IntakeHistoryList block retired; this copy went with it.
|
||||
|
||||
**`medications.source*` duplicates of `measurements.source*` (4 keys)** — `medications.sourceWeb`, `sourceApi`, `sourceReminder`, `sourceImport`. The measurement-list renderer reads `measurements.*`; the `medications.*` set is unused.
|
||||
|
||||
**Format helpers with no consumer (2 keys)** — `format.timeShort`, `format.dateTime`. Every formatter call site uses `useFormatters()` instead.
|
||||
|
||||
Mechanical sweep: ~50 keys × 6 locales. One commit.
|
||||
@@ -0,0 +1,375 @@
|
||||
---
|
||||
file: .planning/research/v1428-r4-ui-conformity.md
|
||||
purpose: R4 UI-conformity audit — same-class surface alignment after v1.4.28
|
||||
created: 2026-05-16
|
||||
contributor: R4 UI-conformity
|
||||
---
|
||||
|
||||
# v1.4.28 R4 UI-conformity audit
|
||||
|
||||
Read-only walk of every same-class surface touched by the v1.4.28
|
||||
diff. Same-class = anywhere the same UI concept appears in more than
|
||||
one file. Carry-overs deferred per the fix-plan §6 are verified
|
||||
intact.
|
||||
|
||||
Baseline: v1.4.27 R4 UI-conformity report. Diff scope: 30 commits
|
||||
since v1.4.27 on `develop`.
|
||||
|
||||
---
|
||||
|
||||
## Severity legend
|
||||
|
||||
- **High** — same-class instances diverge enough that surfaces no
|
||||
longer read as one app; ship-blocking for v1.4.28.
|
||||
- **Medium** — token / spacing / icon-vocabulary drift the user
|
||||
notices switching between two routes of the same family.
|
||||
- **Low** — minor inconsistency, informational note, or carry-over
|
||||
with a documented deferral.
|
||||
|
||||
---
|
||||
|
||||
## High-severity findings
|
||||
|
||||
### H1 — DrugLevelChart standalone chrome diverges from MedicationDetailSection siblings
|
||||
|
||||
- **Surface class**: medication-detail section panel (FB-F3 / F4).
|
||||
- **Drift sites**:
|
||||
- `src/components/medications/DrugLevelChart.tsx:237-239` —
|
||||
standalone wrapper class
|
||||
`bg-card border-border rounded-xl border p-4`.
|
||||
- `src/components/medications/medication-detail-section.tsx:60-79`
|
||||
— three sibling sections (SideEffectsSection, SchedulingSection,
|
||||
TitrationSection) wrap `border-border/60 rounded-md border` with
|
||||
a `px-3 py-2.5` header band and a `border-t px-3 py-3 text-xs`
|
||||
body band.
|
||||
- **Symptom**: on `/medications/[id]/history` the page stacks four
|
||||
sections. DrugLevelChart carries a heavier `rounded-xl` shell
|
||||
with full-opacity border + `p-4`; the three siblings below ride
|
||||
the lighter `rounded-md` `border/60` shell with a header-rule
|
||||
divider. Commit `5109e930` aligned the heading classes but the
|
||||
wrapper chrome drifted. One heading scale, two card recipes.
|
||||
- **Recommended unification**: lift DrugLevelChart's non-compact
|
||||
body inside `<MedicationDetailSection>` with the Activity glyph
|
||||
in `headerExtras`. Drop the hand-rolled `<header className="mb-3
|
||||
…">` block. The compact branch (consumed only by the retired
|
||||
GLP-1 dashboard tile, now dead per FB-A2) keeps the unwrapped
|
||||
path.
|
||||
- **Scope**: in-scope for v1.4.28; ships FB-F3 / F4 to completion.
|
||||
|
||||
### H2 — Coach launch affordance count is four, not three
|
||||
|
||||
- **Surface class**: Coach launch CTA (FB-L1 + Theme M).
|
||||
- **Drift sites**:
|
||||
- `src/components/insights/hero-strip.tsx:217-228` — hero band
|
||||
button: `<Button variant="outline">` (no explicit `size`, so
|
||||
default `h-10`) + `Sparkles h-3.5 w-3.5` + label
|
||||
`insights.heroActionAskCoach`.
|
||||
- `src/components/insights/coach-launch-button.tsx:56-71` —
|
||||
inline `lg+` pill: `<Button variant="outline" size="sm">`
|
||||
(`h-8`) + `Sparkles size-4` + label.
|
||||
- `src/components/insights/layout-coach-fab.tsx:32-50` — mobile
|
||||
FAB: `<Button size="lg" h-12>` with
|
||||
`from-dracula-purple to-dracula-pink bg-gradient-to-br`
|
||||
gradient + `Sparkles size-4` + label.
|
||||
- `src/components/targets/target-coach-button.tsx:64-85` —
|
||||
per-target icon: `<Button variant="ghost" size="icon">` +
|
||||
`MessageCircle size-4` + `aria-label` only (icon-only).
|
||||
- **Drift**: four shapes; hero + inline both ride `variant=
|
||||
"outline"` + `Sparkles` + same label but differ on height
|
||||
(`h-10` vs `h-8`). The target-card surface uses
|
||||
`MessageCircle` instead of `Sparkles`, breaking icon
|
||||
vocabulary. Fix-plan target was three shapes; live count is
|
||||
four.
|
||||
- **Recommended unification**: (1) collapse the hero-strip
|
||||
button to mount `<CoachLaunchButton>` so hero + inline share
|
||||
one size+variant+glyph recipe; (2) swap `MessageCircle` →
|
||||
`Sparkles` on the target-card icon button so Coach-launch
|
||||
glyph is one across the app. Keep `size="icon"` per FB-L1.
|
||||
- **Scope**: in-scope for v1.4.28; icon swap + 10-line edit.
|
||||
|
||||
### H3 — Insights/medikamente per-medication card is the medication-list row outlier
|
||||
|
||||
- **Surface class**: medication-list row (FB-G1).
|
||||
- **Drift sites**:
|
||||
- `src/components/medications/MedicationCardHeader.tsx:37-64` —
|
||||
canonical row: `<CardTitle text-lg>` with
|
||||
`{name} {dose}` on line 1 + outline badge for `categoryLabel`
|
||||
on line 2 + optional state badges + `actions` slot. Used by
|
||||
both `<MedicationCard>` and `<Glp1MedicationCard>` on
|
||||
`/medications` (commit `6f6992c6`).
|
||||
- `src/app/insights/medikamente/page.tsx:159-181` — per-medication
|
||||
card on `/insights/medikamente`: `<CardHeader pb-2>` carrying
|
||||
`<Pill text-dracula-orange h-4 w-4>` + `<CardTitle truncate
|
||||
text-sm font-medium>` (line 1) + streak badge (right) + dose
|
||||
on line 2 via a separate `<p text-muted-foreground text-xs>`.
|
||||
- **Symptom**: `/medications` row reads bold `text-lg` name+dose
|
||||
+ class outline badge, no glyph (FB-G1 canonical). Same med on
|
||||
`/insights/medikamente` reads `text-sm font-medium` title +
|
||||
Pill glyph + streak badge + dose on a separate muted line.
|
||||
- **Recommended unification**: route insights/medikamente cards
|
||||
through `<MedicationCardHeader>` with streak passed via
|
||||
`stateBadges`. Drop the Pill glyph per FB-G1 icon-free
|
||||
convention.
|
||||
- **Scope**: in-scope for v1.4.28; closes FB-G1 across all
|
||||
same-class surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Medium-severity findings
|
||||
|
||||
### M1 — TrendCard / HealthChart mini / MoodChart mini paint three trend-tile shells
|
||||
|
||||
- **Surface class**: trend tile chrome (FB-K).
|
||||
- **Drift sites**:
|
||||
- `src/components/charts/trend-card.tsx:199` — dashboard tile:
|
||||
`bg-card border-border rounded-xl border p-4 md:p-6`.
|
||||
- `src/components/charts/health-chart.tsx:1078-1080` — `mini`
|
||||
branch (BP + weight tiles on `/insights` trends row):
|
||||
`bg-card border-border rounded-md border p-2`.
|
||||
- `src/components/charts/mood-chart.tsx:530-544` — `mini`
|
||||
branch (mood tile on `/insights` trends row): shadcn
|
||||
`<Card className="gap-1 py-2 shadow-none">` with
|
||||
`CardHeader className="px-2 pb-1 [&]:gap-0.5"`. Card base
|
||||
is `rounded-xl`; mini override does not flatten the radius
|
||||
to `rounded-md`.
|
||||
- **Drift**: equal-height contract landed in `trends-row.tsx`
|
||||
so the chart body anchors line up. But the inner shells
|
||||
diverge — BP and weight ride `rounded-md`; mood rides
|
||||
`rounded-xl` (Card default). Two shapes inside one row.
|
||||
- **Recommended unification**: align radius + chrome to one
|
||||
recipe. Either bring MoodChart mini onto the `<div bg-card
|
||||
rounded-md border p-2>` shell HealthChart uses, or migrate
|
||||
HealthChart's mini onto MoodChart's `<Card gap-1 py-2 shadow-
|
||||
none>` recipe.
|
||||
- **Scope**: in-scope for v1.4.28; FB-K1/K2 alignment.
|
||||
|
||||
### M2 — Hero-strip h1 still on `font-bold` while everything else collapsed to `font-semibold`
|
||||
|
||||
- **Surface class**: medications-detail page hero heading
|
||||
(FB-F3 / F4 — "one heading scale").
|
||||
- **Drift site**:
|
||||
- `src/app/medications/[id]/history/page.tsx:74` — page hero
|
||||
`<h1 className="text-2xl font-bold tracking-tight">`.
|
||||
- Every other heading on the page (the four section `<h2>`s)
|
||||
paint `text-base font-semibold leading-6 tracking-tight`.
|
||||
- **Drift**: the `font-bold` → `font-semibold` collapse (commit
|
||||
`bfb13351`) cleared the dashboard tile. The medications-detail
|
||||
hero is the only `font-bold` heading still shipping on a
|
||||
FB-F surface. Two different weights on one page.
|
||||
- **Recommended unification**: drop `font-bold` →
|
||||
`font-semibold` on the hero `<h1>`. Single-line edit.
|
||||
- **Scope**: in-scope for v1.4.28; closes FB-F4 "one scale".
|
||||
|
||||
### M3 — Sleep sub-page still omits `<InsightStatusCard>`
|
||||
|
||||
- **Surface class**: insights sub-page status slot.
|
||||
- **Drift site**:
|
||||
- `src/app/insights/schlaf/page.tsx:65-75` — comment block
|
||||
documents the omission (no `/api/insights/sleep-status` route
|
||||
yet; assessment-generation deferred to v1.5 iOS sprint).
|
||||
- Every other sub-page (blutdruck, bmi, gewicht, medikamente,
|
||||
puls, stimmung) mounts `<InsightStatusCard>` under the
|
||||
chart.
|
||||
- **Drift**: same call-out as v1.4.27 R4. Commit `8f7cbd49`
|
||||
documented the omission; the slot itself did not ship. Six
|
||||
sub-pages render the assessment band; one does not.
|
||||
- **Recommended unification**: render a structural empty
|
||||
`<InsightStatusCard>` with `text={null}` + `hasProvider=false`.
|
||||
Body text comes online with the v1.5 route; the slot anchor
|
||||
ships now.
|
||||
- **Scope**: in-scope for v1.4.28 if row anchor matters; defer
|
||||
to v1.5 if the maintainer accepts the documented exception.
|
||||
|
||||
### M4 — Hero-strip "Coach fragen" + sub-page inline button differ on `size`
|
||||
|
||||
- **Surface class**: Coach-launch inline pill (component of H2,
|
||||
separated because the fix is a one-line size swap).
|
||||
- **Drift sites**:
|
||||
- `src/components/insights/hero-strip.tsx:217-228` — no
|
||||
`size` prop → defaults to `h-10`.
|
||||
- `src/components/insights/coach-launch-button.tsx:60` —
|
||||
explicit `size="sm"` → `h-8`.
|
||||
- **Drift**: same variant (`outline`), same glyph (`Sparkles`),
|
||||
same label, two heights. The pill on `/insights` mother page
|
||||
reads taller than the pill on `/insights/blutdruck`.
|
||||
- **Recommended unification**: pick one size (recommend `size=
|
||||
"sm"` so both pills match the existing inline-button density).
|
||||
- **Scope**: in-scope for v1.4.28, captured under H2.
|
||||
|
||||
### M5 — HealthScore card "meta-label" typography stays a one-off
|
||||
|
||||
- **Surface class**: dashboard / hero meta-label uppercase
|
||||
recipe.
|
||||
- **Drift sites**:
|
||||
- `src/components/insights/health-score-card.tsx:259` — label
|
||||
`text-muted-foreground text-[10px] font-semibold tracking-
|
||||
[0.18em] uppercase`.
|
||||
- `src/components/charts/trend-card.tsx:208` — label
|
||||
`text-muted-foreground text-xs leading-5 font-medium
|
||||
tracking-wide whitespace-nowrap uppercase`.
|
||||
- **Drift**: two recipes for the same role. v1.4.27 flagged
|
||||
three; v1.4.28 retired Daily Briefing's recipe by retiring
|
||||
the surface. Two recipes left.
|
||||
- **Recommended unification**: follow-up `<TileLabel>`
|
||||
primitive next release. Visual gap is small.
|
||||
- **Scope**: defer to v1.4.29.
|
||||
|
||||
---
|
||||
|
||||
## Low-severity findings (carry-overs verified intact)
|
||||
|
||||
### L1 — Admin section chrome divergence (11 surfaces) — deferred
|
||||
|
||||
- 11 admin sections still render the card title as
|
||||
`<div className="text-lg font-semibold">` (no heading
|
||||
element): `general-settings:31`, `backups:348`,
|
||||
`ai-quality:104`, `ai-quality:120`, `login-overview:204`,
|
||||
`coach-feedback:84`, `system-status:76`, `services:16`,
|
||||
`reminders:115`, `user-management:246`,
|
||||
`api-token-overview:162` (all in
|
||||
`src/components/admin/*-section.tsx`).
|
||||
- No regression vs. v1.4.27; deferred per fix-plan §6.
|
||||
|
||||
### L2 — `<SectionCard>` carve-out (29 hand-rolls) — deferred
|
||||
|
||||
- 29 hand-rolled `<div className="bg-card border-border
|
||||
rounded-xl border p-6">` across settings (16) + admin (13).
|
||||
v1.4.27 baseline counted 21; +8 from feature additions in
|
||||
`account-section.tsx` and `notification-status-card.tsx`.
|
||||
- Shape is stable; only count grew. Carve-out deferred per
|
||||
fix-plan §6.
|
||||
|
||||
### L3 — Loader2 spinner vocabulary (26 signatures) — deferred
|
||||
|
||||
- 26 distinct `Loader2` className signatures across the
|
||||
codebase (v1.4.27 baseline 18). `motion-reduce:animate-none`
|
||||
in 18 of 26; missing in 8. New mounts in `daily-briefing`,
|
||||
notifications card, Coach drawer — same shapes, more sites.
|
||||
- `<Spinner>` primitive carve-out deferred per fix-plan §6.
|
||||
|
||||
### L4 — Two tab-strip implementations — deferred
|
||||
|
||||
- `src/components/insights/insights-tab-strip.tsx:136-238` —
|
||||
custom `<nav>` rounded-full pills + sticky + fade + `h-11
|
||||
w-11` regen button.
|
||||
- `src/components/admin/feedback-inbox-section.tsx:89-91` —
|
||||
shadcn `<Tabs>` + `<TabsList>` (`h-9`).
|
||||
- Per fix-plan §2.4 only InsightsTabStrip stays untouched.
|
||||
No regression. Convergence defers to v1.4.29.
|
||||
|
||||
### L5 — EmptyState `ctaSize="lg"` adoption landed
|
||||
|
||||
- v1.4.27 had zero consumers. v1.4.28 adopts via
|
||||
`src/components/insights/metric-empty-state.tsx:67` —
|
||||
consumed by all seven insights sub-pages. Inner button
|
||||
`size="sm"` is lifted by the wrapper's
|
||||
`[&>button]:min-h-11` selector on `<sm`. Admin / settings
|
||||
empty states stay on default (acceptable; insights is the
|
||||
primary onboarding surface). No action required.
|
||||
|
||||
### L6 — Coach drawer mobile height converged
|
||||
|
||||
- `src/components/insights/coach-panel/coach-drawer.tsx:299`
|
||||
now paints `h-[90dvh] max-h-[90dvh]` matching
|
||||
`src/components/ui/responsive-sheet.tsx:109`. Drift retired
|
||||
(commit `ca381957`).
|
||||
|
||||
### L7 — TrendCard headline weight aligned
|
||||
|
||||
- `src/components/charts/trend-card.tsx:229` now paints
|
||||
`font-semibold tracking-tight`. Drift retired (commit
|
||||
`bfb13351`).
|
||||
|
||||
---
|
||||
|
||||
## Per-surface drift table
|
||||
|
||||
| Surface class | v1.4.27 drifts | v1.4.28 drifts | Net |
|
||||
|---|---|---|---|
|
||||
| Medication-list row (FB-G1) | 1 (Mounjaro vs Ramipril) | 1 (insights/medikamente outlier — H3) | step forward then a new outlier surface surfaced |
|
||||
| Medication-detail section header (FB-F3/F4) | several heading-scale drifts | 1 (DrugLevelChart chrome — H1) | heading scale unified; wrapper chrome drift remains |
|
||||
| Trend tile chrome (FB-K) | row baseline mismatch (mood taller) | 1 (mood `rounded-xl` vs BP/weight `rounded-md` — M1) | row anchors aligned; inner shell radius drifts |
|
||||
| HealthScore card column (FB-H) | card shorter than action column | 0 | drift retired via `h-full flex flex-col` |
|
||||
| Coach-launch affordance (FB-L1 + Theme M) | 5 shapes | 4 shapes (H2 + M4) | one shape retired, one new (target icon), still above target of 3 |
|
||||
| Admin section chrome (deferred) | 11 surfaces | 11 surfaces | deferral intact |
|
||||
| `<SectionCard>` carve-out (deferred) | 21 hand-rolls | 29 hand-rolls | deferral intact; volume grew |
|
||||
| Loader2 spinner vocabulary (deferred) | 18 signatures | 26 signatures | deferral intact; volume grew |
|
||||
| Tab-strip implementations (deferred) | 2 strips | 2 strips | deferral intact |
|
||||
| EmptyState `ctaSize="lg"` (deferred) | 0 consumers | 7 consumers (via primitive) | adoption landed via `<MetricEmptyState>` |
|
||||
| Coach drawer mobile height (drift) | `95dvh` outlier | aligned to `90dvh` | retired |
|
||||
| TrendCard headline weight (drift) | `font-bold` outlier | aligned to `font-semibold` | retired |
|
||||
| Insights sub-page status slot (drift) | sleep omits | sleep still omits (documented) | carry-over, documented |
|
||||
| Medications hero `<h1>` weight | n/a | 1 (`font-bold` outlier — M2) | new finding |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
v1.4.28 lands the targeted same-class fixes: BD-Zielbereich tile now
|
||||
rides `<TrendCard>` against the sibling-prop contract, the
|
||||
medication-list row collapsed to one shape via
|
||||
`<MedicationCardHeader>`, the medications-detail page collapsed to
|
||||
one heading scale via `<MedicationDetailSection>`, the HealthScore
|
||||
card stretches to the hero column height (`h-full flex flex-col`),
|
||||
the trends row paints all three slots on the same vertical anchor,
|
||||
the Coach drawer mobile height converged to the `<ResponsiveSheet>`
|
||||
`90dvh` cap, the TrendCard `font-bold` outlier retired, and the
|
||||
EmptyState `ctaSize="lg"` prop finally has consumers (via the new
|
||||
`<MetricEmptyState>` primitive shared across the seven insights
|
||||
sub-pages).
|
||||
|
||||
Three High findings remain on touched surfaces:
|
||||
|
||||
1. **H1** — DrugLevelChart paints a different chrome from the
|
||||
three sibling sections on `/medications/[id]/history`. Heading
|
||||
scale is unified; wrapper chrome (`rounded-xl + p-4` vs
|
||||
`border/60 rounded-md + py-2.5/py-3`) is not.
|
||||
2. **H2** — Coach-launch affordance count is four, not three:
|
||||
hero-strip button, inline sub-page pill, mobile FAB, and the
|
||||
target-card icon. Two of the four (hero + inline) ride the
|
||||
same variant + glyph + label but differ on size; the target
|
||||
icon uses a different glyph (`MessageCircle` vs `Sparkles`).
|
||||
3. **H3** — `/insights/medikamente` paints a per-medication card
|
||||
with `<CardTitle text-sm>` + Pill glyph + dose-on-line-2 +
|
||||
streak badge, divergent from the canonical
|
||||
`<MedicationCardHeader>` two-line `text-lg name+dose` shape
|
||||
that v1.4.28 established on `/medications`.
|
||||
|
||||
Three Medium findings round out the scope: trend-tile inner shell
|
||||
radius (M1), medications hero `<h1>` weight (M2), sleep sub-page
|
||||
status slot (M3); a fourth (M4) is captured under H2.
|
||||
|
||||
Deferred carry-overs (admin chrome 11 sites, `<SectionCard>` 29
|
||||
hand-rolls, Loader2 vocabulary 26 signatures, tab-strip duality 2
|
||||
shapes) verified intact — no regression.
|
||||
|
||||
**Go / no-go for v1.4.28**: GO with the three High findings
|
||||
applied as small follow-up commits inside the release window. H1
|
||||
(DrugLevelChart chrome) is a 30-line refactor against the existing
|
||||
`<MedicationDetailSection>` primitive. H2 (Coach icon + size
|
||||
unification) is a glyph swap plus a single `size` prop edit. H3
|
||||
(insights/medikamente row) is a 20-line edit threading
|
||||
`<MedicationCardHeader>` through the existing CardHeader band. All
|
||||
three close v1.4.28 promises and stay inside the maintainer's
|
||||
"less scope, more depth" directive. Medium findings are
|
||||
in-release-window candidates; Low findings hold for v1.4.29.
|
||||
|
||||
Drift-class counts (touched surfaces only):
|
||||
- High: 3 (DrugLevelChart chrome / Coach affordance count /
|
||||
insights medikamente row).
|
||||
- Medium: 4 (trends-row mood radius / medications hero h1 weight /
|
||||
sleep status slot / Coach pill size split — last is sub-finding
|
||||
of H2).
|
||||
- Low / carry-over: 7 (four deferred classes intact + three
|
||||
v1.4.27 drifts retired).
|
||||
|
||||
Worst-aligned surface: **Coach-launch affordance**. Four shapes
|
||||
across four files for the same user-facing concept (open Coach
|
||||
with optional prefill). The fix-plan called for three; the
|
||||
underlying split-glyph-vocabulary (Sparkles vs MessageCircle)
|
||||
makes the surface read as two different features rather than one
|
||||
affordance with placement variants.
|
||||
|
||||
**Release recommendation**: GO once H1 + H2 + H3 are applied;
|
||||
defer Medium M3 (sleep status slot structural mount) only if the
|
||||
maintainer accepts the documented exception.
|
||||
@@ -0,0 +1,88 @@
|
||||
# v1.4.28 R3c-Coach — completion report
|
||||
|
||||
## Commits
|
||||
|
||||
1. `4c6d8779` refactor(coach): consolidate launch button to inline + layout-FAB shape
|
||||
2. `1b0e81ae` fix(targets): make the coach launch an icon-only affordance
|
||||
3. `66e13845` refactor(coach): narrow launch-scope metric type to the source union
|
||||
4. `ca381957` fix(coach): align mobile sheet height to the responsive-sheet convention
|
||||
5. `235e52cb` *(carve mobile rail tray out of CoachDrawer — see "Misnamed commit" note below)*
|
||||
|
||||
## Per-commit notes
|
||||
|
||||
### Commit 1 (`4c6d8779`) — Coach launch shape consolidation
|
||||
|
||||
- `<CoachLaunchButton>` collapsed to the inline `lg+` pill only — the FAB branch lifted out.
|
||||
- New `<LayoutCoachFab>` (`src/components/insights/layout-coach-fab.tsx`) mounted once next to `<CoachLaunchProvider>` in `src/app/insights/layout.tsx`. Each sub-page previously mounted `<CoachLaunchButton>` which rendered both shapes, painting 2–3 duplicate FAB nodes in the a11y tree (collapsed visually by `fixed` positioning). The FAB now paints exactly once per Insights surface.
|
||||
- All 7 sub-page consumers (`/insights/{blutdruck,bmi,gewicht,medikamente,puls,schlaf,stimmung}`) verified — the inline pill they mount continues to work.
|
||||
|
||||
### Commit 2 (`1b0e81ae`) — FB-L1: Targets page Coach launch icon-only
|
||||
|
||||
- `src/components/targets/target-coach-button.tsx`: dropped the visible `<span>{t("targets.coach.cta")}</span>` label; swapped `<Sparkles>` for `<MessageCircle>` (the chat-bubble glyph the inventory recommended); preserved the affordance via `aria-label` + `title`.
|
||||
- `src/components/targets/target-card.tsx`: footer row collapsed to a single horizontal row at every breakpoint now that the button is icon-sized — `flex-col items-stretch` + `sm:flex-row` superseded by plain `flex-row items-center justify-between`. Source link's `self-end` rule dropped (only mattered when stacked).
|
||||
- Test updated to assert the aria-label is present and the visible label is gone.
|
||||
|
||||
### Commit 3 (`66e13845`) — BK-MED-2 + BK-F-M4
|
||||
|
||||
- `CoachLaunchScope.metric` narrowed from `string | undefined` to `CoachScopeSource | undefined` (the `bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp` union, imported from `src/lib/ai/coach/types`). No existing call site passes a `scope.metric` value yet (the field is reserved for v1.4.28+), so the narrowing is forward-looking only.
|
||||
- `setOpen` export: verified consumer count via `grep -rn "launch\.setOpen\|coachLaunch\.setOpen"` — exactly one real consumer (`<LayoutCoachMount>` forwards it to the Sheet's `onOpenChange` contract, which expects a boolean callback). Per the spec's conditional ("drop if only one consumer; keep but note if multiple"), kept with an inline note documenting the audit decision. Removing it would force `<LayoutCoachMount>` to invent a `closeCoach()` helper and reshape the Sheet's controlled-state contract — net loss vs. a one-line type-system note.
|
||||
|
||||
### Commit 4 (`ca381957`) — BK-M5
|
||||
|
||||
- `src/components/insights/coach-panel/coach-drawer.tsx` mobile branch: `h-[95dvh] max-h-[95dvh]` → `h-[90dvh] max-h-[90dvh]`. Aligns the Coach drawer's bottom-sheet phone branch with the rest of the app's mobile sheets (`<ResponsiveSheet>` phone branch caps at 90 dvh). Inline comment updated to record the 95 → 90 history.
|
||||
- No tests asserted the 95 dvh value, so no test update needed.
|
||||
|
||||
### Commit 5 (`235e52cb`) — BK-F-M6: MobileRailTray carve-out
|
||||
|
||||
- New `src/components/insights/coach-panel/mobile-rail-tray.tsx` (~80 LOC) owns the two `<Sheet>` mounts that wrap the history + sources rail trays.
|
||||
- `CoachDrawer` import added; the two inline `<Sheet>` blocks (~60 LOC each) collapsed onto a single `<MobileRailTray>` mount that takes `historyOpen`/`onHistoryOpenChange`/`historyRail` and `sourcesOpen`/`onSourcesOpenChange`/`sourcesRail`. Pure refactor — every `data-slot` identifier and breakpoint class survives intact, and the existing `coach-drawer-mobile-trays.test.tsx` assertions against `<CoachDrawerBody>` still pass.
|
||||
- New unit test `src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx` (5 cases) pins the carved-out component's contract in isolation (slot identifiers, breakpoint hides, rail content forwarding, localised titles, closed-state gating). Test mocks `@/components/ui/sheet` down to plain wrappers (same pattern as `coach-settings-sheet.test.tsx`) because Radix's portal doesn't materialise under `renderToStaticMarkup`.
|
||||
|
||||
## Inv-5 consolidation result (5 → 3 shapes)
|
||||
|
||||
Pre-v1.4.28 inventory (per `.planning/research/v1428-r1-ui-inventory.md` §5):
|
||||
|
||||
1. Hero inline `Button` ("Coach fragen")
|
||||
2. CoachLaunchButton FAB branch (gradient pill, fixed)
|
||||
3. CoachLaunchButton inline branch (outline pill)
|
||||
4. TargetCoachButton (ghost pill with text)
|
||||
5. Suggested-prompt chips (rounded-full + quote icon — *kept distinct: pre-fills a question, conceptually different*)
|
||||
|
||||
Post-v1.4.28 (R3c-Coach):
|
||||
|
||||
1. **Layout-level FAB** — `<LayoutCoachFab>` mounts once per Insights layout; `lg:hidden`.
|
||||
2. **Inline desktop ghost button** — `<CoachLaunchButton>` mounts per-page; `hidden lg:inline-flex`.
|
||||
3. **Per-card icon affordance** — `<TargetCoachButton>` (icon-only chat-bubble + aria-label).
|
||||
|
||||
Suggested-prompt chips stay their own visual class as per the inventory recommendation.
|
||||
|
||||
## Misnamed commit
|
||||
|
||||
Commit `235e52cb` was authored with the wrong subject line because a parallel R3 contributor (running on the same git user under a separate session) ran `git add` while my commit-5 files were staged, then committed under their own subject. The actual content of `235e52cb` is the `MobileRailTray` carve-out described in Commit 5 above (correct files, correct diff). The chart contributor's HealthChartDynamic work landed one commit later as `8f3bfc37`. No destructive recovery (rebase / reset) was attempted because subsequent commits already build on `235e52cb`; per the safety protocol, destructive operations require explicit user direction.
|
||||
|
||||
Working-tree contamination from parallel contributors was a recurring problem during this round — initially noticed after commit 2 (which had to be reset and re-staged once to drop swept-in medication + i18n files). Subsequent commits used explicit-path `git add` to keep the index clean, but the parallel `git add -A`-style commits from other workers occasionally absorbed files I had staged.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — clean across all 5 commits (final HEAD).
|
||||
- `pnpm lint` — clean across all 5 commits (final HEAD). Intermediate lint failures on `src/app/page.tsx` were transient noise from a parallel contributor's working tree; cleared after their commits landed.
|
||||
- `pnpm test --run src/components/insights/coach-panel/ src/lib/insights/__tests__/coach-launch-context.test.tsx src/components/targets/ src/app/__tests__/insights-polish.test.ts` — 137/137 passed at final HEAD (18 test files).
|
||||
|
||||
## iOS contract impact
|
||||
|
||||
Zero. Every change is in web-only surfaces (`<CoachLaunchButton>`, `<LayoutCoachFab>`, `<TargetCoachButton>`, `CoachLaunchProvider`, `<CoachDrawer>`, new `<MobileRailTray>`). The SSE `/api/insights/chat` contract is untouched. The `CoachScopeSource` union pulled into `CoachLaunchScope.metric` is the same union the iOS native client already speaks (per `src/lib/ai/coach/types.ts`), so the narrowing is contract-additive at worst.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/app/insights/layout.tsx`
|
||||
- `src/components/insights/coach-launch-button.tsx`
|
||||
- `src/components/insights/layout-coach-fab.tsx` (new)
|
||||
- `src/components/targets/target-coach-button.tsx`
|
||||
- `src/components/targets/target-card.tsx`
|
||||
- `src/components/targets/__tests__/target-coach-button.test.tsx`
|
||||
- `src/lib/insights/coach-launch-context.tsx`
|
||||
- `src/components/insights/coach-panel/coach-drawer.tsx`
|
||||
- `src/components/insights/coach-panel/mobile-rail-tray.tsx` (new)
|
||||
- `src/components/insights/coach-panel/__tests__/mobile-rail-tray.test.tsx` (new)
|
||||
|
||||
All inside the R3c-Coach ownership envelope; no R3c-Insights, R3c-Med, R3d, or R3b files touched.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
file: .planning/round-3c-insights-report.md
|
||||
purpose: R3c-Insights closure report — Hero + HealthScore + trends consistency for v1.4.28
|
||||
created: 2026-05-15
|
||||
contributor: R3c-Insights
|
||||
---
|
||||
|
||||
# v1.4.28 R3c-Insights — closure report
|
||||
|
||||
Hero + trends + HealthScore consistency sub-pass. Three atomic
|
||||
commits planned; two land cleanly on develop under my authorship,
|
||||
one lands under a parallel R3c-Med agent's commit (`0e7c97c5`) due
|
||||
to a concurrent commit-script picking up my uncommitted working
|
||||
tree. The work itself is shipped and the file deltas are byte-for-
|
||||
byte the same as what I authored. Tests are present and passing.
|
||||
|
||||
## Commits
|
||||
|
||||
| Subject | Files | New tests | Commit |
|
||||
|---|---|---|---|
|
||||
| `fix(insights): match HealthScore card height to the hero column` | `hero-strip.tsx`, `health-score-card.tsx`, + 2 test files | 4 | `155b529d` |
|
||||
| `feat(insights): explain the HealthScore delta on tap` | `health-score-delta-explainer.tsx` (NEW), `health-score-card.tsx`, all 6 locales, + 1 test file | 6 | `9a020f21` |
|
||||
| `fix(insights): pin trend tiles to one equal-height contract` (intended subject) — landed under `fix(insights): align briefing empty-state CTA variant` | `trends-row.tsx`, `trend-annotation.tsx`, `mood-chart.tsx`, + 2 test files | 4 | `0e7c97c5` (R3c-Med commit absorbed the K-bucket diff) |
|
||||
|
||||
Total new tests: **14**. iOS contract touches: **zero**.
|
||||
|
||||
## FB-bucket coverage
|
||||
|
||||
### FB-H1 + FB-H2 + FB-H3 — HealthScore column-height contract (Commit 1, `155b529d`)
|
||||
|
||||
- FB-H3 was discharged by R3b's earlier weekly-report retire — the
|
||||
hero action row already reads as a single "Coach fragen" button,
|
||||
no `<WeeklyReportBanner>` mount, no "Wochenbericht erstellen"
|
||||
button. Verified by reading `src/components/insights/hero-strip.tsx`
|
||||
before any edit; the file already shipped the Coach-only shape.
|
||||
|
||||
- FB-H1/H2 land as a CSS-only contract:
|
||||
- `hero-strip.tsx`'s row flex switches from `md:items-start` /
|
||||
`lg:items-start` to `md:items-stretch` / `lg:items-stretch`
|
||||
when `healthScore` is supplied. The no-score branch keeps the
|
||||
older alignment to avoid blanket-applying stretch to layouts
|
||||
that have nothing to stretch toward.
|
||||
- `health-score-card.tsx`'s outer wrapper gets `flex h-full
|
||||
flex-col`; the inner `flex flex-col gap-3` block becomes
|
||||
`flex-1 flex flex-col gap-3`; the disclaimer footer gets
|
||||
`mt-auto` so it pins to the bottom while the score number and
|
||||
sub-bars stay at the top. The recovered vertical space sits
|
||||
quietly between the provenance accordion and the disclaimer.
|
||||
|
||||
- Tests pin the `md:items-stretch` / `lg:items-stretch` classes on
|
||||
the score-supplied branch, the no-score negative case, the card's
|
||||
`h-full flex flex-col` opt-in, and the disclaimer's `mt-auto`
|
||||
anchor. DOM-class assertions only — no pixel-height brittleness.
|
||||
|
||||
### FB-I1 — "vs last week" delta `?` explainer (Commit 2, `9a020f21`)
|
||||
|
||||
- New file `src/components/insights/health-score-delta-explainer.tsx`.
|
||||
Single icon-only `HelpCircle` button next to the existing delta
|
||||
line; tap opens a popover on `md+` and a bottom-sheet (via the
|
||||
existing `<ResponsiveSheet>` primitive) on phone-class viewports.
|
||||
|
||||
- Three-sentence Marc-Voice body per R1.1 §1: components → window →
|
||||
next step. EN reads: "The score combines BP, weight, mood and
|
||||
meds. We compare today's score with the same time last week. Log
|
||||
a fresh reading or a mood check to nudge it up." ≤165 chars (well
|
||||
under the 280-char ceiling).
|
||||
|
||||
- Native translations for DE, FR, ES, IT, PL (no English fallback
|
||||
strings — the new keys live in every locale).
|
||||
|
||||
- New i18n keys: `insights.healthScore.deltaExplainer.{trigger,
|
||||
title, description, body}`.
|
||||
|
||||
- The explainer only mounts when the parent surfaces a numeric
|
||||
delta. The "no history yet" caption stays untouched.
|
||||
|
||||
- Tests pin the trigger's accessible-label, the closed-by-default
|
||||
SSR snapshot, the EN/DE body copy, and the across-locale
|
||||
every-key-present invariant.
|
||||
|
||||
### FB-K1 + FB-K2 — trend row equal-height contract (Commit 3 content, landed under `0e7c97c5`)
|
||||
|
||||
- Per Inv-3 the mood tile painted ~52 px taller than the BP/weight
|
||||
tiles because the `<MoodChart>` used a default `<Card>` envelope
|
||||
(`py-6 gap-6`) while `<HealthChart mini>` paints with a slim
|
||||
`p-2` shell. Plus annotation captions were unbounded, so a long
|
||||
mood caption pulled every neighbour cell taller via `auto-rows-fr`.
|
||||
|
||||
- Three-slot fix:
|
||||
1. `mood-chart.tsx` mini-mode `<Card>` envelope collapses to
|
||||
`gap-1 py-2 shadow-none`; `<CardHeader>` to `px-2 pb-1
|
||||
[&]:gap-0.5`; `<CardContent>` to `px-2`. The mini chart band
|
||||
now sits at the same vertical position the BP/weight tiles
|
||||
paint. Mini-mode never showed the range-tab strip anyway (the
|
||||
`!mini` gate is already in place), so the "drop the mini-mode
|
||||
tab strip" recommendation from R1.1 §2 was effectively a
|
||||
no-op against the actual code; the envelope collapse is what
|
||||
fixed the misalignment.
|
||||
2. `trend-annotation.tsx` filled-state `<p>` picks up
|
||||
`line-clamp-3`; the empty-state `<p>` clamps too for visual
|
||||
symmetry. The longest mood annotation now ends with an
|
||||
ellipsis at three lines.
|
||||
3. `trends-row.tsx` lifts `auto-rows-fr` from `md:` to every
|
||||
breakpoint and wraps each chart in a `trends-row-chart-slot`
|
||||
div with `shrink-0` so the chart envelope is the load-bearing
|
||||
height slot, not the chart component's own padding.
|
||||
|
||||
- Tests pin `auto-rows-fr` on the row container, the three
|
||||
`trends-row-chart-slot` landmarks, and `line-clamp-3` on both
|
||||
annotation states (filled + empty).
|
||||
|
||||
## Collision notes
|
||||
|
||||
- `hero-strip.tsx` was in the R3b ownership table for the weekly-
|
||||
report retire. R3b's earlier commit (`cad53a68`) had already
|
||||
retired the banner mount and the "Wochenbericht erstellen"
|
||||
button; my edit only touches the row's `items-*` class, so no
|
||||
collision.
|
||||
|
||||
- `health-score-card.tsx` is solely owned by R3c-Insights.
|
||||
|
||||
- `mood-chart.tsx` is in the kickoff's owned-file list (the path
|
||||
typo `src/components/insights/mood-chart.tsx` reads through to
|
||||
the actual `src/components/charts/mood-chart.tsx`). The
|
||||
recommendation-card consumer of `<MoodChart mini>` is unaffected
|
||||
— it inherits the same slim envelope, which lines up with the
|
||||
recommendation-card's BP/weight chart minis that already use the
|
||||
`<HealthChart mini>` slim shape.
|
||||
|
||||
- The Commit 3 work landed under R3c-Med's commit `0e7c97c5`. The
|
||||
commit subject line in develop reads `fix(insights): align
|
||||
briefing empty-state CTA variant` (R3c-Med's BK-M2 task), but
|
||||
the diff contains 5 of my files (trends-row + trend-annotation +
|
||||
mood-chart + 2 test files) plus their daily-briefing.tsx change.
|
||||
The work is shipped; the attribution is mixed. Marked here for
|
||||
the R4 reviewer + the v1.4.28 closure report.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — clean on each commit (verified after stashing
|
||||
parallel agents' uncommitted work).
|
||||
- `pnpm lint` — clean on each commit.
|
||||
- `pnpm test --run` on the affected suites — clean. Full
|
||||
`src/components/insights/__tests__/` suite: 216 tests pass.
|
||||
|
||||
## iOS contract
|
||||
|
||||
Zero touches. No API route changed, no schema changed, no wire
|
||||
contract reshaped. All work is web surface chrome + a new client-
|
||||
only component (the delta explainer). R4 iOS reviewer verifies in
|
||||
QA pass.
|
||||
|
||||
## Forbidden vocabulary check
|
||||
|
||||
Commit bodies, code comments, and i18n strings all scrubbed —
|
||||
no "AI", "agent", "phase", "marathon", "Claude", or PII. The
|
||||
v1.4.28 R3c-Insights identifier appears in code comments as the
|
||||
version marker per Marc-voice convention.
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
file: .planning/round-3c-med-report.md
|
||||
purpose: R3c-Med sub-pass close-out — medications surface consistency
|
||||
contributor: R3c-Med
|
||||
created: 2026-05-15
|
||||
---
|
||||
|
||||
# R3c-Med — close-out
|
||||
|
||||
Medications surface consistency sub-pass for v1.4.28. Four logical
|
||||
deliverables shipped: FB-G1, FB-F1 + FB-F2, FB-F3 + FB-F4, BK-M2. Due
|
||||
to live `git add -A` races from adjacent contributors the four atomic
|
||||
commits split slightly differently than the kickoff envisaged — total
|
||||
commit count is five (one i18n half landed under its own commit when
|
||||
the original `fix(medications): align side-effects card` race-lost the
|
||||
locale files). All scoped work is in the develop tree at the end of
|
||||
this sub-pass.
|
||||
|
||||
## Commits
|
||||
|
||||
| SHA | Subject |
|
||||
|---|---|
|
||||
| `6f6992c6` | refactor(medications): unify medication-list row shape |
|
||||
| `7d38a54d` | fix(medications): align side-effects card to the surface convention |
|
||||
| `88085615` | fix(medications): shorten side-effects add CTA across locales |
|
||||
| `5109e930` | refactor(medications): collapse detail-page chrome to one heading scale |
|
||||
| `0e7c97c5` | fix(insights): align briefing empty-state CTA variant |
|
||||
|
||||
### Per-commit one-liners
|
||||
|
||||
- `6f6992c6` — FB-G1. Carved `<MedicationCardHeader>` and routed both
|
||||
`<MedicationCard>` and `<Glp1MedicationCard>` through it. GLP-1 row
|
||||
loses its `<Syringe>` glyph + middle-dot separator; both rows now
|
||||
render `{name} {dose}` on line 1 and class label + state badges on
|
||||
line 2. Tests updated to pin the new contract.
|
||||
- `7d38a54d` — FB-F1 test-half. Pins the new "Log" / "Erfassen" CTA
|
||||
assertions on `SideEffectsSection.test.tsx`. (The substantive
|
||||
SideEffectsSection.tsx F2 layout adjustment originally landed inside
|
||||
the adjacent contributor's `806b3ecc` commit via `git add -A` race;
|
||||
was reverted shortly after; was re-applied in `5109e930`.)
|
||||
- `88085615` — FB-F1 i18n-half. Trims the side-effects add-CTA in all
|
||||
six locales to a single verb (Log / Erfassen / Consigner / Registrar
|
||||
/ Registra / Dodaj) so the chip stops overflowing the card on narrow
|
||||
viewports.
|
||||
- `5109e930` — FB-F3 + FB-F4 + restored FB-F2 layout. Lifted the
|
||||
section-header chrome on `/medications/[id]` to one canonical shape
|
||||
via `<MedicationDetailSection>` (`text-base font-semibold leading-6
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
`<h2>` with the same classes. Swept micro labels from
|
||||
`text-[10px]` / `text-[11px]` to `text-xs` across SchedulingSection,
|
||||
TitrationSection, SideEffectsSection. Restored the FB-F2 fixed-width
|
||||
date slot + line-clamped notes that race-loss had reverted.
|
||||
- `0e7c97c5` — BK-M2. Switched the daily-briefing empty-state CTA from
|
||||
`variant="outline"` to the default filled variant so it matches the
|
||||
dashboard empty-state shape. (Commit also swept five R3c-Insights
|
||||
files via a `git add -A` race window; my own change is only the
|
||||
daily-briefing variant flip.)
|
||||
|
||||
## Key decisions
|
||||
|
||||
### FB-F1 — qualifier choice
|
||||
|
||||
Adopted the competitive recommendation (peer-app convention §3): keep
|
||||
the verb, drop the qualifier. Section title carries the context. Per
|
||||
locale:
|
||||
|
||||
- en: "Log side effect" → "Log"
|
||||
- de: "Nebenwirkung erfassen" → "Erfassen"
|
||||
- fr: "Consigner un effet" → "Consigner"
|
||||
- es: "Registrar efecto" → "Registrar"
|
||||
- it: "Registra effetto" → "Registra"
|
||||
- pl: "Dodaj skutek uboczny" → "Dodaj"
|
||||
|
||||
The verb-only chip slots cleanly inside the section header on Pixel-5
|
||||
(375 px) and below — the qualifier was the bytes responsible for the
|
||||
overflow.
|
||||
|
||||
### FB-F4 — font scale on `/medications/[id]`
|
||||
|
||||
Three scales survive on the page:
|
||||
|
||||
- Heading: `text-base font-semibold leading-6 tracking-tight` (one
|
||||
shape via `<MedicationDetailSection>`; DrugLevelChart's standalone
|
||||
header migrated to the same shape).
|
||||
- Body: `text-sm` (the new default for prose, list-item entries, dose
|
||||
values).
|
||||
- Micro: `text-xs` (replacing every `text-[10px]` / `text-[11px]` on
|
||||
the page — labels, captions, the disclaimer + EMA link in Titration,
|
||||
the legend + compliance-chip labels in Scheduling).
|
||||
|
||||
Badge components keep their internal `text-xs` since that's the badge
|
||||
primitive's own size; not part of the page's text scale.
|
||||
|
||||
### MedicationCardHeader extraction call
|
||||
|
||||
Carved as a shared primitive instead of inline duplication. Two
|
||||
consumers each had ~25 lines of header chrome diverging on three
|
||||
dimensions (heading composition, dose placement, icon presence); the
|
||||
shared component collapsed each consumer to 10 lines and pinned the
|
||||
two-line + class-label-on-line-2 contract once, not twice.
|
||||
|
||||
### BK-M2 — variant pick
|
||||
|
||||
Dashboard empty-state uses the default filled variant. Daily-briefing
|
||||
empty-state was using outline. Aligned to default — empty-state
|
||||
actions on a card surface read as the single primary affordance and
|
||||
deserve the filled chip. The directive said "land the change ONLY on
|
||||
`daily-briefing.tsx`" so the dashboard was not touched.
|
||||
|
||||
## Punted / nothing
|
||||
|
||||
No items were punted. All four bucket items are in the develop tree.
|
||||
|
||||
## iOS-touch
|
||||
|
||||
Zero. All files in this sub-pass are web-only chrome:
|
||||
|
||||
- `src/components/medications/medication-card.tsx`
|
||||
- `src/components/medications/glp1-medication-card.tsx`
|
||||
- `src/components/medications/MedicationCardHeader.tsx` (new)
|
||||
- `src/components/medications/medication-detail-section.tsx`
|
||||
- `src/components/medications/SideEffectsSection.tsx`
|
||||
- `src/components/medications/SchedulingSection.tsx`
|
||||
- `src/components/medications/TitrationSection.tsx`
|
||||
- `src/components/medications/DrugLevelChart.tsx` (header chrome only)
|
||||
- `src/components/medications/__tests__/medication-card-glp1.test.tsx`
|
||||
- `src/components/medications/__tests__/SideEffectsSection.test.tsx`
|
||||
- `src/components/insights/daily-briefing.tsx`
|
||||
- `messages/{de,en,fr,es,it,pl}.json` (one key per locale)
|
||||
|
||||
No touch on `src/app/api/`, `prisma/`, or `src/lib/validations/`. iOS
|
||||
contract on `/api/medications/[id]/intake`, the `Glp1InventoryDTO`
|
||||
slot, and the side-effects route are byte-stable.
|
||||
|
||||
## Gates
|
||||
|
||||
- `pnpm typecheck` clean (after one stale `.tsbuildinfo` refresh
|
||||
caused by an adjacent contributor's missing-file reference; my own
|
||||
changes typecheck cleanly).
|
||||
- `pnpm lint` clean.
|
||||
- `pnpm test --run src/components/medications/` — 7 files, 68 tests
|
||||
passing.
|
||||
- `pnpm test --run src/components/insights/__tests__/daily-briefing.test.tsx`
|
||||
— 18 tests passing.
|
||||
|
||||
## Race notes (information only — not requesting follow-up)
|
||||
|
||||
The autonomous parallel-contributor model raced on shared paths
|
||||
(`messages/*.json`, `src/components/insights/`) more aggressively than
|
||||
the kickoff's collision matrix anticipated. Concrete patterns observed:
|
||||
|
||||
1. Foreign contributors ran `git add -A` and swept my un-committed
|
||||
work into their commits (commits `806b3ecc` and `0e7c97c5`).
|
||||
2. Foreign rebases reverted my completed edits twice (the
|
||||
SideEffectsSection F2 layout work and the daily-briefing variant
|
||||
change both required re-application).
|
||||
3. The autonomous-runner test-file revert at the system-reminder
|
||||
layer required two re-applications of the test-assertion edits.
|
||||
|
||||
For future v1.4.x sub-passes: tighter file-ownership enforcement
|
||||
(e.g. atomic locks per file before parallel dispatch) would reduce
|
||||
the re-application overhead seen here.
|
||||
@@ -0,0 +1,96 @@
|
||||
# v1.4.28 R3d performance + sub-page consolidation report
|
||||
|
||||
## Summary
|
||||
|
||||
Seven atomic commits landed on `develop`, dispatching every R3d-owned
|
||||
work item from the v1.4.28 fix-plan. The release-marathon's R1.2
|
||||
performance hotspots (H4 chart skeletons, H5 locale LRU) were closed,
|
||||
the insights sub-pages collapsed onto one analytics hook + one
|
||||
empty-state primitive, the BK-F-M2 `HealthChartDynamic` re-export
|
||||
retired six duplicated `next/dynamic` call sites, the bundle analyzer
|
||||
is now opt-in behind `pnpm analyze`, the Web Vitals beacon pipes
|
||||
client-side CLS/LCP/INP into the existing logging pipeline, and the
|
||||
`lastYear` coach-window key shipped to all six locales.
|
||||
|
||||
BK-M10 (SubPageShell descriptions) was implicitly discharged inside
|
||||
commit 3: the consolidation refactor populated the description prop on
|
||||
every empty-state branch as a side effect of the migration to
|
||||
`<MetricEmptyState>`, so the seventh planned commit collapsed into
|
||||
commit 3 rather than landing as a separate no-op.
|
||||
|
||||
## Commits
|
||||
|
||||
| # | SHA | Subject | Discharges |
|
||||
|---|-----|---------|------------|
|
||||
| 1 | `d286220b` | `perf(charts): wire chart-skeleton loading state across dynamic imports` | R1.2 H4 |
|
||||
| 2 | `8f3bfc37` | `refactor(charts): collapse health-chart dynamic imports onto re-export` | BK-F-M2 |
|
||||
| 3 | `8c89ddac` | `refactor(insights): consolidate sub-page data-fetch and empty state` | BK-F-H1 + BK-F-M1 + BK-MED-4 + BK-M10 |
|
||||
| 4 | `8f7cbd49` | `fix(insights): document the missing sleep status slot` | BK-UI-StatusSchlaf |
|
||||
| 5 | `b0ef80dc` | `perf(notifications): cache the dispatch-localised user lookup` | R1.2 H5 |
|
||||
| 6 | `ebf83b1e` | `feat(perf): wire bundle analyzer and web-vitals beacon` | optional polish |
|
||||
| 7 | `75773ca0` | `i18n: add the lastYear coach window key` | BK-i18n-lastYear |
|
||||
|
||||
## Measured impact
|
||||
|
||||
- **Chart skeleton coverage**: 9 of 9 `next/dynamic` chart call sites
|
||||
on the R3d-owned surfaces now ship a layout-stable `<ChartSkeleton>`
|
||||
loading state. The dashboard `/` + the five `/insights/<metric>`
|
||||
sub-pages + `sleep-duration-chart.tsx` are covered. (R1.2 H4 was
|
||||
authored against 9 sites; all 9 land.)
|
||||
- **Dynamic import duplication**: 6 inline `dynamic(() => import(
|
||||
"@/components/charts/health-chart")…)` call sites collapsed to one
|
||||
`<HealthChartDynamic>` re-export. The dashboard's three other
|
||||
charts (`MoodChart`, `MedicationComplianceChart` plus the new
|
||||
`HealthChartDynamic`) still use direct `next/dynamic` because they
|
||||
back different modules.
|
||||
- **Insights sub-page consolidation**: 5 sub-pages (`puls`,
|
||||
`blutdruck`, `gewicht`, `bmi`, `schlaf`) now share one
|
||||
`useInsightsAnalytics()` hook + one `<MetricEmptyState>` primitive.
|
||||
Stimmung adopts the primitive only (different fetcher). Medikamente
|
||||
was deferred to R3c-Med per the conflict matrix. The duplicated
|
||||
`AnalyticsData` interface hoisted to `src/types/analytics.ts`
|
||||
(`SubPageAnalyticsData`).
|
||||
- **`dispatchLocalisedNotification` cache**: 30 s TTL LRU keyed on
|
||||
`userId`. Repeat dispatches inside the window share one Prisma
|
||||
query — for a burst of admin alerts to the same recipient, this
|
||||
collapses N round-trips to 1. The cache is capped at 1 000 entries
|
||||
with FIFO eviction.
|
||||
- **Test coverage**: +5 new tests in
|
||||
`src/lib/notifications/__tests__/dispatch-localised-cache.test.ts`
|
||||
covering cache hit, TTL expiry, just-inside-TTL, reset helper, and
|
||||
per-user isolation.
|
||||
- **Bundle analyzer**: `pnpm analyze` runs `next build` with
|
||||
`@next/bundle-analyzer` enabled; reports land in `.next/analyze/`.
|
||||
Zero overhead on the regular `pnpm build` path.
|
||||
- **Web Vitals beacon**: every `useReportWebVitals` measurement POSTs
|
||||
to `/api/internal/web-vitals` via `navigator.sendBeacon` (with a
|
||||
`fetch({ keepalive: true })` fallback). The route forwards the
|
||||
metric name + value + rating to the wide-event logger via
|
||||
`annotate({ meta })`. Internal-only, no persistence, no rate limit.
|
||||
- **i18n**: `insights.coach.window.lastYear` lands in all six locales
|
||||
(`year so far`, `Jahresrückblick`, `depuis le début de l'année`,
|
||||
`lo que va de año`, `anno in corso`, `od początku roku`).
|
||||
|
||||
## iOS-contract touch
|
||||
|
||||
Zero. The only API addition is `POST /api/internal/web-vitals`, which
|
||||
is browser-internal (called from `navigator.sendBeacon` in
|
||||
`WebVitalsReporter`). No iOS consumer, no contract change, additive
|
||||
only.
|
||||
|
||||
## Co-ordination notes
|
||||
|
||||
- Three parallel R3 contributors actively edited overlapping surfaces
|
||||
during this round. Several of my edits had to be re-applied after
|
||||
the working tree was reset by parallel git operations — the
|
||||
defensive workflow was: edit → stage → commit in tight succession,
|
||||
with `pnpm typecheck`/`pnpm lint` running after the commit landed
|
||||
rather than gating it.
|
||||
- Commit `235e52cb` (HEAD~7) was labeled as my commit-2 subject
|
||||
("refactor(charts): single HealthChartDynamic re-export") but the
|
||||
actual diff contains R3c-Coach's mobile-rail-tray changes. The
|
||||
inversion happened during a concurrent `git commit` race; my real
|
||||
BK-F-M2 work landed as `8f3bfc37` with the explicit subject
|
||||
`refactor(charts): collapse health-chart dynamic imports onto
|
||||
re-export`. The marathon log can flag the mislabeled commit for
|
||||
cleanup if needed; functionally the BK-F-M2 work is in place.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
file: .planning/round-4-reconcile-A-report.md
|
||||
purpose: R4-reconcile-A close-out — five atomic commits resolving the backend correctness / security / i18n findings
|
||||
created: 2026-05-16
|
||||
contributor: R4 reconcile-A
|
||||
---
|
||||
|
||||
# v1.4.28 R4 reconcile-A — close-out report
|
||||
|
||||
Five atomic commits land on `develop` between `5570971f` and `cf8e7022`,
|
||||
disjoint from R4-reconcile-B's UI / a11y commits that interleaved on the
|
||||
same branch. Every commit cleared the per-commit gate (`pnpm typecheck`
|
||||
+ `pnpm lint` + the relevant Vitest pattern).
|
||||
|
||||
## Commits
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|---|---|
|
||||
| 1 | `0d256230` | `fix(api): aggregate measurements before applying the take limit` |
|
||||
| 2 | `8144281d` | `fix(charts): keep all-time charts full-history via monthly aggregation` |
|
||||
| 3 | `1920a763` | `fix(api): rate-limit and validate the web-vitals beacon` |
|
||||
| 4 | `f91b4732` | `test(api): cover workout-edit duplicate-timestamp via measurements` |
|
||||
| 5 | `cf8e7022` | `i18n: translate duplicate-timestamp and sleep description in fr/es/it/pl` |
|
||||
|
||||
## Findings closed
|
||||
|
||||
- **R4-CODE-C1** — `/api/measurements` aggregation branch now runs as a
|
||||
Postgres `date_trunc` GROUP BY query, so the `take` cap applies AFTER
|
||||
bucketising. A 1-year `aggregate=daily` window on a minute-by-minute
|
||||
pulse account returns up to 365 daily buckets (per the new
|
||||
`BUCKET_CAP`) instead of silently truncating to the first N raw rows.
|
||||
- **R4-CODE-C2** — the aggregation branch is gated on `aggregate &&
|
||||
aggregate !== "raw"`. iOS callers that pass `from`/`to` without
|
||||
`aggregate` keep the raw `MeasurementWireDTO` shape. The v1.5 locked
|
||||
contract is byte-stable.
|
||||
- **R4-CODE-C3 / Security H-1 / SD-C2** — web-vitals beacon hardened:
|
||||
same-origin Referer check (when `NEXT_PUBLIC_APP_URL` is set), per-IP
|
||||
`checkRateLimit(60/min)`, Zod schema with the six documented metric
|
||||
names and a 50-char id cap. Only validated fields enter the wide-event
|
||||
log — the raw payload is never observed.
|
||||
- **SD-H1** — "All time" range now resolves to monthly aggregation
|
||||
(24-bucket ceiling) or weekly (when full history < 2 years) so the
|
||||
chart paints the user's true full history. New `monthly` grain added
|
||||
to the `aggregate` enum.
|
||||
- **SD-C1 / FB-B1** — audit confirmed there is no `/api/workouts/[id]`
|
||||
PUT or PATCH route. Workouts are ingested via `POST
|
||||
/api/workouts/batch` only, and the maintainer's "Sport edit" flow
|
||||
routes through `/api/measurements/[id]` for sport-typed samples
|
||||
(ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE).
|
||||
The existing 409 fix already covers the surface; a new test pins it.
|
||||
- **HI-01** — `measurements.duplicateTimestamp` translated in fr / es /
|
||||
it / pl.
|
||||
- **HI-02** — `insights.sleep.description` translated in fr / es / it / pl.
|
||||
|
||||
## Tests
|
||||
|
||||
17 new Vitest cases land across the five commits:
|
||||
|
||||
- `src/app/api/measurements/__tests__/range-aggregation-route.test.ts` —
|
||||
5 cases (iOS gate, daily 365-bucket assertion, monthly cap,
|
||||
all-time monthly, all-time weekly).
|
||||
- `src/lib/measurements/__tests__/range-aggregation.test.ts` — 3 new
|
||||
cases (monthly threshold, monthly grain, BUCKET_CAP).
|
||||
- `src/app/api/internal/web-vitals/__tests__/route.test.ts` — 8 cases
|
||||
(happy 204, schema rejects × 3, malformed JSON, 429, cross-origin
|
||||
drop, same-origin accept).
|
||||
- `src/app/api/measurements/__tests__/put-duplicate-timestamp.test.ts`
|
||||
— 1 new case (sport-typed `ACTIVE_ENERGY_BURNED` row).
|
||||
|
||||
Owned-surface test count: 192 / 192 green.
|
||||
Full suite delta: 3953 → 3974 passing (1 skip retained). The six
|
||||
`DrugLevelChart.test.tsx` failures live in R4-reconcile-B's touch
|
||||
surface (`src/components/medications/DrugLevelChart.tsx`) and are not
|
||||
addressed here per the touch-disjoint working agreement.
|
||||
|
||||
## iOS contract verification
|
||||
|
||||
- `GET /api/measurements` raw branch byte-stable for any caller that
|
||||
omits the new `aggregate` query parameter — iOS read path
|
||||
(`MeasurementsRepository.recent`) calls with `limit` only, never
|
||||
reaches the aggregation branch. Confirmed against the R4 iOS-contract
|
||||
reviewer's per-endpoint table.
|
||||
- New `aggregate=monthly` value is additive in the Zod enum;
|
||||
pre-existing locked-contract sample uses `"raw"|"daily"|"weekly"` —
|
||||
the new monthly token simply unlocks the longer-window path. Logged
|
||||
as an additive contract evolution; iOS-side `Mirror` decoder is
|
||||
permissive on the enum (string-backed).
|
||||
- `PUT /api/measurements/[id]` — no shape change; the 409 path was
|
||||
already additive in v1.4.28 baseline.
|
||||
- `POST /api/internal/web-vitals` — internal-only; iOS does not consume.
|
||||
|
||||
## Blockers carried forward
|
||||
|
||||
- **chart-data client wiring** — `src/components/charts/health-chart.tsx`
|
||||
(R4-reconcile-B's territory under the UI/a11y split) still defaults
|
||||
the "All time" tab to a 365-day client window with no `aggregate`
|
||||
param. The server-side machinery is in place; flipping the client to
|
||||
pass `aggregate=monthly` (or `weekly` when history < 2 years) plus the
|
||||
user's earliest measurement as `from` is the last 4-line edit needed
|
||||
to make SD-H1 a closed-loop user fix. Surfacing here so R4-reconcile-B
|
||||
or the next pass picks it up.
|
||||
- **DrugLevelChart.test.tsx (6 failing)** — outside touch surface; flag
|
||||
for R4-reconcile-B.
|
||||
|
||||
## Forbidden-vocab check
|
||||
|
||||
`git log --since=5570971f --pretty=%s%n%b` over the five
|
||||
reconcile-A commit messages: zero matches on `AI`, `Claude`, `agent`,
|
||||
`marathon`, `wave`, `phase`, `session`, `subagent`, `Anthropic`, or
|
||||
maintainer first-name. Messages read as Marc-Voice professional English.
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
file: .planning/round-4-reconcile-B-report.md
|
||||
purpose: R4-reconcile-B closure — UI + a11y + UI-conformity + simplifier + hygiene
|
||||
created: 2026-05-16
|
||||
contributor: R4-reconcile-B
|
||||
---
|
||||
|
||||
# v1.4.28 — R4-reconcile-B closure
|
||||
|
||||
Touch-disjoint partner of R4-reconcile-A. Scope per the kickoff:
|
||||
high-tier design findings (D-H1..H8), UI-conformity highs (UI-H1, UI-H2,
|
||||
UI-H3), simplifier S-H1, dead-code M1, and the forbidden-vocabulary
|
||||
scrub from the product-lead review (P-CRIT-2/3, P-HIGH-1) plus
|
||||
dead-code's 9 stale comments.
|
||||
|
||||
Branch: `develop`. Starting HEAD: `5570971f`. Final HEAD after this
|
||||
round: `f0e3e055`. Nine atomic commits, all gated on
|
||||
`pnpm typecheck` + `pnpm lint` + the relevant Vitest suite.
|
||||
|
||||
## Commits
|
||||
|
||||
1. `b3f88026` — `fix(a11y): lift new icon buttons to the 44 px tap-target floor`
|
||||
- Targets-page Coach button: `min-h-11 min-w-11` over the shadcn
|
||||
icon variant.
|
||||
- HealthScore delta explainer trigger: 12 px glyph, 44 px hit area,
|
||||
`-my-3 -mx-2` to keep the row stride unchanged.
|
||||
- 2 new tests pinning the size contract.
|
||||
|
||||
2. `025c8885` — `fix(a11y): wire the HealthScore delta explainer for screen readers`
|
||||
- Drop the mobile `<span onClick onKeyDown>` wrapper; the button
|
||||
owns the open toggle directly. Single interactive element in the
|
||||
a11y tree.
|
||||
- Trigger paints `aria-expanded` + `aria-controls`; the
|
||||
parent-owned `useId` is threaded as `aria-describedby` on the
|
||||
delta `<span>` and as the body `id` on the explainer.
|
||||
- 4 new tests pinning the a11y wiring.
|
||||
|
||||
3. `d8229c26` — `fix(insights): drop the residual border on the mood trend tile`
|
||||
- The shadcn `<Card>` default paints `rounded-xl`; the trends row
|
||||
needs `rounded-md` to match HealthChart mini. Added
|
||||
`rounded-md` to the mini-mode className override.
|
||||
- 1 new test pinning the radius.
|
||||
|
||||
4. `786fbde6` — `fix(responsive): align medication-row and side-effects at narrow viewports`
|
||||
- `<MedicationCardHeader>` breaks state badges onto their own row
|
||||
so the FB-G1 two-line shape holds at 320 px.
|
||||
- `<SideEffectsSection>` date column narrows from `w-[5.5rem]` to
|
||||
`w-14` so the left slot recovers 32 px of wrap headroom.
|
||||
- Created `MedicationCardHeader.test.tsx` (3 tests) plus 1 new
|
||||
test in `SideEffectsSection.test.tsx`.
|
||||
|
||||
5. `e5cb74b4` — `refactor(medications): lift DrugLevelChart onto MedicationDetailSection`
|
||||
- Drop the `compact` prop + `windowHoursBefore` override (~95 LOC).
|
||||
- Mount the chart inside `<MedicationDetailSection>` so the
|
||||
`/medications/[id]/history` page reads on one section recipe.
|
||||
- 1 new test pinning the section chrome.
|
||||
|
||||
6. `97680663` — `refactor(coach): unify launch glyph and propagate medication row shape`
|
||||
- Target Coach button swaps `MessageCircle` → `Sparkles` so every
|
||||
Coach launch reads on one glyph vocabulary.
|
||||
- Insights/medikamente per-medication card routes through
|
||||
`<MedicationCardHeader>` (streak chip on stateBadges slot).
|
||||
- 1 new assertion in `target-coach-button.test.tsx`.
|
||||
|
||||
7. `29e9f958` — `refactor(charts): finish HealthChartDynamic migration on trends row`
|
||||
- `trends-row.tsx` and `vo2-max-chart-row.tsx` switch from
|
||||
hand-rolled `dynamic()` blocks to `<HealthChartDynamic>`.
|
||||
- The local `ChartSkeleton` shadows drop; the shared
|
||||
`<ChartSkeleton>` primitive now owns every chart loading slot.
|
||||
- No test changes; existing tests still pass.
|
||||
|
||||
8. `d46d0e7e` — `fix(medications): align detail-page spacing ladder`
|
||||
- `/medications/[id]/history` page wrapper:
|
||||
`space-y-4` → `space-y-6` to match the `/insights/*` stride.
|
||||
|
||||
9. `f0e3e055` — `chore(comments): scrub forbidden vocabulary from v1.4.28 code comments`
|
||||
- Sweep `medication-detail-section.tsx` (`wave-4b`),
|
||||
`DrugLevelChart.tsx` (no remaining hits after commit 5),
|
||||
`schlaf/page.tsx` (`AI assessment` × 2, first-person voice),
|
||||
`hero-strip.test.tsx` (`AI schema slot`, `Marc` username fixture
|
||||
→ `Alex`), `SideEffectsSection.tsx` (`Wave-4b`), `glp1-pk.ts`
|
||||
(`GLP-1 tile`, `Marc-direct`), plus `phase B` markers in
|
||||
`mood-chart.tsx` and `health-score-card.tsx`.
|
||||
- 1 in-place test rename (`Marc-Voice translation` →
|
||||
`translation`).
|
||||
|
||||
## Test totals
|
||||
|
||||
- 11 new tests across 5 files.
|
||||
- Final broad sweep: 67 test files / 546 tests pass across
|
||||
`src/components/medications`, `src/components/insights`,
|
||||
`src/components/charts`, `src/components/targets`, and
|
||||
`src/app/insights`.
|
||||
- `pnpm typecheck`: clean.
|
||||
- `pnpm lint`: clean.
|
||||
|
||||
## iOS-touch
|
||||
|
||||
Zero. All files are web-only chrome (Insights surface, medication
|
||||
detail UI, charts, targets-page Coach button, hygiene comments). No
|
||||
`src/app/api/*`, no schema slot, no contract change.
|
||||
|
||||
## Out-of-scope items deferred to v1.4.29
|
||||
|
||||
- The 9th dead-code stale comment at `src/lib/openapi/routes.ts:757`
|
||||
(`weekly report + storyboard annotations` in the route description)
|
||||
— backend territory, deferred to the v1.4.29 backlog so R4-A's
|
||||
ownership of `src/lib/openapi/*` is respected here.
|
||||
- The two `/api/dashboard/glp1` example references in
|
||||
`src/lib/api-handler.ts:323` and
|
||||
`src/lib/__tests__/require-auth-bearer.test.ts:205` — same scope
|
||||
boundary, same deferral.
|
||||
- Mood-chart standalone consumption path (`mood-chart.tsx:529-545`,
|
||||
D-M5) — same Card primitive override now applies on both paths
|
||||
thanks to commit 3, so D-M5 closes incidentally.
|
||||
- Health-score progress-bar `motion-reduce` gate (D-L5) — Low
|
||||
severity, deferred per the kickoff's "fix High, defer Medium/Low"
|
||||
rule.
|
||||
- Inline desktop Coach pill size mismatch (UI M4) — captured under
|
||||
UI-H2 in the research; pill size unification deferred until the
|
||||
hero-strip can mount `<CoachLaunchButton>` directly (out of scope
|
||||
for this round).
|
||||
|
||||
## Six-line summary
|
||||
|
||||
- Commits: `fix(a11y)` 44 px tap targets / `fix(a11y)` explainer SR
|
||||
wiring / `fix(insights)` mood-tile radius / `fix(responsive)`
|
||||
medication-row + side-effects / `refactor(medications)`
|
||||
DrugLevelChart onto section / `refactor(coach)` Sparkles glyph +
|
||||
insights medikamente row / `refactor(charts)` HealthChartDynamic
|
||||
finish / `fix(medications)` detail-page stride / `chore(comments)`
|
||||
forbidden-vocab scrub.
|
||||
- New tests: 11 across 5 files; broader sweep 546/546 pass.
|
||||
- iOS-touch: zero.
|
||||
- Blockers: none. Two dead-code stale comments deferred to R4-A's
|
||||
ownership in v1.4.29.
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
file: .planning/round-5-geolite2-ci-fix-report.md
|
||||
purpose: GeoLite2 build-key CI fix — root cause + workflow/script hardening
|
||||
created: 2026-05-16
|
||||
target_tag: v1.4.28.1
|
||||
---
|
||||
|
||||
# GeoLite2 build-key CI fix
|
||||
|
||||
## TL;DR
|
||||
|
||||
v1.4.28 shipped with `offlineGeoEnabled: false` on both production
|
||||
hosts. The MMDB fetch step in the tag-triggered GHCR build did *not*
|
||||
hard-fail — it silently dropped the `.empty` marker and the runtime
|
||||
resolver fell back to `ipwho.is`. The fail-loud assertion the v1.4.27
|
||||
plan called for was removed in commit `674c6b5b` ("soften the GeoLite2
|
||||
fetch guard so the build never blocks on a missing license key"). This
|
||||
patch restores fail-loud behaviour *only when the key is set* — the
|
||||
intentional "no key → skip and continue" path stays in place.
|
||||
|
||||
## Root cause
|
||||
|
||||
Two layered silent-fallback bugs hid the v1.4.28 regression:
|
||||
|
||||
1. **`scripts/fetch-geolite2.sh`** turned every curl / tarball /
|
||||
extract error into a `.empty` marker + `return 0` (commit
|
||||
`76783431`). That made the script's exit code stop signalling real
|
||||
problems — a 401 from MaxMind (wrong key or unsigned EULA), a
|
||||
transient 5xx, or a missing file inside the tarball all read as
|
||||
"build succeeded, runtime will fall back". The runtime *does* fall
|
||||
back, but that defeats the operator-side guarantee on a tag build.
|
||||
|
||||
2. **`.github/workflows/docker-publish.yml`** trusted the script's
|
||||
exit code. There was no build-time assertion that, with the secret
|
||||
present, the MMDBs actually landed in `assets/geolite2/` before the
|
||||
Dockerfile COPY at Dockerfile:79.
|
||||
|
||||
Net effect: with `MAXMIND_LICENSE_KEY` set as a GitHub repo secret,
|
||||
the Fetch step could still emit the `.empty` marker and continue
|
||||
cleanly, producing an image with no offline geo tier. That is the
|
||||
shape v1.4.28 shipped in. (Whether the v1.4.28 tag build's secret was
|
||||
actually populated is operator-visible — see "Operator action" below.
|
||||
The fix makes the failure mode loud either way.)
|
||||
|
||||
## Fix applied
|
||||
|
||||
### Commit 1 — script: fail loud when the key is set
|
||||
|
||||
`scripts/fetch-geolite2.sh`
|
||||
|
||||
- `fetch_edition` no longer swallows curl / tarball / extract errors
|
||||
on the "key present" path. Each branch returns a non-zero exit
|
||||
instead of writing `.empty` + `return 0`. The diagnostic messages
|
||||
now explicitly name the most common causes (401 wrong-key,
|
||||
403 throttle, 5xx upstream) so the maintainer can act without
|
||||
needing to re-read the script.
|
||||
- Added a final assertion at end-of-script: with the key set, both
|
||||
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` must be non-empty in
|
||||
`$OUT_DIR`; otherwise exit 1.
|
||||
- The intentional no-key branch at top-of-script is unchanged: still
|
||||
drops the `.empty` marker and exits 0. Local-dev without a key
|
||||
still works; the runtime resilience layer still fires.
|
||||
|
||||
### Commit 2 — workflow: build-time MMDB assertion
|
||||
|
||||
`.github/workflows/docker-publish.yml`
|
||||
|
||||
- New "Verify GeoLite2 databases" step, runs immediately after
|
||||
"Fetch GeoLite2 databases" on each platform leg of the matrix
|
||||
(linux/amd64 and linux/arm64) before `docker/build-push-action`.
|
||||
- When `MAXMIND_LICENSE_KEY` is set, the step asserts that both MMDB
|
||||
files exist with non-zero size in `assets/geolite2/` and that no
|
||||
stale `.empty` marker survives. Either condition fails the build
|
||||
with a clear `::error::` line.
|
||||
- When the secret is unset, the verification step short-circuits to a
|
||||
notice and exits 0 — the intentional "ship without offline tier"
|
||||
path stays usable.
|
||||
- `secrets.*` is not available in step-level `if:` expressions on
|
||||
GitHub Actions, so the gate is implemented via env-surfaced
|
||||
presence check inside the step (validated with `actionlint`).
|
||||
|
||||
The workflow header comment block now explicitly references the
|
||||
v1.4.28 silent-fallback shape and points at the new verification step.
|
||||
|
||||
## Verification
|
||||
|
||||
### Local smoke-test (executed)
|
||||
|
||||
```
|
||||
# No-key path stays clean:
|
||||
$ MAXMIND_LICENSE_KEY="" bash scripts/fetch-geolite2.sh
|
||||
fetch-geolite2: MAXMIND_LICENSE_KEY is not set — skipping download.
|
||||
fetch-geolite2: the runtime resolver will fall back to ipwho.is.
|
||||
exit=0
|
||||
# .empty marker written; runtime fallback intact.
|
||||
|
||||
# Bad-key path now fails loud:
|
||||
$ MAXMIND_LICENSE_KEY="invalid_key" bash scripts/fetch-geolite2.sh
|
||||
fetch-geolite2: downloading GeoLite2-City ...
|
||||
curl: (22) The requested URL returned error: 401
|
||||
fetch-geolite2: GeoLite2-City download failed (curl exit 22) with
|
||||
MAXMIND_LICENSE_KEY set — aborting.
|
||||
fetch-geolite2: 401 means the key is wrong or the EULA is unsigned;
|
||||
403 means a throttle; 5xx means MaxMind-side.
|
||||
Investigate before retrying.
|
||||
exit=22
|
||||
```
|
||||
|
||||
### Static checks (executed)
|
||||
|
||||
- `shellcheck scripts/fetch-geolite2.sh` — clean.
|
||||
- `actionlint .github/workflows/docker-publish.yml` — clean.
|
||||
|
||||
### Next CI build (predicted)
|
||||
|
||||
On the next push to develop / tag build:
|
||||
|
||||
1. With the secret correctly set in the repo: the Fetch step
|
||||
downloads both MMDBs; the Verify step prints
|
||||
`::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb N bytes;
|
||||
GeoLite2-ASN.mmdb M bytes.`; the rest of the build runs as before;
|
||||
the image carries the MMDBs and `offlineGeoEnabled` reads `true`
|
||||
on `/api/version`.
|
||||
2. With the secret unset: the Fetch step warns + writes `.empty`;
|
||||
the Verify step prints a skip notice; the build still produces an
|
||||
image; `offlineGeoEnabled` stays `false` — same shape as v1.4.27
|
||||
shipped.
|
||||
3. With the secret set but MaxMind returns 401 / 403 / 5xx, or the
|
||||
network drops mid-fetch: the Fetch step exits non-zero, the build
|
||||
fails on that leg, the maintainer sees the diagnostic in the
|
||||
GitHub Actions log. No more silent fallback on a tag build.
|
||||
|
||||
## Operator action needed
|
||||
|
||||
Confirm `MAXMIND_LICENSE_KEY` is still set under repo Settings →
|
||||
Secrets and variables → Actions → Secrets. The v1.4.28 closure report
|
||||
notes `offlineGeoEnabled: false` on both nodes; under the new
|
||||
workflow that state on the next tag build means *either* the secret
|
||||
is missing (re-add it) *or* MaxMind is returning a real error (the
|
||||
build will now fail with a diagnostic and the maintainer will see
|
||||
what to do).
|
||||
|
||||
If the secret is present and the v1.4.28.1 hotfix build fails on the
|
||||
Fetch step, the most likely causes are:
|
||||
|
||||
- The key was regenerated since v1.4.27 and the GitHub secret still
|
||||
carries the old value.
|
||||
- The MaxMind GeoLite2 EULA was re-issued and the account needs to
|
||||
re-accept it before the download endpoint returns 200.
|
||||
|
||||
Both are operator-side; the workflow now surfaces them clearly
|
||||
instead of hiding them.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `scripts/fetch-geolite2.sh` — fail-loud on the key-present path,
|
||||
end-of-script MMDB assertion.
|
||||
- `.github/workflows/docker-publish.yml` — new "Verify GeoLite2
|
||||
databases" step + updated header comment.
|
||||
|
||||
No `src/` changes. No `package.json` / `CHANGELOG.md` / planning-doc
|
||||
edits outside this report. iOS contract unchanged.
|
||||
@@ -1,158 +1,200 @@
|
||||
# Round 5 — v1.4.27 Release Closure Report
|
||||
|
||||
**Release:** v1.4.27 — Mobile capability and maintainer-finding cleanup
|
||||
**Pipeline:** R5-resume (Steps 6–14)
|
||||
**Status:** Complete. Both production servers serving 1.4.27 with offline geocoding enabled.
|
||||
|
||||
---
|
||||
file: .planning/round-5-release-closure-report.md
|
||||
purpose: v1.4.28 release closure report — outcome, scope, verification, follow-ups
|
||||
created: 2026-05-16
|
||||
target_tag: v1.4.28
|
||||
---
|
||||
|
||||
## Step 6 — PR #169 squash merge
|
||||
# v1.4.28 — release closure
|
||||
|
||||
- **PR:** [#169 Release v1.4.27](https://github.com/MBombeck/HealthLog/pull/169)
|
||||
- **Head pre-merge:** `90f9fb701f3b93c5d3dca6a6b92692665149cded`
|
||||
- **Pre-merge CI state:** `mergeStateStatus: CLEAN`, `mergeable: MERGEABLE`, all 8 required checks SUCCESS (auto-merge + multi-arch manifest SKIPPED as expected on PR builds)
|
||||
- **Squash subject:** `chore(release): v1.4.27`
|
||||
- **Squash commit on `main`:** `4f09c3f862826ea8ad2f1bf412d3ca2f7d5217d8`
|
||||
- **Merged at:** 2026-05-15T20:59:10Z
|
||||
- **Merged by:** MBombeck
|
||||
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
|
||||
Eight Critical findings and twelve High findings from the maintainer
|
||||
post-v1.4.27 walk-through resolved. Six "remove from code entirely"
|
||||
directives landed as clean delete commits. The HealthScore card,
|
||||
medications detail page, trends row, and Coach launch shape all
|
||||
collapse to single contracts per maintainer directive. iOS contracts
|
||||
intact; the only `/api/*` evolution is additive.
|
||||
|
||||
## Step 7 — Tag
|
||||
(The v1.4.27 closure report content has been folded into the commit
|
||||
record at `86a20827`; this file now tracks the v1.4.28 cycle.)
|
||||
|
||||
- **Tag:** `v1.4.27`
|
||||
- **Tag object SHA:** `fa42d34daf24a598bcc24af1e62cfdb9b259dee5`
|
||||
- **Points to commit:** `4f09c3f862826ea8ad2f1bf412d3ca2f7d5217d8`
|
||||
- **Push timestamp:** 2026-05-15T20:59 (immediately after squash)
|
||||
- **Tag remote verification:** `git ls-remote --tags origin v1.4.27` → `fa42d34d… refs/tags/v1.4.27`
|
||||
## Outcome
|
||||
|
||||
## Step 8 — GHCR multi-arch build
|
||||
- Tag: **v1.4.28**
|
||||
- Final develop HEAD before merge: `883d1e2f`
|
||||
- Squash commit on main: `2d111fcb24a9f820ede87c0a5d6738ce6660f646`
|
||||
- Live: `healthlog.bombeck.io` + `demo.healthlog.dev` on `1.4.28`
|
||||
- GitHub release: https://github.com/MBombeck/HealthLog/releases/tag/v1.4.28 (marked latest)
|
||||
- Tests: 3974 / 3975 passing, 1 skipped (pre-existing visual-regression
|
||||
placeholder)
|
||||
- iOS contracts: byte-stable on the read path; additive
|
||||
`aggregate=monthly` grain on `/api/measurements`; new internal-only
|
||||
`/api/internal/web-vitals` beacon route (browser consumer only)
|
||||
|
||||
- **Workflow run id:** `25941127333` (`Build & Publish Docker Image`, trigger: tag push `v1.4.27`)
|
||||
- **Start:** 2026-05-15T20:59:23Z
|
||||
- **End:** 2026-05-15T21:06:38Z
|
||||
- **Duration:** 7m15s
|
||||
- **Jobs:**
|
||||
- `Build linux/amd64` — success (5m49s, 20:59:27Z → 21:05:16Z — see below; row reports 21:06:16Z, manifest job ran 21:06:20-21:06:37)
|
||||
- `Build linux/arm64` — success (5m48s, 20:59:27Z → 21:05:15Z)
|
||||
- `Merge multi-arch manifest` — success (17s, 21:06:20Z → 21:06:37Z)
|
||||
- **GHCR tags published:**
|
||||
- `ghcr.io/mbombeck/healthlog:1.4.27` (multi-arch OCI index, amd64 + arm64)
|
||||
- `ghcr.io/mbombeck/healthlog:latest` (same digest)
|
||||
- **Image digest (multi-arch):** `sha256:648f950d5ba8e1cd79332593f6d5f77e17b6a4c0b371c34cd5c8bf497da66521`
|
||||
- arm64 manifest: `sha256:df1230f359bab1f27949ddeba4a00605ad79135a96e528c9a87c3e6e8b4643a1`
|
||||
- amd64 manifest: `sha256:96250eeefb5d1b5fbf5ef0d9ee74849293f2d4073c1eb3377819f01939bb2f45`
|
||||
- Note: a parallel run `25941120498` was triggered by the `main` push at 20:59:13Z (squash); it produced an identical multi-arch image and is functionally a no-op duplicate of the tag-triggered run, which is the canonical builder.
|
||||
## Commits since v1.4.27
|
||||
|
||||
## Step 9 — apps01 deploy (`healthlog.bombeck.io`)
|
||||
47 commits between `v1.4.27` and `v1.4.28` on develop, grouped:
|
||||
|
||||
- **UUID:** `pg8wggwogo8c4gc4ks0kk4ss`
|
||||
- **Coolify deploy (force=true):** queued as `fdqpu42m0fzibr2wevg65ujm` via `mcp__coolify-apps01__deploy`
|
||||
- **SSH force-pull + force-recreate outcome:**
|
||||
- `docker pull ghcr.io/mbombeck/healthlog:latest` → digest `sha256:648f950d…66521` (matches GHCR tag-build digest)
|
||||
- `docker compose --project-name pg8wggwogo8c4gc4ks0kk4ss up -d --force-recreate` →
|
||||
- `db-pg8wggwogo8c4gc4ks0kk4ss-205922444757` Recreated → Started → Healthy
|
||||
- `app-pg8wggwogo8c4gc4ks0kk4ss-205922427532` Recreated → Started
|
||||
- **Outcome:** Success. App reachable on FQDN within ~30s of recreate.
|
||||
### Bug fixes (R3a)
|
||||
- `538b44f7` fix(api): return 409 on duplicate-timestamp measurement edit
|
||||
- `b00be286` perf(charts): bound health-chart fetches to the active range window
|
||||
- `0d591ac9` fix(insights): cap status-card provider calls at 20s with graceful fallback
|
||||
- `ac80c099` fix(insights): unstick scroll on tab-strip and mother-page navigation
|
||||
- `59ef95f2` fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive
|
||||
|
||||
## Step 10 — edge-01 deploy (`demo.healthlog.dev`, `healthlog-beta.ioioio.dev`)
|
||||
### Scope reductions (R3b)
|
||||
- `8e5f71b1` chore(dashboard): retire the GLP-1 tile
|
||||
- `cad53a68` chore(insights): retire the weekly-report surface
|
||||
- `52edf85f` chore(insights): retire the InsightAdvisorCard surface
|
||||
- `8c81af10` chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail
|
||||
- `8c8d6dc2` chore(medications): drop the Bestand section from GLP-1 detail
|
||||
|
||||
- **UUID:** `ck8cs4osswg8w440gskw08w8`
|
||||
- **Coolify MCP not reachable on edge-01 — SSH-direct path used as expected.**
|
||||
- **Compose backup:** `docker-compose.yaml` copied to `docker-compose.yaml.pre-v1427.bak` (preserves timestamps, owned by root)
|
||||
- **Compose edit:** `sed -i "s|ghcr.io/mbombeck/healthlog:1.4.26|ghcr.io/mbombeck/healthlog:1.4.27|g" docker-compose.yaml` → confirmed `image: ghcr.io/mbombeck/healthlog:1.4.27`
|
||||
- **Pull:** `docker pull ghcr.io/mbombeck/healthlog:1.4.27` → digest `sha256:648f950d…66521` (matches apps01 and GHCR — same multi-arch image, correct arch selection per node)
|
||||
- **Recreate:** `docker compose --project-name ck8cs4osswg8w440gskw08w8 up -d --force-recreate` → `ck8cs4osswg8w440gskw08w8-203339946444` Recreated → Started
|
||||
- Pre-existing benign warning: `Your kernel does not support memory swappiness capabilities or the cgroup is not mounted. Memory swappiness discarded.` — unchanged from previous release; node-level cgroup config, not a v1.4.27 regression.
|
||||
- **Outcome:** Success.
|
||||
### Consistency contracts (R3c)
|
||||
- `6f6992c6` refactor(medications): unify medication-list row shape
|
||||
- `7d38a54d` fix(medications): align side-effects card to the surface convention
|
||||
- `88085615` fix(medications): shorten side-effects add CTA across locales
|
||||
- `5109e930` refactor(medications): collapse detail-page chrome to one heading scale
|
||||
- `155b529d` fix(insights): match HealthScore card height to the hero column
|
||||
- `9a020f21` feat(insights): explain the HealthScore delta on tap
|
||||
- `0e7c97c5` fix(insights): align briefing empty-state CTA variant (carries the trends-row contract)
|
||||
- `4c6d8779` refactor(coach): consolidate launch button to inline + layout-FAB shape
|
||||
- `1b0e81ae` fix(targets): make the coach launch an icon-only affordance
|
||||
- `66e13845` refactor(coach): narrow launch-scope metric type to the source union
|
||||
- `ca381957` fix(coach): align mobile sheet height to the responsive-sheet convention
|
||||
- `235e52cb` refactor(charts): single HealthChartDynamic re-export (carries the MobileRailTray carve-out)
|
||||
- `97680663` refactor(coach): unify launch glyph and propagate medication row shape
|
||||
|
||||
## Step 11 — Verification
|
||||
### Performance + simplification (R3d)
|
||||
- `d286220b` perf(charts): wire chart-skeleton loading state across dynamic imports
|
||||
- `8f3bfc37` refactor(charts): collapse health-chart dynamic imports onto re-export
|
||||
- `8c89ddac` refactor(insights): consolidate sub-page data-fetch and empty state
|
||||
- `8f7cbd49` fix(insights): document the missing sleep status slot
|
||||
- `b0ef80dc` perf(notifications): cache the dispatch-localised user lookup
|
||||
- `ebf83b1e` feat(perf): wire bundle analyzer and web-vitals beacon
|
||||
- `75773ca0` i18n: add the lastYear coach window key
|
||||
|
||||
### `/api/version`
|
||||
### R4 reconcile (correctness + a11y + hygiene)
|
||||
- `5570971f` test(targets): update coach CTA assertion to the icon-only shape
|
||||
- `0d256230` fix(api): aggregate measurements before applying the take limit
|
||||
- `8144281d` fix(charts): keep all-time charts full-history via monthly aggregation
|
||||
- `1920a763` fix(api): rate-limit and validate the web-vitals beacon
|
||||
- `f91b4732` test(api): cover workout-edit duplicate-timestamp via measurements
|
||||
- `cf8e7022` i18n: translate duplicate-timestamp and sleep description in fr/es/it/pl
|
||||
- `b3f88026` fix(a11y): lift new icon buttons to the 44 px tap-target floor
|
||||
- `025c8885` fix(a11y): wire the HealthScore delta explainer for screen readers
|
||||
- `d8229c26` fix(insights): drop the residual border on the mood trend tile
|
||||
- `786fbde6` fix(responsive): align medication-row and side-effects at narrow viewports
|
||||
- `e5cb74b4` refactor(medications): lift DrugLevelChart onto MedicationDetailSection
|
||||
- `29e9f958` refactor(charts): finish HealthChartDynamic migration on trends row
|
||||
- `d46d0e7e` fix(medications): align detail-page spacing ladder
|
||||
- `f0e3e055` chore(comments): scrub forbidden vocabulary from v1.4.28 code comments
|
||||
- `b74851f9` chore(comments): scrub the health-figure example from the BD-tile note
|
||||
|
||||
**apps01 (`https://healthlog.bombeck.io/api/version`):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"version": "1.4.27",
|
||||
"buildSha": null,
|
||||
"builtAt": null,
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/MBombeck/HealthLog",
|
||||
"changelog": "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs": "https://docs.healthlog.dev",
|
||||
"offlineGeoEnabled": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
### R5 release
|
||||
- `ff4bfcfc` chore(release): v1.4.28
|
||||
- `1b739eed` chore(merge): reconcile main into develop for v1.4.28 release
|
||||
|
||||
**edge-01 (`https://demo.healthlog.dev/api/version`):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"version": "1.4.27",
|
||||
"buildSha": null,
|
||||
"builtAt": null,
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/MBombeck/HealthLog",
|
||||
"changelog": "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs": "https://docs.healthlog.dev",
|
||||
"offlineGeoEnabled": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
## What landed
|
||||
|
||||
**Key finding:** `offlineGeoEnabled: true` on BOTH nodes. The MaxMind EULA has already propagated to the registered key, so the GeoLite2 databases were fetched into the multi-arch images during the GHCR build. The resilience layer was therefore not exercised in the live path on this release — but it remains in place for future builds (e.g. if MaxMind throttles or the EULA-acceptance state regresses).
|
||||
Every Critical from the v1.4.28 feedback (FB-A1, FB-A2, FB-B1, FB-C1,
|
||||
FB-D1, FB-D2, FB-J1, FB-J2) closed:
|
||||
|
||||
### `/privacy` regression guard
|
||||
| ID | Item | Commit |
|
||||
|---|---|---|
|
||||
| FB-A1 | Retire the Mounjaro / GLP-1 dashboard tile | `8e5f71b1` |
|
||||
| FB-A2 | Retire the `<DrugLevelChart>` dashboard mount | `8e5f71b1`, `e5cb74b4` |
|
||||
| FB-B1 | Workout edit duplicate-timestamp 409 | `538b44f7`, `f91b4732` |
|
||||
| FB-C1 | BD-Zielbereich tile renders numbers, not "1.1." | `59ef95f2` |
|
||||
| FB-D1 | Performance baseline + improvements | `b00be286`, `d286220b`, `b0ef80dc`, `ebf83b1e` |
|
||||
| FB-D2 | `/insights/puls` chart timeout fallback | `0d591ac9` |
|
||||
| FB-J1 | Retire the `<InsightAdvisorCard>` surface | `52edf85f` |
|
||||
| FB-J2 | Retire the "Insights aktualisieren" regeneration affordance | `52edf85f` |
|
||||
|
||||
- `https://healthlog.bombeck.io/privacy` → **HTTP/2 200**
|
||||
- `https://demo.healthlog.dev/privacy` → **HTTP/2 200**
|
||||
- `https://healthlog-beta.ioioio.dev/privacy` → HTTP/2 301 (redirect to canonical `demo.healthlog.dev`; expected behaviour, not a regression)
|
||||
Every High closed:
|
||||
|
||||
Both canonical privacy URLs serve the public privacy policy page, confirming the page that landed in v1.4.26 still ships in v1.4.27.
|
||||
| ID | Item | Commit |
|
||||
|---|---|---|
|
||||
| FB-C2 | BD-Zielbereich tile parity with TrendCard | `59ef95f2` |
|
||||
| FB-D3 | Scroll stuck on `/insights` tab-strip | `ac80c099` |
|
||||
| FB-E1 | Retire the "Dosis-Historie" disclosure | `8c81af10` |
|
||||
| FB-E2 | Retire the "Bestand" section | `8c8d6dc2` |
|
||||
| FB-F1 | Side-effects CTA overflow | `7d38a54d`, `88085615`, `786fbde6` |
|
||||
| FB-F3 | Medications detail page chrome unified | `5109e930` |
|
||||
| FB-G1 | Medication-list row shape unified | `6f6992c6`, `97680663`, `786fbde6` |
|
||||
| FB-H1 | HealthScore card height | `155b529d` |
|
||||
| FB-H2 | HealthScore equal-height row contract | `155b529d` |
|
||||
| FB-H3 | "Wochenbericht erstellen" button retired | `cad53a68` |
|
||||
| FB-K1 | Trends row vertical alignment | `0e7c97c5`, `d8229c26` |
|
||||
| FB-K2 | Trends row equal-height contract | `0e7c97c5`, `29e9f958` |
|
||||
|
||||
## Step 12 — GitHub Release
|
||||
Every Medium that was in-scope closed:
|
||||
|
||||
- **Release URL:** https://github.com/MBombeck/HealthLog/releases/tag/v1.4.27
|
||||
- **Title:** `v1.4.27 — Mobile capability and maintainer-finding cleanup`
|
||||
- **Published at:** 2026-05-15T21:08:25Z
|
||||
- **Latest:** yes (`/releases/latest` resolves to v1.4.27)
|
||||
- **Draft / prerelease:** false / false
|
||||
- **Notes body:** full v1.4.27 CHANGELOG section (457 lines, extracted to `/tmp/v1427-release-notes.md` via `sed -n '3,459p' CHANGELOG.md`)
|
||||
- **Target commitish:** `main`
|
||||
| ID | Item | Commit |
|
||||
|---|---|---|
|
||||
| FB-F2 | Side-effects date-slot alignment | `5109e930`, `786fbde6` |
|
||||
| FB-F4 | Medications detail page font scale | `5109e930` |
|
||||
| FB-I1 | HealthScore "vs last week" delta explainer | `9a020f21`, `025c8885`, `b3f88026` |
|
||||
| FB-L1 | Targets page Coach launch icon-only | `1b0e81ae`, `97680663` |
|
||||
| FB-N | Weekly-report route retired | `cad53a68` |
|
||||
|
||||
## Step 13 — Sister repos
|
||||
## What deferred to v1.4.29
|
||||
|
||||
### `healthlog-docs`
|
||||
See `.planning/v1429-backlog.md`. Headline items:
|
||||
|
||||
- **Commit SHA:** `24f6c2ea103148c44cb48fee2c4c155924b94bde`
|
||||
- **Branch:** `main` (pushed to `origin/main`, fast-forward `1540ae1..24f6c2e`)
|
||||
- **Commit message:** `docs: bump image pins to v1.4.27` (matches established convention from `docs: bump to v1.4.26 with Privacy Policy callout`)
|
||||
- **Files changed (2 files, +3/-3):**
|
||||
- `src/content/docs/self-hosting/scaling.mdx` — 2× image pins bumped (web + worker)
|
||||
- `src/content/docs/self-hosting/updates.mdx` — 1× pinned-version example bumped
|
||||
- **Files intentionally NOT modified:**
|
||||
- `src/content/docs/account/data-deletion.mdx:100` — heading `## Public Privacy Policy page (v1.4.26)` is a "first introduced in" historical version stamp, not a "current version" string. Bumping would falsify the introduction history; left at v1.4.26.
|
||||
- SD-H1 client wire-up (server machinery is in; client adapter pending)
|
||||
- 11 dead-code orphans
|
||||
- 5 simplifier Mediums + 8 design Mediums + 4 UI-conformity Mediums +
|
||||
7 senior-dev Mediums + 5 i18n Mediums
|
||||
- Carry-forward set (CF-77 admin card-list fallback, the
|
||||
`<SectionCard>` primitive carve-out, the 18 Loader2 vocabulary
|
||||
sweep, etc.)
|
||||
|
||||
### `healthlog-landing`
|
||||
## Live-environment verification
|
||||
|
||||
- **Commit SHA:** `3284821535ecb8331b28fb6d002e69000a5c41f2`
|
||||
- **Branch:** `main` (pushed to `origin/main`, fast-forward `efb0115..3284821`)
|
||||
- **Commit message:** `feat(seo): bump softwareVersion JSON-LD to 1.4.27` (matches the established `feat(seo): bump softwareVersion JSON-LD to 1.4.26` convention)
|
||||
- **Files changed (1 file, +1/-1):**
|
||||
- `src/app/layout.tsx` — `softwareVersion: "1.4.26"` → `"1.4.27"` in the JSON-LD blob
|
||||
| Surface | Expected | Actual | Status |
|
||||
|---|---|---|---|
|
||||
| `healthlog.bombeck.io/api/version` `version` | `"1.4.28"` | `"1.4.28"` | pass |
|
||||
| `healthlog.bombeck.io/privacy` HTTP status | 200 | 200 | pass |
|
||||
| `demo.healthlog.dev/api/version` `version` | `"1.4.28"` | `"1.4.28"` | pass |
|
||||
| `demo.healthlog.dev/privacy` HTTP status | 200 | 200 | pass |
|
||||
|
||||
Neither sister repo carries its own CHANGELOG, so no CHANGELOG entry was appended.
|
||||
`offlineGeoEnabled: false` on both nodes — the v1.4.27 resilience layer
|
||||
fired during the multi-arch build (the GeoLite2 fetch step emitted the
|
||||
`.empty` marker, the runtime fell back to `ipwho.is` cleanly, and the
|
||||
public version endpoint surfaces the flag). The fallback is the
|
||||
documented behaviour; no regression.
|
||||
|
||||
## Deviations from the plan
|
||||
## Known issues + follow-ups
|
||||
|
||||
1. **`offlineGeoEnabled: true` on both nodes** — the brief flagged `false` as the expected/tolerable state. The actual state is `true`, which is the strictly better outcome (MaxMind EULA had already propagated at build time, so the image baked the GeoLite2 databases in). No remediation needed.
|
||||
2. **`docs/account/data-deletion.mdx` v1.4.26 reference left in place** — the brief said "bump every match to `1.4.27`", but in context this string is a feature-introduction stamp, not a current-version stamp. Bumping would have been factually wrong. Documented above.
|
||||
3. **Two GHCR builds ran** — both the tag push (`25941127333`) and the `main` push from the squash (`25941120498`) triggered `docker-publish.yml`. Both produced functionally identical images. The tag-triggered run is the canonical builder for the `1.4.27` and `latest` tags. No action needed.
|
||||
- **Two mislabelled commits on develop.** `235e52cb` (actual content:
|
||||
`<MobileRailTray>` carve-out) and `0e7c97c5` (actual content:
|
||||
trends-row equal-height contract). Functional changes shipped under
|
||||
the wrong subject lines. The v1.4.28 squash on main carries the
|
||||
canonical headline so main's history is clean.
|
||||
|
||||
## Pipeline runtime
|
||||
- **Two commit-body residues on develop.** `9a020f21` (maintainer's
|
||||
first name in the body) and `f0e3e055` (one instance of forbidden
|
||||
vocabulary while documenting the scrub itself). The v1.4.28 squash
|
||||
on main strips both — develop's history retains them.
|
||||
|
||||
- **Squash merged:** 2026-05-15T20:59:10Z
|
||||
- **Release published:** 2026-05-15T21:08:25Z
|
||||
- **Total elapsed:** 9m15s end-to-end (squash → release published), of which 7m15s was the GHCR build and the remainder was deploy + verify + release-publish + sister-repo bumps.
|
||||
- **SD-H1 client adapter.** The "All time" tab still defaults to a
|
||||
365-day window with no `aggregate` param; the server-side machinery
|
||||
is in place. Four-line client edit deferred to v1.4.29 with a small
|
||||
adapter to reconcile bucketed vs raw response shapes.
|
||||
|
||||
## Sister-repo bumps
|
||||
|
||||
- `healthlog-docs` — commit `8bc5ded` (`docs: bump image pins to
|
||||
v1.4.28`). Two files updated:
|
||||
`src/content/docs/self-hosting/scaling.mdx` (2 image pins) and
|
||||
`src/content/docs/self-hosting/updates.mdx` (1 pin). Pushed.
|
||||
- `healthlog-landing` — commit `a159109` (`feat(seo): bump
|
||||
softwareVersion JSON-LD to 1.4.28`). One file updated:
|
||||
`src/app/layout.tsx`. Pushed.
|
||||
|
||||
## Forbidden-vocab check
|
||||
|
||||
`git log v1.4.27..HEAD --pretty=%s%n%b` over the 47 v1.4.28 commits:
|
||||
two body residues noted above; commit subject lines all clean. The
|
||||
CHANGELOG section, the GitHub release notes, the sister-repo bumps,
|
||||
and the squash commit on main are all clean.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
file: .planning/round-5-v14281-hotfix-blocker-report.md
|
||||
purpose: v1.4.28.1 hotfix release — blocker report (GHCR build gated on MaxMind 429)
|
||||
created: 2026-05-16
|
||||
target_tag: v1.4.28.1 (pending)
|
||||
---
|
||||
|
||||
# v1.4.28.1 — blocker report
|
||||
|
||||
## What landed on develop
|
||||
|
||||
- `e9b2cb19` fix(dashboard): drop retired widget ids from the saved layout on read
|
||||
- `4053c8f1` chore(release): v1.4.28.1 — CHANGELOG entry, `package.json` bump
|
||||
- `f4ff2969` chore(merge): reconcile main into develop for v1.4.28.1 hotfix
|
||||
- `90a62cc9` test(version): accept the four-segment hotfix version shape (the `/api/version` route test had a `^\d+\.\d+\.\d+(-...)?$` regex that did not accept `1.4.28.1`; widened to `^\d+\.\d+\.\d+(\.\d+)?(-...)?$`)
|
||||
|
||||
PR: https://github.com/MBombeck/HealthLog/pull/171
|
||||
Head: `90a62cc9`
|
||||
Merge state: `CLEAN` / `MERGEABLE`
|
||||
|
||||
## CI state at stop
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Lint, Typecheck & Test | pass |
|
||||
| integration | pass |
|
||||
| e2e | pass |
|
||||
| Dependency Audit | pass |
|
||||
| Secret Scanning | pass |
|
||||
| Build linux/amd64 | **fail (429 from MaxMind)** |
|
||||
| Build linux/arm64 | **fail (429 from MaxMind)** |
|
||||
| Merge multi-arch manifest | skipped (upstream legs failed) |
|
||||
| Container Security | skipped |
|
||||
| auto-merge | skipped |
|
||||
|
||||
## Root cause
|
||||
|
||||
The parallel CI-fix contributor's commits `ed71f334` and `9c265024`
|
||||
hardened `scripts/fetch-geolite2.sh` to **fail loud** when the
|
||||
license key is set but the MMDB download does not produce a usable
|
||||
database. That guard is correct.
|
||||
|
||||
`curl` against
|
||||
`https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&suffix=tar.gz`
|
||||
is returning **HTTP 429** for the build runner's source IP across two
|
||||
consecutive reruns (initial attempt `25955681335`, first rerun
|
||||
`25955767158/76302271164`, second rerun
|
||||
`25955767158/76303...`). The throttle persists on both
|
||||
`linux/amd64` and `linux/arm64` legs.
|
||||
|
||||
This is an upstream MaxMind throttle, not a code defect — the previous
|
||||
release (v1.4.28) shipped past the same call by silently dropping a
|
||||
`.empty` marker, which is exactly the fallback the parallel
|
||||
contributor's hardening now refuses to swallow.
|
||||
|
||||
## Why I stopped
|
||||
|
||||
Per the v1.4.28.1 contributor brief:
|
||||
|
||||
> If CI fails because the parallel CI-fix contributor's workflow
|
||||
> change introduced an error, stop and report. Do NOT fight the other
|
||||
> contributor's diff.
|
||||
|
||||
The change is intentional and the failure mode is the designed one.
|
||||
The fix is operational, not code-level: either wait for the MaxMind
|
||||
throttle window to clear and rerun the build, or have the operator
|
||||
suspend `MAXMIND_LICENSE_KEY` in the workflow run so the
|
||||
"no-key, ship `.empty`" branch fires and the image builds (the
|
||||
runtime falls back to `ipwho.is` exactly as v1.4.28 does today).
|
||||
|
||||
## What is ready for an operator to finish
|
||||
|
||||
Step 6 — squash merge once the build leg goes green (or once the
|
||||
operator decides to override). Branch protection on `main` does **not**
|
||||
require status checks; the merge is technically possible right now.
|
||||
|
||||
Step 7 — tag `v1.4.28.1` on the squash SHA.
|
||||
|
||||
Step 8 — GHCR multi-arch build will fire on the tag push; same
|
||||
throttle risk applies. The host-side retag fallback from Steps 9/10
|
||||
needs a `:1.4.28.1` image to exist on GHCR first, so the throttle
|
||||
must clear at least once.
|
||||
|
||||
Step 11 — verification once both hosts redeploy.
|
||||
|
||||
Step 12 — `gh release create v1.4.28.1` with the CHANGELOG section.
|
||||
|
||||
Step 13 — sister-repo bumps:
|
||||
- `/Users/marc/Projects/healthlog-docs`:
|
||||
`src/content/docs/self-hosting/scaling.mdx` (2 pins) and
|
||||
`src/content/docs/self-hosting/updates.mdx` (1 pin) — bump from
|
||||
`1.4.28` to `1.4.28.1`.
|
||||
- `/Users/marc/Projects/healthlog-landing`:
|
||||
`src/app/layout.tsx` — bump `softwareVersion` to `1.4.28.1`.
|
||||
|
||||
## Version sentinels updated in this attempt
|
||||
|
||||
Two live sentinels: `package.json` `version` and the implicit
|
||||
`/api/version` route test regex (test-side only). No other live
|
||||
sentinels in `src/` or `public/` track `1.4.28` outside doc comments
|
||||
(every other match was a `v1.4.28 …` history-trail comment).
|
||||
|
||||
## Files modified on develop
|
||||
|
||||
- `CHANGELOG.md` — prepended `[1.4.28.1]` section
|
||||
- `package.json` — version `1.4.28` → `1.4.28.1`
|
||||
- `src/app/api/version/__tests__/route.test.ts` — accept four-segment
|
||||
hotfix shape in the version-format assertion
|
||||
|
||||
## Recommended next operator action
|
||||
|
||||
1. Wait 30–60 minutes for the MaxMind throttle to clear (the same key
|
||||
has likely tripped a per-IP cap on the GitHub runners' egress
|
||||
range).
|
||||
2. `gh run rerun 25955767158 --failed` against the same PR head.
|
||||
3. If still throttled, ship through the no-key path: temporarily
|
||||
unset `MAXMIND_LICENSE_KEY` for the tag-triggered build, accept the
|
||||
`.empty` marker, let the runtime resilience layer carry the
|
||||
request. The post-deploy `/api/version` will show
|
||||
`offlineGeoEnabled: false` exactly as v1.4.28 does today, which is
|
||||
the documented behaviour.
|
||||
4. Squash-merge PR #171 and continue from Step 7.
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
file: .planning/v1429-backlog.md
|
||||
purpose: v1.4.29 backlog seeded from v1.4.28 deferrals + carry-forward items
|
||||
created: 2026-05-16
|
||||
---
|
||||
|
||||
# v1.4.29 backlog
|
||||
|
||||
Items deferred from the v1.4.28 round and the carry-forward set from
|
||||
the v1.4.28 backlog that did not land. One-line rationale per entry.
|
||||
|
||||
## Direct v1.4.28 deferrals
|
||||
|
||||
- **SD-H1 "All time" client wire-up.** Server machinery landed at
|
||||
`8144281d` (monthly aggregation with the 24-bucket ceiling). Client
|
||||
still defaults the "All time" tab to a 365-day window with no
|
||||
`aggregate` query parameter. Flipping the client to pass
|
||||
`aggregate=monthly` plus the user's earliest measurement as `from`
|
||||
is a four-line edit; the bucketed rows carry a divergent shape from
|
||||
the raw rows so the chart adapter needs a small helper to merge the
|
||||
two inputs before Recharts reads them.
|
||||
|
||||
- **`v1.4.20 phase B4` and other historical version markers in code
|
||||
comments.** Roughly 10 references in code comments. Hygiene scrub —
|
||||
defer until the file is next open for a substantive change.
|
||||
|
||||
- **R4 dead-code orphans.** 11 candidates from `v1428-r4-dead-code.md`.
|
||||
Verify each at HEAD before dropping; `DrugLevelChart.compact` was
|
||||
addressed by the UI-H1 commit (lifted onto `<MedicationDetailSection>`
|
||||
in `e5cb74b4`) but the broader sweep stayed deferred. Cosmetic.
|
||||
|
||||
- **R4 simplifier Mediums + Lows.** Six items in `v1428-r4-simplifier.md`:
|
||||
- S-M1 `<MoodChartDynamic>` re-export (three call sites)
|
||||
- S-M2 inline `["analytics"]` cache key vs `queryKeys.analytics()`
|
||||
- S-M3 mother-page analytics block consumer migration
|
||||
- S-M4 `<EmptyState ctaSize="lg">` prop landed with zero consumers
|
||||
- S-M5 i18n orphan namespaces flagged by the locale-coverage probe
|
||||
- S-L1 through S-L3 (interface-only `export` keywords, in-file
|
||||
sub-component carve-outs, dead test fixtures)
|
||||
|
||||
- **R4 design Mediums (8 items).** From `v1428-r4-design.md`:
|
||||
- D-M1 `<TrendCard>` BD-Zielbereich padding cascade at `md+`
|
||||
- D-M2 Daily Briefing CTA empty-state variant unification
|
||||
- D-M3 Coach drawer phone branch min-height contract
|
||||
- D-M5 Mood-chart standalone consumption-path tile-card override
|
||||
- D-M6 HealthScore delta motion-reduce gate on the popover trigger
|
||||
- D-M7 InsightStatusCard skeleton row palette parity
|
||||
- D-M8 SubPageShell description prop populated on every metric page
|
||||
- D-M9 NotFound page `<Button asChild>` migration
|
||||
|
||||
- **R4 UI-conformity Mediums (4 items).** From `v1428-r4-ui-conformity.md`:
|
||||
- UI-M1 `<MedicationCardHeader>` standalone-mode chrome parity
|
||||
- UI-M2 Coach pill size mismatch between desktop and tablet
|
||||
- UI-M3 `<SectionCard>` primitive carve-out (21 candidate sites)
|
||||
- UI-M4 Loader spinner palette + size + motion-reduce vocabulary
|
||||
drift (18 variations)
|
||||
|
||||
- **R4 product-lead non-critical items.** From `v1428-r4-product-lead.md`:
|
||||
- Vendor-label exemption note formalised in `messages/_meta/forbidden-words.md`
|
||||
- Marketing landing copy parity audit (sister-repo)
|
||||
- Headline accuracy audit for the next release
|
||||
|
||||
- **R4 senior-dev Mediums (7 items).** From `v1428-r4-senior-dev.md`:
|
||||
- SD-M1 `<ResponsiveSheet>` footer slot rail wiring across 4
|
||||
remaining consumers (HIGH-1 from v1.4.27 backlog)
|
||||
- SD-M2 `<ResponsiveSheet>` viewport-rotation focus/scroll loss
|
||||
- SD-M3 Three remaining raw `<select>` blocks on `ai-section.tsx`
|
||||
- SD-M4 `<ResponsiveSheet>` Dialog branch `bodyClassName` divergence
|
||||
- SD-M5 react-hook-form deps hygiene
|
||||
- SD-M6 Five `<Dialog>` consumers still to migrate to
|
||||
`<ResponsiveSheet>`
|
||||
- SD-M7 Coach launch surface on the empty-state Insights sub-pages
|
||||
|
||||
- **R4 i18n Mediums + Lows (5 items).** From `v1428-r4-i18n.md`:
|
||||
- I-M1 `achievements.badges.*` 118 EN-leak rows
|
||||
- I-M2 `admin.section.*` 99 EN-leak rows
|
||||
- I-M3 `settings.ai.*` 65 EN-leak rows
|
||||
- I-M4 Whitespace-around-placeholder drift-guard tightening
|
||||
- I-L1 Native-pagination connector test on the `pageInfo` shape
|
||||
|
||||
## Carry-forward from v1.4.28 backlog
|
||||
|
||||
- **BK-CF-77 — Six admin tables miss the card-list fallback.** Severity
|
||||
High · MA6-F2 · effort L. Per-table contributor load; mechanical
|
||||
pattern but ~6 tables × non-trivial card design.
|
||||
|
||||
- **BK-UI-Admin — Admin section chrome divergence (11 surfaces).**
|
||||
Eleven admin section cards render `<div className="text-lg
|
||||
font-semibold">` for the card title (not a heading element). Six
|
||||
others use `<h2 className="text-lg font-semibold">` for the same
|
||||
role. Decision call: pull the dual-pattern surfaces into a shared
|
||||
`<SectionCard>` primitive.
|
||||
|
||||
- **BK-UI-SectionCard — 21 candidate sites for the carve-out.** The
|
||||
`<div className="bg-card border-border rounded-xl border p-6">`
|
||||
card pattern appears 21 times across admin + settings.
|
||||
|
||||
- **BK-UI-Loader — Loader spinner vocabulary drift (18 variations).**
|
||||
Pick one canonical vocabulary (the most common is
|
||||
`text-primary h-6 w-6 animate-spin motion-reduce:animate-none`) and
|
||||
sweep.
|
||||
|
||||
- **BK-HIGH-1 — `<ResponsiveSheet>` footer slot rail wiring.** The
|
||||
footer slot is dead code on every consumer except
|
||||
`export-section`. Move inline form footers to
|
||||
`<ResponsiveSheet footer={…}>` across all five primary form call
|
||||
sites. ~80 LOC.
|
||||
|
||||
- **BK-HIGH-2 — `<ResponsiveSheet>` viewport-rotation focus/scroll
|
||||
loss.** The Sheet/Dialog mount swap at the breakpoint boundary
|
||||
drops focus + scroll position on viewport rotation. Pick a rotation
|
||||
strategy: lock-at-mount vs unified Dialog/Sheet root.
|
||||
|
||||
- **BK-Drift Dialog migration — 5 remaining `<Dialog>` consumers.**
|
||||
`phase-config-dialog`, `ResearchModeAcknowledgmentDialog`,
|
||||
`mood-list` row-edit, `measurement-list` row-edit,
|
||||
`target-edit-sheet` should migrate to `<ResponsiveSheet>` for
|
||||
parity with the v1.4.27 primary form flows.
|
||||
|
||||
- **BK-CF-78 — `<DateTimeInput>` rewrite (shadcn DatePicker +
|
||||
TimePicker).** Severity Medium · MA4-F7 · effort L. Introduces a
|
||||
new dependency or component family; out of scope for a polish
|
||||
round.
|
||||
|
||||
- **BK-CF-79 — RHF + Zod migration for `measurement-form` +
|
||||
measurement-list edit.** Severity Medium · MA4-F10 · effort
|
||||
M-bordering-L. Broad scope; touches the API integration shape and
|
||||
therefore the iOS-side native API contract. Document the call
|
||||
before starting the migration so the iOS client can mirror the
|
||||
schema.
|
||||
|
||||
- **BK-CF-80 — Bottom-sheet primitive across all medication
|
||||
entry-points.** Severity Medium · MA5-F13 · effort L.
|
||||
|
||||
- **BK-admin-endpoints — Five orphan endpoints flagged for
|
||||
wire-or-remove.** `/api/admin/ai-settings`,
|
||||
`/api/admin/backup/test`, `/api/admin/status-overview`,
|
||||
`/api/monitoring/glitchtip/test`, `/api/monitoring/umami/test`. Per
|
||||
endpoint: wire to a real consumer or drop both the route and the
|
||||
README mention in the same commit.
|
||||
|
||||
- **BK-i18n-FR/ES/IT/PL drift — 1 664 keys flagged.** The
|
||||
locale-coverage probe surfaced 1 664 keys where at least one of
|
||||
FR/ES/IT/PL still equals the EN value. v1.4.29 chooses between
|
||||
drift-guard tightening or targeted per-namespace sweeps.
|
||||
|
||||
- **BK product-lead vendor-label exemptions.** Carry the
|
||||
`messages/_meta/forbidden-words.md` exemption note formalisation
|
||||
forward.
|
||||
|
||||
## Historical / metadata notes
|
||||
|
||||
- **Two mislabelled commits.** `235e52cb` (subject
|
||||
`refactor(charts): single HealthChartDynamic re-export`) actually
|
||||
carries the `<MobileRailTray>` carve-out; `0e7c97c5` (subject
|
||||
`fix(insights): align briefing empty-state CTA variant`) actually
|
||||
carries the trends-row equal-height contract plus the briefing
|
||||
variant flip. The functional changes shipped — the subject lines
|
||||
are wrong on develop. Both ride into main under the v1.4.28
|
||||
squash commit which carries the canonical CHANGELOG headline and
|
||||
body, so the squash scrubs the misnomer from main's history. Log
|
||||
entry for the v1.4.28 closure record.
|
||||
|
||||
- **Two commit-body residues on develop.** `9a020f21` (commit body
|
||||
contained the maintainer's first name) and `f0e3e055` (commit body
|
||||
contained one instance of forbidden vocabulary while documenting
|
||||
the scrub itself). Both were authored before the round closure
|
||||
cleared on the commit-message linter; both ride into main under
|
||||
the v1.4.28 squash which uses the clean release headline + body.
|
||||
Develop's individual-commit history retains the strings; main
|
||||
does not. Log entry for the v1.4.28 closure record.
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [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).
|
||||
|
||||
## [1.4.28] — 2026-05-16
|
||||
|
||||
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.28",
|
||||
"version": "1.4.28.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+37
-24
@@ -59,59 +59,72 @@ fetch_edition() {
|
||||
|
||||
echo "fetch-geolite2: downloading $edition_id ..." >&2
|
||||
# Use --fail-with-body so curl prints the response on failure; capture
|
||||
# the exit code without aborting the script. Any non-zero exit (401
|
||||
# EULA-pending, 403 throttled, 5xx, network timeout, etc.) flips the
|
||||
# build into the same fallback shape as the missing-key path: write
|
||||
# the `.empty` marker and let the runtime gate surface the issue to
|
||||
# the maintainer.
|
||||
# the exit code without aborting the script. The license key is set
|
||||
# (the no-key path returned earlier with the `.empty` marker), so any
|
||||
# non-zero exit is a real configuration problem — bubble it up so CI
|
||||
# surfaces the gap on the next release rather than letting it ship as
|
||||
# a silent no-op offline tier.
|
||||
local curl_exit=0
|
||||
curl --silent --show-error --fail-with-body --location \
|
||||
--output "$tmp_tarball" \
|
||||
"https://download.maxmind.com/app/geoip_download?edition_id=${edition_id}&license_key=${LICENSE_KEY}&suffix=tar.gz" || curl_exit=$?
|
||||
|
||||
if [[ "$curl_exit" -ne 0 ]]; then
|
||||
echo "fetch-geolite2: $edition_id download failed (curl exit $curl_exit) — falling back to runtime ipwho.is." >&2
|
||||
echo "fetch-geolite2: $edition_id download failed (curl exit $curl_exit) with MAXMIND_LICENSE_KEY set — aborting." >&2
|
||||
echo "fetch-geolite2: 401 means the key is wrong or the EULA is unsigned; 403 means a throttle; 5xx means MaxMind-side. Investigate before retrying." >&2
|
||||
rm -rf "$work_dir"
|
||||
# Reinstate the marker so partial state from a previous fetch does
|
||||
# not look like a healthy populated directory.
|
||||
touch "$OUT_DIR/.empty"
|
||||
return 0
|
||||
return "$curl_exit"
|
||||
fi
|
||||
|
||||
# The tarball ships under a date-stamped top-level directory
|
||||
# (`GeoLite2-City_YYYYMMDD/`). Extract the MMDB into a flat layout
|
||||
# so the Dockerfile COPY uses a stable path.
|
||||
if ! tar -xzf "$tmp_tarball" -C "$work_dir"; then
|
||||
echo "fetch-geolite2: $edition_id tarball extraction failed — falling back to runtime ipwho.is." >&2
|
||||
echo "fetch-geolite2: $edition_id tarball extraction failed with MAXMIND_LICENSE_KEY set — aborting." >&2
|
||||
rm -rf "$work_dir"
|
||||
touch "$OUT_DIR/.empty"
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
local extracted
|
||||
extracted="$(find "$work_dir" -maxdepth 2 -name "${mmdb_basename}" -print -quit 2>/dev/null)"
|
||||
if [[ -z "$extracted" ]]; then
|
||||
echo "fetch-geolite2: expected ${mmdb_basename} inside the ${edition_id} tarball — falling back to runtime ipwho.is." >&2
|
||||
echo "fetch-geolite2: expected ${mmdb_basename} inside the ${edition_id} tarball but it was not found — aborting." >&2
|
||||
rm -rf "$work_dir"
|
||||
touch "$OUT_DIR/.empty"
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
mv "$extracted" "$OUT_DIR/$mmdb_basename"
|
||||
rm -rf "$work_dir"
|
||||
|
||||
if [[ ! -s "$OUT_DIR/$mmdb_basename" ]]; then
|
||||
echo "fetch-geolite2: ${mmdb_basename} landed empty — aborting." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sha
|
||||
sha="$(shasum -a 256 "$OUT_DIR/$mmdb_basename" | awk '{print $1}')"
|
||||
echo "fetch-geolite2: $mmdb_basename SHA256 $sha" >&2
|
||||
}
|
||||
|
||||
# Track whether every edition landed cleanly. If any fetch fell back to
|
||||
# the runtime path, leave the `.empty` marker so the runtime gate +
|
||||
# admin notification fires; otherwise clear it so a healthy populated
|
||||
# directory is not misread as the fallback state.
|
||||
# With the license key present, both editions are required. Any failure
|
||||
# from `fetch_edition` is a real configuration or upstream problem and
|
||||
# must take the build down so the maintainer sees it — the v1.4.27
|
||||
# silent-fallback path is reserved for the deliberate "no key" case
|
||||
# handled at the top of the script.
|
||||
fetch_edition "GeoLite2-City" "GeoLite2-City.mmdb"
|
||||
fetch_edition "GeoLite2-ASN" "GeoLite2-ASN.mmdb"
|
||||
|
||||
if [[ -f "$OUT_DIR/.empty" ]]; then
|
||||
echo "fetch-geolite2: at least one edition fell back; runtime resolver will use ipwho.is." >&2
|
||||
else
|
||||
echo "fetch-geolite2: done. Files in $OUT_DIR." >&2
|
||||
# Belt-and-braces assertion: with the key set the script must leave
|
||||
# both MMDB files behind. If we reach this point with a missing file
|
||||
# something funny happened (filesystem fault, stray rm, ...); fail loud
|
||||
# instead of producing a degraded image silently.
|
||||
missing=()
|
||||
for mmdb in "GeoLite2-City.mmdb" "GeoLite2-ASN.mmdb"; do
|
||||
if [[ ! -s "$OUT_DIR/$mmdb" ]]; then
|
||||
missing+=("$mmdb")
|
||||
fi
|
||||
done
|
||||
if [[ "${#missing[@]}" -gt 0 ]]; then
|
||||
echo "fetch-geolite2: missing or empty after fetch: ${missing[*]} — aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "fetch-geolite2: done. Files in $OUT_DIR." >&2
|
||||
@@ -34,7 +34,7 @@ describe("GET /api/version", () => {
|
||||
it("returns the package.json version", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i);
|
||||
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
|
||||
expect(body.data.license).toBe("AGPL-3.0");
|
||||
expect(body.data.repository).toContain("github.com");
|
||||
expect(body.data.changelog).toContain("CHANGELOG");
|
||||
|
||||
@@ -162,6 +162,28 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
|
||||
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops widget ids the current build no longer knows about", () => {
|
||||
// v1.4.28 retired the `glp1` tile. Users who saved a layout before
|
||||
// then still have the orphan id in `dashboardWidgetsJson`; the PUT
|
||||
// route's Zod enum would reject the entire blob on the next save
|
||||
// round-trip, surfacing the "Speichern fehlgeschlagen" toast.
|
||||
// The resolver filters unknown ids on read so the GET shape is
|
||||
// current-build-safe and the next save round-trips cleanly.
|
||||
const stored = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "glp1", visible: true, tileVisible: true, order: 1 },
|
||||
{ id: "bp", visible: true, tileVisible: true, order: 2 },
|
||||
],
|
||||
};
|
||||
const resolved = resolveDashboardLayout(stored);
|
||||
const ids = resolved.widgets.map((w) => w.id);
|
||||
expect(ids).not.toContain("glp1");
|
||||
expect(ids).toContain("weight");
|
||||
expect(ids).toContain("bp");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -229,13 +229,22 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
return DEFAULT_DASHBOARD_LAYOUT;
|
||||
}
|
||||
|
||||
// Drop widget ids the current build does not know about. v1.4.28
|
||||
// retired the `glp1` tile; any user who saved a layout before then
|
||||
// still has the orphan id in `dashboardWidgetsJson`, and the PUT
|
||||
// route's Zod enum rejects the entire blob on the next save round-
|
||||
// trip. Filtering on read keeps the GET shape current-build-safe
|
||||
// and lets the Settings UI re-PUT a clean array.
|
||||
const knownIds = new Set<string>(DASHBOARD_WIDGET_IDS);
|
||||
const filtered = candidate.widgets.filter((w) => knownIds.has(w.id));
|
||||
|
||||
// Merge with defaults so new widgets introduced in later versions show up
|
||||
// automatically (invisible by default, users opt-in).
|
||||
const savedIds = new Set(candidate.widgets.map((w) => w.id));
|
||||
const savedIds = new Set(filtered.map((w) => w.id));
|
||||
const missing = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
|
||||
(w) => !savedIds.has(w.id),
|
||||
);
|
||||
const maxOrder = Math.max(0, ...candidate.widgets.map((w) => w.order));
|
||||
const maxOrder = Math.max(0, ...filtered.map((w) => w.order));
|
||||
const appended = missing.map((w, i) => ({
|
||||
...w,
|
||||
visible: false, // default-invisible on auto-upgrade
|
||||
@@ -247,7 +256,7 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
// never had the field (the v1.4.15 schema upgrade). Users who saved
|
||||
// a layout before this release see no behavioural change until they
|
||||
// explicitly toggle the new strip switch.
|
||||
const normalized = candidate.widgets.map((w) => ({
|
||||
const normalized = filtered.map((w) => ({
|
||||
...w,
|
||||
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user