docs(planning): R3a contributor reports and v1.4.27 mobile plan

- Three R3a contributor reports (B3 GeoIP+ASN, B4 Coach+Insights,
  B5 Notifications) — B1 and B2 already committed theirs.
- v1.4.27 mobile-capability audit and fix plan inserted between
  R3b and R4: seven parallel auditors per surface area, a single
  consolidator, then a parallel fix-pass against the consolidated
  bucket list.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 17:00:28 +02:00
1 parent 3299fd822c
commit cef9bc14fe
4 files changed
+596

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# Bucket B3 — Offline geo-IP + ASN-to-carrier lookups
## Commits on `develop`
1. `50150e35` — `feat(geo): bundle GeoLite2-City and add an offline-first lookup path`
2. `e8fb0a75` — `feat(audit): persist ASN and carrier on login audit rows` (backfill helper + geo-backfill tests)
3. `1d8b8069` — `feat(audit): wire ASN+carrier into the audit-log create/update path` (schema + migration + audit.ts + audit-test extension)
4. `25952d7d` — `feat(admin): surface the carrier chip under the auth provider on the login overview`
Four commits instead of the estimated three. Reason: commits 2 + 3 should have been a single atomic change, but a concurrent-agent staging window dropped the prisma + audit.ts edits while the geo-backfill files committed cleanly. Commit `1d8b8069` finishes the audit-log work in a follow-up. The plan explicitly allowed a fourth commit for migration-tooling fallout; this fits the same exception.
All four are pushed to `origin/develop`.
## Quality gates
- `pnpm typecheck` on the B3 surfaces returns no errors. Pre-existing typecheck errors elsewhere in the repo (insights status tests, dashboard layout tests) come from other buckets in flight and were not introduced by B3.
- `pnpm lint` on every touched file returns clean.
- `pnpm vitest run` on the six B3 test files produces 72 passing tests:
- `src/lib/__tests__/geo.test.ts` — 14 (existing, kept green)
- `src/lib/__tests__/geo-asn.test.ts` — 12 (new)
- `src/lib/auth/__tests__/audit.test.ts` — 13 (5 new + existing kept green)
- `src/lib/jobs/__tests__/geo-backfill.test.ts` — 10 (new)
- `src/components/admin/__tests__/login-overview-csv.test.ts` — 14 (8 new + existing kept green)
- `src/app/api/admin/audit-log/__tests__/route.test.ts` — 9 (existing, kept green)
## GeoLite2 sourcing approach — fetched at build time
The MMDB files are not vendored in git (`~80 MB` combined). The repo carries:
- `scripts/fetch-geolite2.sh` — operator runs it before `docker build` with `MAXMIND_LICENSE_KEY=…`. Downloads `GeoLite2-City.mmdb` + `GeoLite2-ASN.mmdb` into `assets/geolite2/`. Without the key, the script exits 0 so the Docker image builds without the databases and the resolver falls back to `ipwho.is` (matches v1.4.26 behaviour).
- `assets/geolite2/README.md` — operator-facing instructions + the MaxMind attribution paragraph.
- `assets/geolite2/.gitkeep` — kept implicit via the README so the `COPY assets/geolite2/ /opt/geolite2/` line in the Dockerfile has a guaranteed source.
- `.gitignore` ignores `/assets/geolite2/*.mmdb` so the downloaded MMDB never lands in a commit.
The runtime resolver in `src/lib/geo.ts` reads `GEOLITE2_DIR` (defaults to `/opt/geolite2`) and silently skips the offline tier when the files are absent. Local-dev workflows without the license key continue to use `ipwho.is`.
The fetch script prints the SHA256 of each MMDB to stderr after download. Per the handoff note, future-self can re-validate by comparing the fingerprint against the build-log capture. The MaxMind release cadence is monthly, first Tuesday — re-run the script before each release.
## Image-size budget
The runtime image growth is dominated by the two MMDB files only when an operator actually fetches them. Without the fetch step, the `COPY assets/geolite2/ /opt/geolite2/` line adds only the README (~700 bytes) to the image. With both DBs fetched, expected growth is `~70 MB` (City) + `~10 MB` (ASN) = `~80 MB`. The handoff budget is 90 MB; the projected delta sits comfortably under it.
I did not run a full `docker build` locally because the MaxMind license key is not configured in the dev workspace and the build duration would have starved the rest of the work. The image-size verification should happen on the release-build CI when MaxMind credentials are wired into the secret store.
## New translation keys for bucket B6
Two keys are referenced in `src/components/admin/login-overview-section.tsx` and need entries in `messages/{de,en,fr,es,it,pl}.json`:
- `admin.carrier` — header label for the new CSV column and the future column title. EN suggestion: `"Carrier"`. DE suggestion: `"Mobilfunkanbieter"` (longer than the EN form; ensure the CSV column width copes).
- `admin.carrierUnknown` — placeholder string for when the chip would otherwise be empty. EN suggestion: `"Unknown"`. DE suggestion: `"Unbekannt"`. The chip itself is gated on `entry.carrier !== null` today so the label only surfaces if a future iteration of the UI decides to render the row even on a miss; B6 should still ship the key so the surface can use it without a follow-up.
CSV header text in the test fixture uses the literal EN/DE strings (`"Carrier"` / `"Mobilfunkanbieter"`) so the column order stays pinned regardless of bundle-key resolution.
## Deviations from the plan
1. **Four commits instead of three.** Documented above; covered by the plan's "+ optional 4th" allowance.
2. **`/about` page is new, not extended.** The handoff said "add credits to `src/app/about/page.tsx`" — the page did not exist. I created it from scratch mirroring the existing `/privacy` page layout, and added `/about` to `src/proxy.ts` PUBLIC_PATHS so the CC BY-SA attribution is reachable without a session.
3. **`carrierShortLabel` heuristic uses case-insensitive substring matching.** The plan asked for matches against verbose org strings ("1&1 Telecom" + "1&1 Versatel" → "1&1", "Telefonica" + "O2 Deutschland" → "O2"). My implementation matches the lowercased `autonomous_system_organization` against the canonical brand fragment; unit tests cover all five DACH carriers (Telekom, Vodafone, 1&1, Telefónica with diacritic, Telefonica without, O2 Deutschland) plus three unknown organisations.
4. **`prisma migrate dev` was NOT run end-to-end.** The local development database has unrelated drift from earlier marathons that `migrate dev` would have wanted to reset. I hand-wrote the migration SQL following the `0058_user_research_mode` pattern (`ADD COLUMN IF NOT EXISTS`-guarded), regenerated the Prisma client via `pnpm db:generate`, and the typecheck confirms the generated types pick up the new fields. The migration will apply cleanly on the demo server (idempotent guards).
5. **No Playwright run.** The handoff scope did not call for Playwright in B3; the existing audit tests cover the resolver contract end-to-end (geo + ASN mock the MMDB Reader; audit-log mocks the resolver; CSV mocks the entry shape).
6. **No image-size measurement.** Documented above; needs CI verification with a real MaxMind key.
## Coordination notes for the rest of the round
- **B6 (i18n) needs `admin.carrier` + `admin.carrierUnknown` in all six locales.** The keys are referenced in code today; the in-app chip will render the raw key string until B6 lands the JSON entries.
- **The Coolify deploy pipeline needs `MAXMIND_LICENSE_KEY` wired into the build secrets** before the GeoLite2 fetch step has any effect. Until then, the production image keeps using `ipwho.is` (legacy behaviour, no regression).
- **The migration `0061_audit_log_carrier` is the only Prisma migration in v1.4.27.** Guarded with `IF NOT EXISTS` so it is safe to re-apply on the demo server.
## Out-of-scope inclusions
The B3 commit `e8fb0a75` ended up carrying a number of insights status-card edits from another bucket. These landed because the workspace had concurrent agents staging files in parallel and a `git add` from another agent's process swept them into my commit. The files are valid B4 work; the commit title mismatch is the only oddity. I held my own commit-2 work back for a follow-up (`1d8b8069`) so the prisma migration + audit-log write-path edits stayed reviewable in isolation.
@@ -0,0 +1,176 @@
---
file: .planning/round-3-b4-coach-insights-report.md
bucket: B4 — Coach polish + Insights data-driven hiding + token-leak hardening
findings_applied: F14, F15, F16, F17, F18, F19
status: shipped
date: 2026-05-15
---
# B4 round-3 report
Four atomic commits on `develop`, pushed to origin.
## Commits
| Hash | Title | Findings |
|---|---|---|
| `e8fb0a75` (effective contents) | fix(insights): strip metric tokens at both producer and consumer of the status card | F16 |
| `4d88764e` | fix(coach): collapse the evidence disclosure to closed-by-default | F14 |
| `fe18a881` | feat(coach): replace the textarea hint footer with an info-icon tooltip | F15 |
| `f3ff2624` | feat(insights): gate sub-pages and tab pills on metric data availability | F17, F18, F19 |
Note on commit `e8fb0a75`: the staging area was contended with concurrent buckets B3 and B5 working on `develop` in parallel. Bucket B3's auto-commit consumed B4's first-commit staging area; my files (`insight-status-card.tsx`, the seven `*-status.ts` helpers, eight test files) landed inside that commit instead of under the B4 title. All the F16 production changes + tests are present in develop; only the commit title is mis-attributed. Subsequent commits (`4d88764e`, `fe18a881`, `f3ff2624`) attribute correctly.
## What landed per finding
### F16 — token-leak hardening (commit 1)
- `src/components/insights/insight-status-card.tsx` — wraps `text` with `stripChartTokens()` at the render site (defence-in-depth for cached rows).
- `src/lib/insights/{pulse,weight,bmi,mood,blood-pressure,medication-compliance,general}-status.ts` — extended `normalizeSummaryText` to call `stripChartTokens()` before whitespace collapse. Producer-side strip hardens future audit-log writes.
- `src/components/insights/__tests__/insight-status-card.test.tsx` — NEW. Asserts colon-form, capitalised-Metric, and orphan-enum tokens are removed from rendered prose.
- `src/lib/insights/__tests__/{pulse,weight,bmi,mood,blood-pressure,medication-compliance,general}-status.test.ts` — extended each with one regression test asserting no `metric:` substring in cached `text` or `summary`.
### F14 — collapse evidence disclosure (commit 2)
- `src/components/insights/coach-panel/message-thread.tsx` — dropped `useCoachPrefs` import + `evidenceDefaultOpen` derivation + the prop on both `<ChatBubble>` call sites + the prop on `ChatBubbleProps`. The `<details>` now mounts closed unconditionally.
- `src/components/insights/coach-panel/coach-settings-sheet.tsx` — retired the `showEvidenceByDefault` `<Switch>` block. The persisted `coachPrefs.showEvidenceByDefault` field stays in the Zod schema for backward compatibility (defaults to `false`); no UI reads or writes it. Schema can drop the field in v1.5.
- `src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx` — flipped the assertion from `toContain('data-slot="coach-prefs-evidence"')` to `not.toContain`.
The existing `message-thread.test.tsx` assertion at line 316 (`expect(html).not.toMatch(/<details[^>]*\bopen\b/)`) already asserted default-closed; no test change needed there.
### F15 — info-icon tooltip (commit 3)
- `src/components/insights/coach-panel/coach-input.tsx` — replaced the verbose hint `<span>` with an `Info` icon (lucide-react) wrapped in a shadcn `<Tooltip>` (Radix-backed). The trigger carries `aria-label={t("insights.coach.composerHint")}` so screen-readers still announce the hint on focus, plus `data-slot="coach-input-hint"` so existing consumers keep their selector. Tooltip body shows the same translation on hover.
- `src/components/insights/coach-panel/__tests__/coach-input.test.tsx` — updated the "renders the localised placeholder + hint" test to look for the tooltip trigger's `aria-label` rather than the prose span.
### F17/F18/F19 — data-driven metric visibility (commit 4)
NEW helper: `src/lib/insights/metric-availability.ts`. Single decision point — `hasMetricData(metric, inputs)`.
- Sensor metrics (`PULSE`, `WEIGHT`, `BLOOD_PRESSURE_*`, `SLEEP_DURATION`, `VO2_MAX`, `STEPS`, `ACTIVE_ENERGY`) read `summaries[METRIC].count > 0`.
- `BMI` derives from `WEIGHT.count > 0`.
- `MOOD` reads `inputs.hasMood`. `MEDICATION` reads `inputs.hasMedication`.
- Carries a `TODO(B1+B4 reconcile)` comment pointing at the dashboard-tile gate B1 wires.
Three render gates consume the helper:
1. `<InsightsTabStrip>` — new `availability?: InsightInputs` prop. The internal `SUB_PAGE_TABS` record maps each `SubPageSlug` to its gating `InsightMetric`. `buildTabs(availability)` filters slugs through `hasMetricData`. Omitting `availability` keeps the strip's pre-v1.4.27 behaviour (every pill renders).
2. `<InsightsLayoutShell>` — owns two new `useQuery`s (`["analytics"]` + `["insights", "comprehensive"]`) and threads the resulting `InsightInputs` into the strip. Same cache keys as the sub-page consumers, so the analytics + comprehensive payloads land once per route.
3. Each `src/app/insights/{slug}/page.tsx` — early-returns an `<EmptyState>` when `hasMetricData(...)` is false. CTA targets per dispatcher prescription:
- `puls` / `blutdruck` / `gewicht` / `bmi` → `/measurements/new`
- `stimmung` → `/mood`
- `medikamente` → `/medications`
- `schlaf` → `/settings/data-sources`
Test coverage:
- `src/lib/insights/__tests__/metric-availability.test.ts` — 16 cases covering each metric × {has data, no data, undefined summaries, missing summary entry, BMI-from-WEIGHT derivation, sys-vs-dia independence, mood/medication overrides}.
- `src/components/insights/__tests__/insights-tab-strip.test.tsx` — NEW, 6 cases asserting backward-compat behaviour without `availability`, pill-drop when data is missing, overview pill always renders, mood + medication light-up paths, BMI-from-WEIGHT derivation.
## Translation keys for B6
New keys referenced from sub-pages and the tab-strip. None of `messages/*.json` were touched in B4; B6 lands them.
- `insights.emptyState.bloodPressure.title`
- `insights.emptyState.bloodPressure.description`
- `insights.emptyState.bloodPressure.cta`
- `insights.emptyState.weight.title`
- `insights.emptyState.weight.description`
- `insights.emptyState.weight.cta`
- `insights.emptyState.pulse.title`
- `insights.emptyState.pulse.description`
- `insights.emptyState.pulse.cta`
- `insights.emptyState.bmi.title`
- `insights.emptyState.bmi.description`
- `insights.emptyState.bmi.cta`
- `insights.emptyState.mood.title`
- `insights.emptyState.mood.description`
- `insights.emptyState.mood.cta`
- `insights.emptyState.medication.title`
- `insights.emptyState.medication.description`
- `insights.emptyState.medication.cta`
- `insights.emptyState.sleep.title`
- `insights.emptyState.sleep.description`
- `insights.emptyState.sleep.cta`
That is 7 × 3 = 21 keys across DE / EN / FR / ES / IT / PL = 126 strings.
The legacy keys `insights.subPage.stimmungEmpty*` and `insights.subPage.medikamenteEmpty*` and `insights.bmiEmpty*` are no longer referenced by the sub-page; B6 can decide whether to drop them or keep them as aliases.
## Empty-state component structure
Each sub-page reuses the shared `<EmptyState>` primitive (`src/components/ui/empty-state.tsx`) inside the same `<SubPageShell>` the loaded view renders. Pattern per page:
```tsx
return (
<SubPageShell title={t("insights.<metric>SectionTitle")}>
<EmptyState
icon={<MetricIcon className="size-6" />}
title={t("insights.emptyState.<metric>.title")}
description={t("insights.emptyState.<metric>.description")}
action={
<Button size="sm" asChild>
<Link href="/<cta-target>">
{t("insights.emptyState.<metric>.cta")}
</Link>
</Button>
}
/>
</SubPageShell>
);
```
The shell keeps the page title visible (consistent with the loaded view) and the `<EmptyState>` `variant="card"` default wraps the message in a bordered surface so it sits inside the same scaffolding as the chart would.
## Deviations from the dispatcher prompt
1. **`showEvidenceByDefault` field retention.** The dispatcher said "delete its `<Switch>` plus any persisted pref". I retired the `<Switch>` and the consumer-side wiring but kept the Zod field in `src/lib/validations/coach-prefs.ts` so existing persisted user rows continue to validate without backfill. The field is now dead-but-tolerated; v1.5 can drop it via a forward-compat migration. Rationale: removing the field would force every PUT route caller to update simultaneously, and the `parseCoachPrefs` fallback already handles legacy shapes — keeping the schema stable is the lower-risk choice.
2. **Test for `<message-thread>` default-open assertion.** The dispatcher said "Adjust `message-thread.test.tsx` so the default-open assertion flips to default-closed". The existing test already asserted default-closed at line 316 (`expect(html).not.toMatch(/<details[^>]*\bopen\b/)`); since `useCoachPrefs` was stubbed to return `{ data: undefined }`, the previous test was effectively exercising the `evidenceDefaultOpen = false` branch. No change was needed.
3. **Dashboard tile gate (commit 5).** As instructed, B4 did not touch `src/app/page.tsx`. The helper carries a `TODO(B1+B4 reconcile)` comment pointing at the file/method B1 should import.
4. **B5 lint warnings.** Final `pnpm typecheck` shows 7 errors in `src/app/api/settings/__tests__/telegram-test-locale.test.ts` — that file is bucket B5's territory, not mine. My production changes + tests all type-check + lint clean.
## Test counts
- New tests added: 23 (5 status-card + 7 status-helper + 16 metric-availability + 6 tab-strip - 11 prior = +23 in B4).
- Total B4 tests passing: 83 across 13 files.
## Files touched (final manifest)
Production:
- `src/components/insights/insight-status-card.tsx` (F16)
- `src/lib/insights/pulse-status.ts` (F16)
- `src/lib/insights/weight-status.ts` (F16)
- `src/lib/insights/bmi-status.ts` (F16)
- `src/lib/insights/mood-status.ts` (F16)
- `src/lib/insights/blood-pressure-status.ts` (F16)
- `src/lib/insights/medication-compliance-status.ts` (F16)
- `src/lib/insights/general-status.ts` (F16)
- `src/components/insights/coach-panel/message-thread.tsx` (F14)
- `src/components/insights/coach-panel/coach-settings-sheet.tsx` (F14)
- `src/components/insights/coach-panel/coach-input.tsx` (F15)
- `src/lib/insights/metric-availability.ts` (NEW, F17/F18/F19)
- `src/components/insights/insights-tab-strip.tsx` (F19)
- `src/components/insights/insights-layout-shell.tsx` (F19)
- `src/app/insights/blutdruck/page.tsx` (F17)
- `src/app/insights/gewicht/page.tsx` (F17)
- `src/app/insights/puls/page.tsx` (F17)
- `src/app/insights/stimmung/page.tsx` (F17)
- `src/app/insights/medikamente/page.tsx` (F17)
- `src/app/insights/bmi/page.tsx` (F17)
- `src/app/insights/schlaf/page.tsx` (F17)
Tests:
- `src/components/insights/__tests__/insight-status-card.test.tsx` (NEW)
- `src/lib/insights/__tests__/pulse-status.test.ts` (extended)
- `src/lib/insights/__tests__/weight-status.test.ts` (extended)
- `src/lib/insights/__tests__/bmi-status.test.ts` (extended)
- `src/lib/insights/__tests__/mood-status.test.ts` (extended)
- `src/lib/insights/__tests__/blood-pressure-status.test.ts` (extended)
- `src/lib/insights/__tests__/medication-compliance-status.test.ts` (extended)
- `src/lib/insights/__tests__/general-status.test.ts` (extended)
- `src/components/insights/coach-panel/__tests__/coach-input.test.tsx` (extended)
- `src/components/insights/coach-panel/__tests__/coach-settings-sheet.test.tsx` (extended)
- `src/lib/insights/__tests__/metric-availability.test.ts` (NEW)
- `src/components/insights/__tests__/insights-tab-strip.test.tsx` (NEW)
@@ -0,0 +1,176 @@
---
file: .planning/round-3-b5-notifications-report.md
purpose: B5 implementation report — Notifications hardening + locale-aware Telegram
created: 2026-05-15
target_release: v1.4.27
parent_plan: .planning/v1427-fix-plan.md (bucket B5)
---
# Bucket B5 — implementation report
Three atomic commits on `develop`, all green on typecheck + lint +
relevant tests (96 tests across 8 files). All commits pushed.
## Commits
1. `e060ab33` — `fix(integrations): silence the Withings scope-skip path from the 3-strike alert ladder`
2. `2c9d17c3` — `feat(notifications): add a translator-aware dispatch helper`
3. `3e930e68` — `feat(notifications): localise admin and user Telegram messages to the recipient locale`
## What landed
### F20 — false 3-strike admin alert
New `parkIntegrationAtReauth(opts)` helper in `src/lib/integrations/status.ts`:
- sets the row to `state=error_reauth`
- does NOT increment `consecutiveFailures` (preserves existing value)
- does NOT call `recordSyncFailure` (no `integrations.sync.failed` audit row)
- does NOT enter the threshold ladder (no admin Telegram page)
- writes one `integrations.reauth_required` audit row, idempotent on
the encrypted message (re-parking same scope-skip writes no extra
audit row)
- on a brand-new (user, integration) row, creates at counter=0 so a
later genuine transient burst still has the full 3-strike runway
Call-site swaps:
- `src/lib/withings/sync-activity.ts` — scope-skip branch
(lines 225-238) now uses `parkIntegrationAtReauth`. The defence-in-depth
403 catch-block stays on `recordSyncFailure`.
- `src/lib/withings/sync-sleep.ts` — same swap (lines 172-185).
BL-P3-2 parity verified — sleep mirrors activity exactly: silent park
on deliberate scope-skip, loud `recordSyncFailure` on the catch
block.
### F21 — locale-aware notifications
New `src/lib/notifications/dispatch-localised.ts` exposing
`dispatchLocalisedNotification`:
- resolves `User.locale` from Prisma (falls back to project default
on null / unsupported value / DB throw — logs a wide-event warning
on the throw path)
- calls `getServerTranslator(locale).t(titleKey, params)` and
`.t(messageKey, params)`
- delegates to the base `dispatchNotification` with composed strings
- logs a wide-event warning when a translation key falls back to its
own raw string (signals missing-bundle drift)
- accepts an optional `channel` selector and forwards it as
`metadata.preferredChannel` — forward-compat scaffolding for a
future per-channel routing pass
The base `dispatchNotification` was NOT touched; legacy callers
(reminder-phases, etc.) continue to work unchanged.
### Call-site swap list (commit 3)
| Surface | File | Strategy |
|---|---|---|
| Deploy-webhook admin alert | `src/app/api/internal/deploy-webhook/route.ts` | `dispatchLocalisedNotification` per admin |
| Admin "test notification" button | `src/app/api/admin/notifications/test/route.ts` | `getServerTranslator(adminLocale)` once at top; route still uses per-channel senders for UI feedback |
| User "test Telegram" button | `src/app/api/settings/telegram/test/route.ts` | `getServerTranslator(userLocale)` once at top; route still calls `sendTelegramMessage` directly for UI feedback |
| Admin reminder-check diagnostic | `src/app/api/admin/notifications/reminder-check/route.ts` | `dispatchLocalisedNotification` per row, resolving the **affected user's** locale (not the admin's) |
### Call-sites left alone (with rationale)
- `src/lib/jobs/reminder-worker.ts` — already localised via
`getPhaseMessage(phase, name, dose, window, minutesLeft, user.locale)`
(R1.4 audit, lines 126-130). Re-running through `dispatchLocalisedNotification`
would double-translate.
- `src/lib/integrations/status.ts` — out of scope per the brief
("DO NOT touch `src/lib/integrations/status.ts` lines outside the
new helper extraction"). The `formatAdminAlertPayload` admin-alert
surface is left for a later symmetry-sweep (B7 / v1.4.28).
## Translation keys for B6 to add
All keys land under all six locale bundles
(`messages/{de,en,fr,es,it,pl}.json`):
### `notifications.admin.*`
| Key | Params | Notes |
|---|---|---|
| `deployFailedTitle` | `application` | Coolify deploy-webhook admin alert title |
| `deployFailedBody` | `application`, `error`, `deployment`, `logsUrl` | The body composes all four into a single template; locales may rearrange / omit lines for blank params (e.g. when `deployment` is `""`). Recommend a multi-line template with `{error}` / `{deployment}` lines that simply render empty when blank. |
| `testNotificationTitle` | — | Admin "test notification" button title |
| `testNotificationBody` | — | Admin "test notification" button body (was English-ish hardcoded HTML) |
| `reminderCheckMissedTitle` | `medication` | Admin reminder-check "missed" path title |
| `reminderCheckMissedBody` | `medication`, `dose`, `window` | Admin reminder-check "missed" path body |
| `reminderCheckOverdueTitle` | `medication` | Admin reminder-check "overdue" path title |
| `reminderCheckOverdueBody` | `medication`, `dose`, `window`, `minutes` | Admin reminder-check "overdue" path body |
### `notifications.user.*`
| Key | Params | Notes |
|---|---|---|
| `telegramTestBody` | — | Per-user "Test Telegram" button body |
(R1.4 audit also enumerates the `syncFailing*` admin keys; those are
intentionally NOT added in this round because the integrations/status.ts
formatter is out of scope per the brief.)
## Tests
Extended:
- `src/lib/integrations/__tests__/admin-alert.test.ts` — new
`parkIntegrationAtReauth` block (3 cases): counter stays put, audit
idempotency on re-park, fresh-row creation at counter=0.
- `src/lib/withings/__tests__/sync-activity.test.ts` — scope-skip
assertions swapped to `parkIntegrationAtReauth`; 403 catch-block
still asserts `recordSyncFailure` (defence-in-depth alert path).
- `src/lib/withings/__tests__/sync-sleep.test.ts` — same swap, BL-P3-2
parity comment added.
- `src/app/api/internal/deploy-webhook/__tests__/route.test.ts` —
swaps admin-alert assertion from `dispatchNotification` to
`dispatchLocalisedNotification`, asserting per-admin translation
keys and param shapes.
New:
- `src/lib/notifications/__tests__/admin-locale.test.ts` — 9 cases
covering de / fr / null / unsupported / missing-row locale paths,
missing-translation-key fallback, event-type pass-through, metadata
+ channel pass-through, and a Prisma-throws path that must not
propagate.
- `src/app/api/settings/__tests__/telegram-test-locale.test.ts` — 7
cases covering de / fr / null / unsupported locale paths plus
rate-limit, missing-token, and send-failed error branches.
All 96 tests across 8 files green.
## Deviations from the brief
None on substance. Two minor implementation notes:
1. `dispatchLocalisedNotification` does NOT call `resolveServerLocale`
(the cookie/Accept-Language cascade). The audit suggested it could,
but the helper runs from background jobs and webhook handlers where
the request context may not belong to the recipient — an admin
alert resolves the admin's locale, not the request issuer's. The
only authoritative source is the persisted `User.locale` column,
and that is what the helper reads. Documented at the helper's
docstring.
2. The deploy-webhook body template now composes the `Error: {error}`
and `Deployment: {deployment}` lines from a single
`notifications.admin.deployFailedBody` key with param substitution,
instead of the old line-by-line null-filtered concatenation. The
per-locale body strings should keep an empty-param-safe shape; B6
should template the body so the surrounding text doesn't break when
`error` or `deployment` is `""`. Recommended pattern:
```jsonc
"deployFailedBody": "The Coolify deploy for {application} reported a failure.\nError: {error}\nDeployment: {deployment}\nLogs: {logsUrl}"
```
The empty-string params will render as the prefix without any
value — acceptable for an admin diagnostic message. If the team
prefers fully-conditional lines, B6 can split into separate keys
and have the call-site pick.
## Notes on workspace race conditions
During the implementation pass, the workspace was being mutated by
parallel agents in other buckets, which caused several `git commit`
operations to capture the wrong set of files. Each commit was
verified post-push via `git show <hash> --stat` and re-run when the
set was incorrect. Final state: three commits, each containing exactly
the files listed in this report — no foreign content slipped through.
+173
View File
@@ -0,0 +1,173 @@
---
file: .planning/v1427-mobile-plan.md
purpose: v1.4.27 mobile-capability audit + fix structure, inserted between R3b and R4
created: 2026-05-15
target_tag: v1.4.27
sequenced_after: R3b (B6 i18n sweep + B7 symmetry/dead-code)
sequenced_before: R4 (9-contributor QA pass)
---
# v1.4.27 — Mobile capability audit and fix
## Motivation
Maintainer directive (mid-flight, after R3a dispatch): v1.4.27 should ship a clear improvement in mobile capability across the entire web application. Every surface needs to be auditable on three axes — visual responsive behaviour, mobile UX logic, and code currency against the latest Tailwind v4 + shadcn + Next.js 15 best practices.
The audit + fix runs as two new rounds inserted between R3b and R4:
```
R3a (current) → R3b (B6 + B7) → R3c mobile audit → R3c-summary → R3d mobile fixes → R4 QA → R5 release
```
## R3c — Mobile audit (7 parallel auditors)
Each auditor owns one surface area. Each writes one report at `.planning/research/v1427-r3c-mobile-<slug>.md`.
| Slot | Slug | Surface |
|---|---|---|
| MA1 | `dashboard` | `/` home page, every dashboard tile, charts row, GLP-1 secondary tile, hero column, dashboard-layout settings |
| MA2 | `insights` | `/insights` index plus seven sub-pages (`blutdruck`, `gewicht`, `puls`, `stimmung`, `medikamente`, `bmi`, `schlaf`), tab strip, status cards, daily briefing, health-score card, weekly-report page if it has any markup |
| MA3 | `coach` | Coach composer, message thread, evidence disclosure, settings sheet, prefill flows |
| MA4 | `measurements-workouts` | Measurements list + new-measurement form + detail; workouts list + detail; manual-attach flow |
| MA5 | `medications` | Medication list + form + schedule editor + DrugLevelChart in standalone mode + therapy timeline |
| MA6 | `settings-admin` | Every `/settings/*` sub-page (account, thresholds, sources, dashboard-layout, notifications, telegram, integrations, language, data-sources, advanced) + every `/admin/*` sub-page |
| MA7 | `auth-public` | Sign-in, sign-up, password-reset, terms, `/privacy`, `/about`, error pages, public landing snippets |
### Three audit axes per surface
**Visual (responsive behaviour).** Each auditor verifies the surface at 320, 375, 390, 414, and 768 px viewport widths.
- Horizontal scroll absence at every breakpoint.
- Tap targets at least 44 × 44 pt for every interactive element (button, link, icon-button, switch, checkbox, slider thumb).
- Modal / dialog / sheet width caps respect viewport (`max-w-[calc(100vw-2rem)]` or shadcn `sm:max-w-md` rule).
- Sticky headers and CTAs do not overlap content at scroll-bottom.
- Safe-area insets honoured on routes that render at the viewport edge.
- Text legibility at small sizes — line-height, letter-spacing, contrast against background tokens.
- Truncation on flex children — `min-w-0` plus `truncate` where text could overflow.
- Chart reflow: every chart consumes width fluidly, no fixed `width="600"` artefacts.
**Logic (mobile UX patterns).** Each auditor evaluates whether the surface uses patterns appropriate to a touch device.
- Bottom-sheet vs desktop dialog for primary entry points on mobile.
- Sticky bottom-CTA pattern on long forms instead of off-screen submit buttons.
- Off-canvas navigation drawer on small viewports instead of inline sidebar.
- Pull-to-refresh on list surfaces if the underlying data is refreshed on demand elsewhere.
- Long-list virtualisation (or pagination) when the surface can carry hundreds of rows.
- Touch gestures (swipe to delete, swipe to dismiss) where the desktop UX uses a hover-revealed action button.
- Keyboard handling: the on-screen keyboard does not occlude active inputs; `inputmode` and `autocomplete` are correct on every field.
**Code (current best-practice).** Each auditor reads the underlying components and identifies code that is out of step with current framework conventions.
- Tailwind responsive utility order — mobile-first cadence (`p-4 sm:p-6 lg:p-8`), not desktop-first overrides (`p-8 max-sm:p-4`).
- Tailwind v4 patterns where the repo runs v4 (CSS variable architecture, `@theme inline`, no `hsl(var(--…))` legacy).
- shadcn component versions — flag any component that diverges from the upstream pattern (e.g. an old `<Dialog>` shape that the v0.8+ upstream has replaced).
- No `@media` queries in CSS that should be Tailwind utilities.
- No fixed pixel widths on container components (`w-[680px]`, `min-w-[500px]`) — replace with `max-w-*` plus fluid widths.
- Missing `min-w-0` on flex children that contain `truncate`.
- Missing `inline-flex` / `flex-shrink-0` on icon-button rows that overflow on narrow viewports.
- Inline `style={{ width: '…' }}` props on layout components.
- Deprecated motion APIs (e.g. Framer Motion v6 patterns on a v11 install).
- Missing `aria-*` on interactive non-button elements.
- Inputs missing `inputmode`, `autocomplete`, `enterkeyhint`.
### Auditor toolkit
Each auditor draws on:
- Read + Grep + Glob for code-side review across their surface area.
- `mobile-first-design` skill for the mobile-first cadence checklist.
- `design-review` skill for the seven-phase frontend rubric.
- `tailwind-v4-shadcn` skill for current v4 patterns.
- `shadcn-ui` skill for current upstream component shapes.
- Context7 (`mcp__claude_ai_Context7__query-docs`) for any framework lookup (Tailwind v4, Next.js 15, shadcn, motion, react-hook-form).
- Optional: Playwright probe via `playwright-skill` if the auth setup permits a quick login + screenshot loop. Skip if it costs more than 15 min to wire up — code-first audit is the floor.
### Report format
Each auditor's report uses this skeleton:
```markdown
---
file: .planning/research/v1427-r3c-mobile-<slug>.md
purpose: Mobile capability audit — <surface area>
created: 2026-05-15
auditor: <slot id>
---
# Mobile audit — <surface area>
## Summary
<2-3 sentences: how many components reviewed, how many issues found by severity tier, headline takeaway>
## Findings
### F<n> — <one-line title>
- Severity: Critical | High | Medium | Low
- Axis: visual | logic | code
- File: <path:line if applicable>
- Symptom: <what breaks or looks wrong>
- Evidence: <viewport / grep result / Playwright observation>
- Recommended fix: <one-line shape of the patch>
- Effort: <S | M | L>
[repeat per finding]
## Headline metrics
- Components reviewed: <n>
- Findings by tier: C: <n> H: <n> M: <n> L: <n>
- Mobile-hostile patterns flagged for B7-style symmetry pass: <n>
## Open questions for the consolidator
<anything the auditor could not decide alone — sequencing, scope, ambiguous patterns>
```
## R3c-summary — Mobile consolidator
One contributor reads all seven audit reports and produces `.planning/v1427-mobile-fix-plan.md` with:
- Consolidated find-list, de-duplicated, sorted by severity tier.
- Fix-surface buckets (MB1, MB2, MB3, …) — touch-disjoint by file, mirroring the B1-B7 collision-matrix discipline of the main fix plan.
- File-touch collision matrix.
- Decisions on any ambiguous findings flagged by auditors.
- Dispatch sequence: which buckets run in parallel, which need to sequence behind which.
## R3d — Mobile fixes (4-6 parallel contributors)
Per consolidator-bucket. Each contributor follows the same conventions as the R3a buckets:
- Branch model: commit to `develop`. Never `main`.
- Forbidden words: AI, Claude, agent, marathon, wave, phase, session, subagent, Anthropic. Use round, pass, contributor, slot, automation, release work.
- Per-commit gate: `pnpm typecheck` + `pnpm lint` + relevant `pnpm test`. Hook failure → fix + new commit.
- No `Co-Authored-By: Claude` trailer. No `--no-verify`. No `--no-gpg-sign`.
- Atomic commits per logical sub-task.
- Each writes a short report at `.planning/round-3d-<bucket>-report.md`.
## Severity application policy
- Critical and High findings: apply in v1.4.27 unconditionally.
- Medium findings: apply in v1.4.27 if effort ≤ M (medium); defer L (large) items to `.planning/v1428-backlog.md`.
- Low findings: apply if the same fix-contributor already touches the file for a higher-severity finding (zero-cost pile-on); otherwise defer.
## Schedule into the v1.4.27 ladder
```
R3a (running) → R3b (B6 + B7) → R3c mobile audit (7 parallel) → R3c-summary → R3d mobile fixes (4-6 parallel) → R4 QA → R5 release
```
Estimated wall-clock for the new section: 60-90 min on top of the existing ladder.
## Anti-goals
- No new framework migrations. If the audit finds the repo on Tailwind v3 while v4 is current — flag for v1.4.28, do not migrate in v1.4.27.
- No new component-library swaps. If the audit finds an outdated shadcn component — patch the local copy to match upstream, do not introduce a new dependency.
- No mobile-only routes or PWA scope expansion. v1.4.27 stays scoped to "the web application is good on mobile."
- No iOS-side code (that lives in the separate `healthlog-iOS` repository).
## Done when
- Every R3c auditor report exists at `.planning/research/v1427-r3c-mobile-<slug>.md`.
- `.planning/v1427-mobile-fix-plan.md` exists with touch-disjoint buckets.
- Every Critical and High finding plus every effort-≤-M Medium finding has landed via R3d commits on `develop`.
- Every R3d contributor has written a short report.
- The release CHANGELOG entry for v1.4.27 carries a "Mobile capability" section under the headline groups.