From cef9bc14fe5876e22278b92a97673195c3f2479c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 17:00:28 +0200 Subject: [PATCH] docs(planning): R3a contributor reports and v1.4.27 mobile plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .planning/round-3-b3-geoip-report.md | 71 +++++++ .planning/round-3-b4-coach-insights-report.md | 176 ++++++++++++++++++ .planning/round-3-b5-notifications-report.md | 176 ++++++++++++++++++ .planning/v1427-mobile-plan.md | 173 +++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 .planning/round-3-b3-geoip-report.md create mode 100644 .planning/round-3-b4-coach-insights-report.md create mode 100644 .planning/round-3-b5-notifications-report.md create mode 100644 .planning/v1427-mobile-plan.md diff --git a/.planning/round-3-b3-geoip-report.md b/.planning/round-3-b3-geoip-report.md new file mode 100644 index 000000000..77fc110fa --- /dev/null +++ b/.planning/round-3-b3-geoip-report.md @@ -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. diff --git a/.planning/round-3-b4-coach-insights-report.md b/.planning/round-3-b4-coach-insights-report.md new file mode 100644 index 000000000..d4dc916c6 --- /dev/null +++ b/.planning/round-3-b4-coach-insights-report.md @@ -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 `` call sites + the prop on `ChatBubbleProps`. The `
` now mounts closed unconditionally. +- `src/components/insights/coach-panel/coach-settings-sheet.tsx` — retired the `showEvidenceByDefault` `` 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(/]*\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 `` with an `Info` icon (lucide-react) wrapped in a shadcn `` (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. `` — 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. `` — 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 `` 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 `` primitive (`src/components/ui/empty-state.tsx`) inside the same `` the loaded view renders. Pattern per page: + +```tsx +return ( + SectionTitle")}> + } + title={t("insights.emptyState..title")} + description={t("insights.emptyState..description")} + action={ + + } + /> + +); +``` + +The shell keeps the page title visible (consistent with the loaded view) and the `` `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 `` plus any persisted pref". I retired the `` 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 `` 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(/]*\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) diff --git a/.planning/round-3-b5-notifications-report.md b/.planning/round-3-b5-notifications-report.md new file mode 100644 index 000000000..39d719a2e --- /dev/null +++ b/.planning/round-3-b5-notifications-report.md @@ -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 --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. diff --git a/.planning/v1427-mobile-plan.md b/.planning/v1427-mobile-plan.md new file mode 100644 index 000000000..05f2c27e9 --- /dev/null +++ b/.planning/v1427-mobile-plan.md @@ -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-.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 `` 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-.md +purpose: Mobile capability audit — +created: 2026-05-15 +auditor: +--- + +# Mobile audit — + +## Summary + +<2-3 sentences: how many components reviewed, how many issues found by severity tier, headline takeaway> + +## Findings + +### F — +- Severity: Critical | High | Medium | Low +- Axis: visual | logic | code +- File: +- Symptom: +- Evidence: +- Recommended fix: +- Effort: + +[repeat per finding] + +## Headline metrics +- Components reviewed: +- Findings by tier: C: H: M: L: +- Mobile-hostile patterns flagged for B7-style symmetry pass: + +## Open questions for the consolidator + +``` + +## 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--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-.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.