From 67207d725e8f1766613c049d3d5f0ed81b9a1f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 18:36:17 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.41=20=E2=80=94=20iOS=20p?= =?UTF-8?q?erf=20hotfix,=20soft-delete=20completeness,=20tree=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight touch-disjoint waves landed on develop before this release: - W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub persist helper and wire all seven *-status routes through it, closing the recurring ~14 s warm response the iOS client sees on dashboard open once the AI provider has stalled once that day. - W-DELETED-2 — close the remaining three soft-delete reader-tier gaps (export bundle, gamification achievements, doctor-report aggregator) with a five-route integration-test pin. - W-ORG — types consolidation into src/types/analytics.ts and src/types/backups.ts; prompt directory unification. - W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift. - W-IOS-COORD — POST /api/auth/check-user four-branch onboarding discovery, identifier matched verbatim, per-IP rate-limited. - W-SIMPLIFIER — today-intake projection helper extract; unused tx? params dropped; 13 knip-flagged dead exports trimmed. - W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired. - W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule formalisation; knip CI gate staged for v1.4.42 flip. No migration, no env-var change, no API contract break for iOS v0.5.4. The new check-user endpoint is an additive surface. AP-2 .p8 key is installed; SB-5 time-sensitive APNs is live as of this deploy. pnpm test --run green at 4732 passing / 1 skipped. pnpm typecheck, pnpm lint, pnpm knip green. --- .github/workflows/knip.yml | 19 +- .../phase-W-FRONTEND-FACTORY-v1441-report.md | 62 +++ .planning/phase-W-IOS-COORD-v1441-report.md | 88 +++++ .planning/phase-W-ORG-v1441-report.md | 73 ++++ .../phase-W-PROCESS-DOCS-v1441-report.md | 141 +++++++ .../phase-W-RECONCILE-INSIGHTS-report.md | 161 ++++++++ .planning/phase-W-SIMPLIFIER-report.md | 166 ++++++++ .planning/research/rsc-migration-plan-v1.5.md | 313 +++++++++++++++ .planning/round-audit-marathon-closure.md | 4 + .../round-v1440-QA-product-lead-findings.md | 357 ++++++++++++++++++ .planning/round-v1440-QA-security-findings.md | 81 ++++ .planning/round-v1440-QA-senior-findings.md | 299 +++++++++++++++ .../round-v1440-QA-simplifier-findings.md | 53 +++ .../round-v1440-QA-specialist-findings.md | 181 +++++++++ .planning/round-v1440-QA-ux-findings.md | 321 ++++++++++++++++ .planning/round-v1440-perf-verify.md | 94 +++++ .../sb-8-dashboard-layout-merge-semantics.md | 86 +++++ .planning/sb-9-streak-deprecation.md | 30 ++ .planning/v1441-handoff.md | 53 +++ CHANGELOG.md | 42 +++ docs/self-hosting/scaling.md | 71 ++++ eslint-plugins/healthlog/queryKey-factory.js | 134 +++++++ eslint.config.mjs | 16 + package.json | 2 +- .../api/auth/check-user/route.test.ts | 161 ++++++++ .../dashboard-suspense-boundaries.test.ts | 15 +- src/app/api/admin/backups/route.ts | 29 +- src/app/api/admin/status/route.ts | 4 +- src/app/api/auth/check-user/route.ts | 114 ++++++ src/app/api/dashboard/summary/route.ts | 115 +----- .../api/doctor-report/availability/route.ts | 7 + .../__tests__/soft-delete-filter.test.ts | 164 ++++++++ src/app/api/export/full-backup/route.ts | 5 +- src/app/api/export/measurements/route.ts | 16 +- src/app/api/export/route.ts | 5 +- .../api/gamification/achievements/route.ts | 4 + .../insights/generate/__tests__/route.test.ts | 2 +- src/app/api/insights/generate/route.ts | 4 +- src/app/api/medications/intake/route.ts | 108 +----- src/app/auth/login/page.tsx | 7 +- src/app/auth/register/page.tsx | 3 +- src/app/insights/page.tsx | 49 +-- src/app/notifications/page.tsx | 18 +- src/app/page.tsx | 64 ++-- src/components/admin/backups-section.tsx | 2 +- src/components/charts/trend-card.tsx | 2 +- .../insights/personal-record-badge.tsx | 12 +- .../onboarding/getting-started-checklist.tsx | 7 +- src/components/settings/about-section.tsx | 3 +- src/lib/__tests__/query-keys.test.ts | 25 ++ src/lib/ai/coach/snapshot.ts | 2 +- src/lib/ai/codex-oauth.ts | 14 - src/lib/ai/feedback-attribution.ts | 2 +- .../__tests__/compact-sections.test.ts} | 2 +- .../__tests__/prompt-comparison.test.ts | 2 +- .../prompts/compact-sections.ts} | 0 .../prompts/insight-system-prompt.ts} | 0 .../__tests__/summaries-slice.test.ts | 138 +------ src/lib/analytics/summaries-slice.ts | 56 +-- src/lib/dashboard-layout.ts | 4 +- src/lib/doctor-report-data.ts | Bin 20585 -> 20804 bytes src/lib/feedback/publish-github.ts | 2 +- src/lib/format-locale.ts | 2 +- src/lib/format.ts | 4 +- .../__tests__/blood-pressure-status.test.ts | 100 +++++ .../insights/__tests__/general-status.test.ts | 96 +++++ .../medication-compliance-status.test.ts | 108 ++++++ .../insights/__tests__/mood-status.test.ts | 91 +++++ .../__tests__/pulse-status-timeout.test.ts | 28 +- .../insights/__tests__/pulse-status.test.ts | 47 +++ .../insights/__tests__/weight-status.test.ts | 88 +++++ src/lib/insights/blood-pressure-status.ts | 19 +- src/lib/insights/bmi-status.ts | 53 +-- src/lib/insights/general-status.ts | 19 +- .../insights/medication-compliance-status.ts | 42 ++- src/lib/insights/mood-status.ts | 19 +- src/lib/insights/persist-timeout-stub.ts | 73 ++++ src/lib/insights/pulse-status.ts | 25 +- src/lib/insights/weight-status.ts | 19 +- src/lib/medication-category.ts | 2 +- .../scheduling/project-today-intakes.ts | 145 +++++++ .../medications/side-effects/validators.ts | 2 +- src/lib/medications/window-status.ts | 2 +- src/lib/openapi/registry.ts | 2 +- src/lib/query-keys.ts | 47 +++ .../measurement-read-cumulative.test.ts | 136 ------- .../__tests__/measurement-read-wmy.test.ts | 83 ---- .../__tests__/measurement-rollups.test.ts | 24 -- .../rollups/measurement-read-cumulative.ts | 120 ------ src/lib/rollups/measurement-read-wmy.ts | 44 +-- src/lib/rollups/measurement-rollups.ts | 47 +-- .../rollups/medication-compliance-rollups.ts | 52 +-- src/lib/rollups/mood-rollups.ts | 9 +- src/lib/telegram.ts | 19 - src/types/analytics.ts | 152 +++++++- src/types/backups.ts | 36 ++ 96 files changed, 4582 insertions(+), 1187 deletions(-) create mode 100644 .planning/phase-W-FRONTEND-FACTORY-v1441-report.md create mode 100644 .planning/phase-W-IOS-COORD-v1441-report.md create mode 100644 .planning/phase-W-ORG-v1441-report.md create mode 100644 .planning/phase-W-PROCESS-DOCS-v1441-report.md create mode 100644 .planning/phase-W-RECONCILE-INSIGHTS-report.md create mode 100644 .planning/phase-W-SIMPLIFIER-report.md create mode 100644 .planning/research/rsc-migration-plan-v1.5.md create mode 100644 .planning/round-v1440-QA-product-lead-findings.md create mode 100644 .planning/round-v1440-QA-security-findings.md create mode 100644 .planning/round-v1440-QA-senior-findings.md create mode 100644 .planning/round-v1440-QA-simplifier-findings.md create mode 100644 .planning/round-v1440-QA-specialist-findings.md create mode 100644 .planning/round-v1440-QA-ux-findings.md create mode 100644 .planning/round-v1440-perf-verify.md create mode 100644 .planning/sb-8-dashboard-layout-merge-semantics.md create mode 100644 .planning/sb-9-streak-deprecation.md create mode 100644 .planning/v1441-handoff.md create mode 100644 eslint-plugins/healthlog/queryKey-factory.js create mode 100644 src/__tests__/api/auth/check-user/route.test.ts create mode 100644 src/app/api/auth/check-user/route.ts create mode 100644 src/app/api/export/__tests__/soft-delete-filter.test.ts rename src/lib/{insights/__tests__/prompt-compact.test.ts => ai/prompts/__tests__/compact-sections.test.ts} (97%) rename src/lib/{insights => ai/prompts}/__tests__/prompt-comparison.test.ts (98%) rename src/lib/{insights/prompt-compact.ts => ai/prompts/compact-sections.ts} (100%) rename src/lib/{insights/prompt.ts => ai/prompts/insight-system-prompt.ts} (100%) create mode 100644 src/lib/insights/persist-timeout-stub.ts create mode 100644 src/lib/medications/scheduling/project-today-intakes.ts delete mode 100644 src/lib/rollups/__tests__/measurement-read-cumulative.test.ts delete mode 100644 src/lib/rollups/measurement-read-cumulative.ts create mode 100644 src/types/backups.ts diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index a36dfdec5..e04d8a904 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -29,12 +29,15 @@ jobs: - run: pnpm db:generate - # v1.4.40 W-INFRA — fail the build when knip surfaces a new dead - # module, an unused production dependency, an unused devDependency, - # or an unlisted binary. Unused exports + types are intentionally - # left informational while the existing surface is cleaned wave by - # wave (487 exports / 52 types on the v1.4.40 baseline). The - # baseline carve-outs live in `knip.json` under `ignore` + - # `ignoreDependencies` + `ignoreBinaries` with the rationale - # captured in `.planning/phase-W-INFRA-v1440-report.md`. + # v1.4.41 W-PROCESS-DOCS — exports + types tier staged for + # promotion to enforcing. The W-SIMPLIFIER and W-INSIGHTS-HOT + # cleanup waves have not yet landed on develop at the time this + # comment was written (48 unused exports + 52 unused exported + # types still in the tree), so the gate remains scoped to + # files,dependencies,binaries,unlisted. Once those cleanup waves + # merge and `pnpm knip --reporter compact` reports zero unused + # exports + zero unused types, drop the `--include` flag below + # to flip exports + types to enforcing. See + # `.planning/phase-W-PROCESS-DOCS-v1441-report.md` for the + # promotion checklist and the rollback path. - run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted diff --git a/.planning/phase-W-FRONTEND-FACTORY-v1441-report.md b/.planning/phase-W-FRONTEND-FACTORY-v1441-report.md new file mode 100644 index 000000000..e3bfe3b9f --- /dev/null +++ b/.planning/phase-W-FRONTEND-FACTORY-v1441-report.md @@ -0,0 +1,62 @@ +# W-FRONTEND-FACTORY v1.4.41 report + +## Scope landed (commit 0bf07abd) + +- `src/lib/query-keys.ts` — factory extended with new entries for the + unmigrated surfaces (notificationsStatus, settingsReminderThresholds, + insightsSettings, insightsProviderChain, insightsGlp1Timeline, + userAiProvider, userProfile, apiVersion, publicVersion, researchMode, + moodlogStatus, integrationsStatus, featureFlags, coachPrefs, + adminAiQuality, adminAppLogs, adminAssistantFlags, adminBackups, + adminCoachFeedback, adminFeedback/+Root, adminHostMetrics, + adminAuditActions, adminAuditOverview). +- `src/app/auth/login/page.tsx` — `queryKeys.authRegistrationStatus()` + + `queryKeys.auth()` on the two invalidation sites. +- `src/app/auth/register/page.tsx` — `queryKeys.auth()` invalidation. +- `src/app/notifications/page.tsx` — all five + read/getQueryData/setQueryData/cancelQueries/invalidate sites swapped + to `queryKeys.notificationsPreferences()`. +- `src/components/settings/about-section.tsx` — `queryKeys.apiVersion()`. +- `src/app/page.tsx` — UX M1: tile Suspense fallback switched from + `null` to a layout-stable card div mirroring trend-card chrome + (`bg-card border-border rounded-xl border p-4 md:p-6`) so a future + RSC hoist can't trigger CLS. +- `src/lib/__tests__/query-keys.test.ts` — walker `guardedRoots` + extended to scan `src/app/auth`, `src/app/notifications`, and the + about-section. New factory-shape test pins authRegistrationStatus, + notificationsPreferences, notificationsStatus, apiVersion. + +## Quality gates + +- `pnpm typecheck` — clean. +- `pnpm exec vitest run src/lib/__tests__/query-keys.test.ts` — 17/17 + passing including the expanded walker scope. + +## Deferred / handed-off + +Settings sub-files (account, advanced, ai-section, api-section, +integrations-section, mood-reminder-card, notification-status-card, +notifications-section, ntfy-card, telegram-card, thresholds-editor, +sources-section), targets/target-edit-sheet, medications/*, +mood/mood-list, measurements/measurement-list, insights/* (coach-panel, +therapy-timeline, personal-record-badge), admin/* (twelve sections), +onboarding/getting-started-checklist, hooks (use-coach-prefs, +use-feature-flags). The factory already carries entries for every key +those sites use; the swap itself plus walker expansion can land in a +follow-up wave or hotfix without further factory work. + +The single context budget on this wave ran out before the long tail +could be migrated and verified end-to-end. The committed slice is +green and self-contained: walker guards three new surfaces, and the +new factory entries are unit-pinned even where call sites still hold +bare literals. + +## Files touched (absolute) + +- /Users/marc/Projects/HealthLog/src/lib/query-keys.ts +- /Users/marc/Projects/HealthLog/src/lib/__tests__/query-keys.test.ts +- /Users/marc/Projects/HealthLog/src/app/auth/login/page.tsx +- /Users/marc/Projects/HealthLog/src/app/auth/register/page.tsx +- /Users/marc/Projects/HealthLog/src/app/notifications/page.tsx +- /Users/marc/Projects/HealthLog/src/app/page.tsx +- /Users/marc/Projects/HealthLog/src/components/settings/about-section.tsx diff --git a/.planning/phase-W-IOS-COORD-v1441-report.md b/.planning/phase-W-IOS-COORD-v1441-report.md new file mode 100644 index 000000000..db3715ac5 --- /dev/null +++ b/.planning/phase-W-IOS-COORD-v1441-report.md @@ -0,0 +1,88 @@ +# Phase W-IOS-COORD — v1.4.41 iOS coordination closure + +## Goal +Close remaining iOS coordination items from `.planning/v05x-marathon/SERVER-BACKLOG.md`: SB-7 follow-up (`/api/auth/check-user`), SB-8 (dashboard layout merge-semantics doc), SB-9 (streak deprecation decision), and the recurring v1.4.40 `PUT /api/dashboard/widgets` 422 drift. + +## Deliverables + +| Item | Artefact | Decision | +| --- | --- | --- | +| SB-7 follow-up | `src/app/api/auth/check-user/route.ts` + 5-branch test pin | **Implemented.** Four-branch discovery endpoint per iOS spec. | +| SB-8 | `.planning/sb-8-dashboard-layout-merge-semantics.md` | **Investigation-only — no code change.** Merge is append-only resolver; new widgets ship migration-free. | +| SB-9 | `.planning/sb-9-streak-deprecation.md` | **Already retired.** No `/api/streak/*` surface exists in this codebase. | +| 422 drift | This report (root-cause section below) | **No server fix viable — iOS-side payload bug.** | + +## Files touched + +| File | Status | Commit | +| --- | --- | --- | +| `src/app/api/auth/check-user/route.ts` | NEW | `feat(auth-check-user): four-branch discovery endpoint for iOS onboarding` | +| `src/__tests__/api/auth/check-user/route.test.ts` | NEW | (same) | +| `.planning/sb-8-dashboard-layout-merge-semantics.md` | NEW | `docs(dashboard-layout): document merge semantics for SB-8 audit gap` | +| `.planning/sb-9-streak-deprecation.md` | NEW | `chore(streak): formalise deprecation status per SB-9` | + +No existing source files modified — additive only. + +## SB-7 — four-branch contract + +`POST /api/auth/check-user` with body `{ identifier: string }` (username or email, case-insensitive). Returns `{ branch, hasPasskey, hasPassword }` where `branch` is one of: + +- `not_found` — no account; show sign-up screen. +- `passkey_only` — account exists, ≥1 passkey, no password hash. Show "Sign in with Passkey". +- `email_fallback` — account has a password hash (with or without a passkey). Show password field plus optional "Use Passkey" button when `hasPasskey === true`. +- `exists` — account has neither passkey nor password. Recovery path; show "Reset access". + +`hasPasskey` and `hasPassword` are also returned so the iOS client can render both affordances when applicable without a second round-trip. + +Tests: 5 cases (all four branches + `422 identifier required`). All pass. + +The endpoint deliberately mirrors the existing `/api/auth/registration-status` envelope shape (`{ data, error, meta? }`) and the existing PII posture of `/api/auth/passkey/login-options` — both already disclose account-existence by identifier, so this route adds no new enumeration surface. + +## SB-8 — dashboard layout merge semantics + +See `.planning/sb-8-dashboard-layout-merge-semantics.md`. Key finding: the resolver in `src/lib/dashboard-layout.ts` is **append-only** on read — new widget ids added to `DASHBOARD_WIDGET_IDS` automatically appear for every existing user on next GET, with `visible` / `tileVisible` / `order` taken from `DEFAULT_DASHBOARD_LAYOUT`. **No database migration is ever required** when introducing a new widget. The anti-pattern of writing a one-shot SQL migration to overlay tile-visibility is explicitly called out — it would clobber every user's customisation. + +The PUT route already preserves `chartOverlayPrefs` when omitted (lines 137–151 in `src/app/api/dashboard/widgets/route.ts`); `comparisonBaseline` is NOT preserved-on-omit and is documented as a known limitation. No code change made. + +## SB-9 — streak deprecation + +`ls src/app/api/streak` → `No such file or directory`. No web frontend code paths reference `/api/streak/...`. The only `streak` references in the API tree are computed fields inside `src/app/api/insights/targets/route.ts` (`streakDays`, `streakHighlight`) — those are derived attributes on an unrelated payload, not a standalone route family. + +**Decision:** nothing to deprecate. The route family either never existed on this server or was removed before the audit. iOS can safely treat `/api/streak/*` as 404; the web app does not depend on it either. + +## `/api/dashboard/widgets` 422 root-cause + +Live logs from the v1.4.38.1 deploy showed iOS v0.5.4 PUTting this endpoint and receiving 422 twice (`V054-SR-merge-deploy-report.md` line 59). I read the Zod schema (`src/app/api/dashboard/widgets/route.ts:38-96`) carefully against the v1.4.16 / v1.4.18 / v1.4.25 W6 history that already debugged 422 storms on this route: + +- `widgetIdEnum` is **derived from** `DASHBOARD_WIDGET_IDS` (lines 17-45 of `dashboard-layout.ts`). The set currently includes 16 ids — `weight`, `bp`, `pulse`, `bodyFat`, `mood`, `medications`, `sleep`, `steps`, `glucose`, `totalBodyWater`, `boneMass`, `bpInTarget`, `oxygenSaturation`, `achievements`, `vo2Max`, `recentWorkouts`. Any iOS payload with a widget id outside this set 422s. +- `chartOverlayPrefs` is `z.partialRecord(z.enum(CHART_OVERLAY_KEYS), …)` — partial; missing keys are OK. The v1.4.25 W6 fix already addressed the strict-record regression. +- `widgets[]` is bounded `.min(1).max(20)` — iOS sending an empty or oversize array would 422. +- The `comparisonBaseline` enum (`none | lastMonth | lastYear`) is strict — an unknown value 422s. +- Each widget object requires `order` ∈ `[0, 99]` integer; a float or out-of-range value 422s. + +The Zod validator is **correct and additive-safe** — every back-compat lever it can pull (optional `tileVisible`, optional `comparisonBaseline`, partial `chartOverlayPrefs`) is already in place. The 422 is therefore an iOS-side payload bug, NOT a server-side validator gap. Most likely candidates given the iOS v0.5.4 release notes: + +1. iOS is sending a widget id the server does not know (e.g. `glp1`, which the iOS team did add as a tile in v0.5.4 but is not in `DASHBOARD_WIDGET_IDS`). +2. iOS is sending `order: ` or `order: 100+` (e.g. `Date.now()`-derived ordering). +3. iOS is omitting one of the required `widgets[].id | visible | order` fields. + +**Server action:** none. The validator is correct. **iOS action required:** confirm the payload shape against the contract in `.planning/v15-ios-handoff/03-api-contracts.md` §`GET/PUT /api/dashboard/widgets` and `DASHBOARD_WIDGET_IDS`. The 422 response body (`{ data: null, error: }`) already names the offending path; iOS should surface this in their dev console. + +The route also returns 422 on a **single** issue (`parsed.error.issues[0].message`), which can be unhelpful when multiple fields are wrong. That is a separate "diagnostic improvement" backlog item — not the cause of the iOS 422s and out-of-scope for this wave. + +## Quality gates + +- `pnpm vitest run src/__tests__/api/auth/check-user/` → 5/5 passing. +- `pnpm tsc --noEmit` — no new errors introduced by check-user route. Existing pre-existing errors in `summaries-slice.test.ts` and `measurement-read-wmy.test.ts` are unrelated to this wave. +- No source files outside the authorised set touched. + +## Commits + +Planned commit sequence: + +1. `feat(auth-check-user): four-branch discovery endpoint for iOS onboarding` — route + tests. +2. `docs(dashboard-layout): document merge semantics for SB-8 audit gap` — `.planning/sb-8-…md`. +3. `chore(streak): formalise deprecation status per SB-9` — `.planning/sb-9-…md`. +4. (this report) `docs(planning): W-IOS-COORD v1.4.41 closure report` — `.planning/phase-W-IOS-COORD-v1441-report.md`. + +No `fix(dashboard-widgets): …` commit — the server-side validator is correct; no change needed. diff --git a/.planning/phase-W-ORG-v1441-report.md b/.planning/phase-W-ORG-v1441-report.md new file mode 100644 index 000000000..78add9917 --- /dev/null +++ b/.planning/phase-W-ORG-v1441-report.md @@ -0,0 +1,73 @@ +# W-ORG — v1.4.41 wave report + +Closes v1.4.39 org-audit recommendations #2 (types promotion) and #3 +(prompt unification). + +## Commits landed on `develop` + +1. `bba38b8c` — `refactor(types): move BackupRow and BackupsList out of + route handler into src/types/backups.ts` +2. `dc66dfcf` — `refactor(types): consolidate AnalyticsData into + src/types/analytics.ts` +3. `8a56f482` — `refactor(prompts): unify src/lib/insights/prompt*.ts + into src/lib/ai/prompts/` + +## Rec #2 — types promotion + +### `BackupRow` / `BackupsList` +- New home: `src/types/backups.ts`. +- The single component → route-handler import in the codebase + (`components/admin/backups-section.tsx ← app/api/admin/backups/route.ts`) + is closed. The route still owns the HTTP contract but re-imports the + shapes from the shared module. + +### `AnalyticsData` consolidation +Three structurally-distinct inline interfaces collided on the name +across `src/app/page.tsx`, `src/app/insights/page.tsx`, and +`src/components/onboarding/getting-started-checklist.tsx`. Hoisted as +named exports in `src/types/analytics.ts`: +- `SubPageAnalyticsData` (pre-existing slim shape for sub-pages) +- `DashboardAnalyticsData` (BD-Zielbereich aggregates, glucoseByContext, + lastSeenByType) +- `InsightsAnalyticsData` (correlations + healthScore for mother page) +- `ChecklistAnalyticsData` (loose per-type count shape) + +Call sites re-import their shape under the original local alias so +downstream code reads unchanged. Four named shapes (not one optional +mega-shape) keep call sites from cross-reading fields they shouldn't +reach for. + +## Rec #3 — prompt unification + +Moves: +- `src/lib/insights/prompt.ts` → `src/lib/ai/prompts/insight-system-prompt.ts` +- `src/lib/insights/prompt-compact.ts` → `src/lib/ai/prompts/compact-sections.ts` +- Co-located tests follow (`__tests__/prompt-comparison.test.ts`, + `__tests__/compact-sections.test.ts`). + +Three importers updated: +- `src/app/api/insights/generate/route.ts` +- `src/lib/ai/coach/snapshot.ts` +- `src/app/api/insights/generate/__tests__/route.test.ts` (the + `vi.mock("@/lib/insights/prompt")` mock path moved too). + +Prompts now have one home — `src/lib/ai/prompts/`. Nothing remains +under `src/lib/insights/prompt*`. + +## Quality gates + +- `pnpm typecheck` — clean for the W-ORG touch set. Remaining errors + (`rollups/__tests__/measurement-read-wmy`, + `measurement-read-cumulative`, `summaries-slice`) belong to W-PERF-OPS + / W-DELETED-2 and are touch-disjoint. +- `pnpm test --run src/lib/ai/prompts/__tests__/` and the + `app/api/insights/generate/__tests__` paths pass. +- One unrelated `dashboard-suspense-boundaries.test.ts` regex contract + failure observed pre-this-wave (different wave's territory). + +## Out of scope (per touch-disjoint guards) + +- `src/lib/rollups/**` (W-PERF-OPS). +- `src/app/api/insights/**` body changes (W-INSIGHTS-HOT) — only the + one import path in `generate/route.ts` was touched to follow the + prompt move. diff --git a/.planning/phase-W-PROCESS-DOCS-v1441-report.md b/.planning/phase-W-PROCESS-DOCS-v1441-report.md new file mode 100644 index 000000000..2ea320fcb --- /dev/null +++ b/.planning/phase-W-PROCESS-DOCS-v1441-report.md @@ -0,0 +1,141 @@ +# Phase W-PROCESS-DOCS — v1.4.41 report + +Wave **W-PROCESS-DOCS** delivers three process / DX hardening items +identified in the v1.4.40 audit + W-INFRA closure report. + +## Item 1 — knip exports + types enforcement (STAGED, not yet flipped) + +**Goal**: promote `exports` + `types` from informational-only to +enforcing in `.github/workflows/knip.yml`. + +**Status**: gate **staged** — comment block + flip instructions in +place, but the `--include` filter still excludes `exports,types` +because the cleanup waves they depend on (W-SIMPLIFIER, +W-INSIGHTS-HOT) had not yet landed on `develop` at the time +W-PROCESS-DOCS ran. + +**Baseline at hand-off** (from `pnpm knip --reporter compact` on +HEAD `4cca62dc…`): + +- Unused exports: 48 +- Unused exported types: 52 + +Most of the remaining surface is public-API zod schemas and type +aliases re-exported for downstream consumers — many will legitimately +stay after cleanup. The promotion criterion is **zero offenders on a +clean `pnpm knip --reporter compact`**, NOT "drive both counts to +zero unconditionally". Where a public schema must remain exported, +the next wave should: + +1. Verify the export has an external consumer (test file, API route, + downstream package). +2. Add a `// knip-ignore` marker tag on the export, OR add the file + to `knip.json`'s `ignore` block with a one-line rationale. + +**Flip recipe** for the wave that lands after W-SIMPLIFIER + +W-INSIGHTS-HOT: + +```yaml +# .github/workflows/knip.yml — drop the --include filter +- run: pnpm knip --reporter compact +``` + +A revert is a one-line restoration of the `--include` filter — risk +is low and reversible. + +## Item 2 — ESLint custom rule `healthlog/queryKey-factory` (SHIPPED) + +**Goal**: promote the test-guard substitute (v1.4.40 W-RSC, +`src/lib/__tests__/query-keys.test.ts` factory-bypass guard) to a +real ESLint rule so editor + CI both fail fast on a literal-array +`queryKey: [ … ]` declaration inside guarded files. + +**Files**: + +- `eslint-plugins/healthlog/queryKey-factory.js` — new flat-config + plugin. AST rule scans every `Property` node and flags + `queryKey: ArrayExpression` / `mutationKey: ArrayExpression` only + when the surrounding file is on the guarded list. Identifiers, + `queryKeys.foo()` call expressions, and conditional expressions + all pass — only the bypass shape from audit-H1 fails. +- `eslint.config.mjs` — registers the plugin and wires the rule at + `"error"` severity. + +**Guarded surface** (mirrors `guardedRoots` in the test-guard +substitute): + +- `src/components/charts/**` +- `src/components/comparison/**` +- `src/app/page.tsx` +- `src/hooks/use-auth.ts` + +Files outside the guarded surface are intentionally exempt — extend +the list in lockstep with the test-guard substitute as future waves +migrate `src/components/admin/**`, `src/components/settings/**`, +`src/components/medications/**`, and `src/components/integrations/**` +to the factory. + +**Whitelist** for the factory home itself: + +- `src/lib/query-keys.ts` (the factory definition) +- `src/lib/__tests__/query-keys.test.ts` (asserts factory output + shape against literal keys — intentional) + +**Verification**: `pnpm lint` reports 0 errors / 5 pre-existing +warnings on HEAD. The new rule loads cleanly and matches zero +offenders, confirming W-RSC + W-FRONTEND-FACTORY already routed +every guarded file through the factory. + +## Item 3 — pg.Pool max × N container deploy doc (SHIPPED) + +**Goal**: document the v1.4.40 W-POOL change (`pg.Pool max = 20` per +container, env-overridable via `DATABASE_POOL_MAX`) in deploy notes +so operators can compute the per-deployment pool envelope without +reading source. + +**File**: `docs/self-hosting/scaling.md` — appended a +`## Postgres connection-pool sizing (v1.4.40 W-POOL)` section +covering: + +- The 20-slot default rationale (cold-mount trace, `p-limit(4)` + pairing). +- A `container_count × DATABASE_POOL_MAX` ready-reckoner table from + 1 to 5 containers against the stock `max_connections = 100` + ceiling, including the headroom column and the "do not exceed" + callout for 5 containers. +- An override example (`docker-compose.yml`) and three rules of + thumb for picking the right per-container ceiling. +- A pointer to PgBouncer transaction pooling for deployments that + outgrow the per-process pool model. +- A cross-reference to `src/lib/db.ts → getPoolMax()` and the + empirical trace that motivated the bump. + +The page already covered the web/worker split from v1.4 G3, so the +new section sits naturally at the end as the next "what to know +when you scale horizontally" topic. + +## Touch-disjoint guards honoured + +- Did NOT touch `src/lib/query-keys.ts` (W-FRONTEND-FACTORY territory). +- Did NOT touch `src/lib/__tests__/query-keys.test.ts` (W-FRONTEND-FACTORY + may extend its allowlist; the ESLint rule's allowlist mirrors the + test-guard substitute and can be updated in lockstep without merge + conflicts). +- Coordinated via `git pull --ff-only` mid-flight; the eslint plugin + + config + knip workflow edits landed on top of W-FRONTEND-FACTORY's + `0bf07afb refactor(query-keys): expand factory and migrate auth, + notifications, about` commit without conflicts. + +## Quality gates + +- `pnpm lint` — 0 errors, 5 pre-existing warnings (unchanged). +- `pnpm tsc --noEmit` — clean. +- `pnpm knip --reporter compact --include files,dependencies,binaries,unlisted` + — clean (matches current CI gate). + +## Hand-off to the next wave + +When W-SIMPLIFIER + W-INSIGHTS-HOT close and `pnpm knip --reporter +compact` reports zero unused exports + zero unused types, flip the +`--include` filter off in `.github/workflows/knip.yml` to enforce +both tiers. Everything else here is ready to ship as-is. diff --git a/.planning/phase-W-RECONCILE-INSIGHTS-report.md b/.planning/phase-W-RECONCILE-INSIGHTS-report.md new file mode 100644 index 000000000..6039a262c --- /dev/null +++ b/.planning/phase-W-RECONCILE-INSIGHTS-report.md @@ -0,0 +1,161 @@ +# W-RECONCILE-INSIGHTS — v1.4.41 phase report + +## Goal + +Close the last architectural-correctness High before the v1.4.41 tag: +collapse the duplicated 40-line `auditLog.create` timeout-stub block +into a single shared helper, refactor the existing call-site +(`bmi-status.ts`) onto it, and wire the six remaining sibling status +routes to the same persist path so every route shares one shape on +provider stall. + +## Branch + commits + +- Branch: `worktree-agent-a4b70eb08bcc2d056` (worktree branched from + `develop` at `a1bfc3b4 docs(planning): v1.4.41 marathon handoff + for fresh session`). +- Pushed to origin (see end of report). +- Commits added: + - `9584dc40 refactor(insights): extract shared timeout-stub persist helper` + - `d782f65a perf(insights): wire six sibling status routes to the shared timeout-stub helper` + +## Files touched + +### Helper (new) + +- `src/lib/insights/persist-timeout-stub.ts` — exports + `persistTimeoutStubAndReturn(input)` returning the standard + `{ hasProvider: true; text; cached: true; updatedAt }` envelope. + Body is byte-equivalent to the previous inline block in + `bmi-status.ts` (today's `dateKey`, `locale`, `text`, + `providerType`, `model: "timeout-stub"`, `tokensUsed: null`, + `timeout: true`; best-effort `try/catch`). + +### Routes refactored to use the helper (7 total) + +- `src/lib/insights/bmi-status.ts` — replaced the 45-line inline + block with a 7-line return. +- `src/lib/insights/blood-pressure-status.ts` — new wire, was a bare + fallback before. +- `src/lib/insights/weight-status.ts` — new wire, was bare. +- `src/lib/insights/general-status.ts` — new wire. +- `src/lib/insights/pulse-status.ts` — new wire; updated the stale + "fallback is NOT persisted" comment. +- `src/lib/insights/mood-status.ts` — new wire. +- `src/lib/insights/medication-compliance-status.ts` — new wire with + shape mapping (route returns `{ summary, medications: [] }` so the + helper is called for the persist side-effect and `summary` is + mapped from `text` on the way out). Also extended its cache-read + block to recognise the stub envelope (`dateKey + timeout: true + + text`) so the second-mount short-circuit fires on the same row + shape the helper writes. + +### Tests added / updated + +- `src/lib/insights/__tests__/blood-pressure-status.test.ts` — new + describe block: persist-on-timeout + short-circuit-on-stub. +- `src/lib/insights/__tests__/weight-status.test.ts` — same pair. +- `src/lib/insights/__tests__/general-status.test.ts` — same pair. +- `src/lib/insights/__tests__/mood-status.test.ts` — same pair. +- `src/lib/insights/__tests__/medication-compliance-status.test.ts` + — persist-on-timeout asserts the helper-shaped row + the + `{summary, medications: []}` mapped return shape; short-circuit + pinned against the new stub-recogniser branch. +- `src/lib/insights/__tests__/pulse-status.test.ts` — added the + short-circuit-on-stub case (the persist arm was already covered + by the existing dedicated file below). +- `src/lib/insights/__tests__/pulse-status-timeout.test.ts` — + flipped the old "does NOT persist" assertion (legacy contract) + to the new "persists exactly one stub row with `timeout: true` + + `model: timeout-stub`" assertion. The route now matches BP / + weight / BMI; the legacy comment in the file was rewritten too. + +The existing `bmi-status.ts` tests still pass after the refactor +(helper extraction preserves behaviour; verified, not rewritten). + +## Helper interface + +```ts +export async function persistTimeoutStubAndReturn(input: { + userId: string; + cacheAction: string; + todayKey: string; + locale: string; + providerType: string; + stubText: string; +}): Promise<{ + hasProvider: true; + text: string; + cached: true; + updatedAt: string | null; +}>; +``` + +`updatedAt` is `null` when the `auditLog.create` itself fails +(best-effort persist); the user-facing `text` always renders. + +## Quality gates + +- `pnpm typecheck`: clean (no new errors). +- `pnpm lint`: clean (only the 5 pre-existing warnings on + `src/app/insights/page.tsx` + `src/lib/analytics/summaries-slice.ts` + which are not in scope). +- `pnpm test --run src/lib/insights`: **155 / 155 passed** (was + 144 before; +11 new persist/short-circuit cases across the 6 + sibling routes + the pulse-status-timeout flip). + +Full suite (`pnpm test --run`) was also exercised; the only +failure is the pre-existing `dashboard-suspense-boundaries.test.ts` +red on develop tip (verified by stashing the worktree changes and +re-running — same failure). Not in scope for this wave. + +## Risk callouts / reconcile notes + +1. **medication-compliance cache-shape divergence (resolved + inline).** The route stores `{summary, medications}` in its + audit-log row, not `{text}`. A naive wire would have written a + helper-shaped stub that the route's cache-read block could not + recognise — defeating the entire short-circuit purpose. Fix + landed in the same commit: the route's cache-read block now + recognises both shapes (canonical `summary + medications` for + real assessments + `text + timeout: true` for stubs). Test + pins both branches. + +2. **pulse-status-timeout legacy assertion.** The pre-existing + `pulse-status-timeout.test.ts` asserted + `prisma.auditLog.create` was **never** called on timeout — a + sentinel encoding the v1.4.28 "do not poison tomorrow's cache" + contract. The v1.4.37 stub pattern reversed that decision (the + stub IS the way to NOT poison tomorrow: the daily pre-warm job + detects `model: "timeout-stub"` and overwrites). The test was + updated to assert the new contract; if a future wave wants + strict "no persist on timeout" semantics back, this test pin + makes the regression visible. + +3. **Helper is best-effort.** The `try/catch` around + `auditLog.create` is intentional and preserved from the v1.4.37 + bmi-status original: a failed write must not crash the response. + `updatedAt: null` on persist-failure is the signal. Worth + documenting in v1.4.41 ops notes — a sustained burst of "persist + failed" events would mean DB pressure, not a code bug, and would + degrade the route to the pre-v1.4.37 re-race-every-visit + behaviour silently. + +4. **No `toBerlinDayKey` extraction.** Each route still owns its + own `toBerlinDayKey` local — Simplifier-residual M2 deferred to + v1.4.42 per the wave brief. The helper takes `todayKey` as an + input so the extraction is a follow-up refactor inside a single + file (`src/lib/utils/berlin-day-key.ts` or similar) and won't + touch this helper's interface. + +## Counts at-a-glance + +- 1 helper file added. +- 7 routes share one persist path (was 0; bmi was the only persist + before, the other 6 had bare-fallback returns). +- ~120 LOC of duplicate `auditLog.create` calls collapsed into the + helper. +- Tests: 144 → 155 (+11). +- LOC delta: +673 / -81 across 14 files (tests dominate; production + code delta is roughly neutral after the bmi inline-to-helper + collapse). diff --git a/.planning/phase-W-SIMPLIFIER-report.md b/.planning/phase-W-SIMPLIFIER-report.md new file mode 100644 index 000000000..fd6184098 --- /dev/null +++ b/.planning/phase-W-SIMPLIFIER-report.md @@ -0,0 +1,166 @@ +# Phase W-SIMPLIFIER — v1.4.41 + +## Branch + +`worktree-agent-ae93a3f076c190857` (off `develop` @ `a1bfc3b4`). + +## Commits (4) + +1. `a106c82c` — `chore(cleanup): drop five unused imports flagged by eslint` +2. `aebb8a5b` — `refactor(medications): extract shared today-intake projection helper` +3. `da9f067c` — `refactor(rollups): drop unused optional tx params from recompute helpers` +4. `d6752cf9` — `chore(cleanup): trim 13 dead exports surfaced by knip` + +## 1. Lint warnings — closed + +All 5 `@typescript-eslint/no-unused-vars` warnings are gone. `pnpm lint` is clean. + +- `src/app/insights/page.tsx`: dropped `CorrelationResult` + `DataSummary` type imports. +- `src/lib/analytics/summaries-slice.ts`: dropped `RollupGranularity`, + `aggregateWmyBuckets`, and `RollupBucketRow` from the imports (both helpers + remain exported from `measurement-read-wmy.ts` because the test suite still + exercises them). + +## 2. Helper extraction — applied + +Created `src/lib/medications/scheduling/project-today-intakes.ts` exposing +`projectTodayIntakesAndRecompute({ userId, userTz, todayStart, todayEnd })`. + +Wired into both call sites: + +- `src/app/api/medications/intake/route.ts` (scope=today branch) +- `src/app/api/dashboard/summary/route.ts` (`projectAndReadTodaysIntakes`) + +The helper folds the projection, the idempotent `createMany` +(`skipDuplicates: true`), and the per-`(med, day)` compliance recompute +(`Promise.allSettled`) that both routes were carrying in lockstep. Returns +`{ projected, backfilled }` so the intake route can still emit telemetry. + +Net: ~200 inline lines replaced by one 145-line shared helper plus short +call sites. + +## 3. Unused `tx?` params — dropped + +Three Prisma helpers carried `tx?` Prisma transaction-client params that no +call site has ever passed: + +- `src/lib/rollups/mood-rollups.ts` → `recomputeMoodBucketsForEntry` +- `src/lib/rollups/medication-compliance-rollups.ts` → + `recomputeMedicationComplianceForDay` and + `recomputeMedicationComplianceForEvent` + +Dropping the params also dropped the `PrismaLike` / `PrismaTxOrClient` aliases +that only existed to type them, and the now-orphan top-level +`import type { Prisma }` in `medication-compliance-rollups.ts`. The +`PrismaTxOrClient` alias stays in `mood-rollups.ts` for two internal helpers +that legitimately consume it (`runMoodRollupAggregate`, +`persistMoodRollupRows`). No call-site changes needed. + +## 4. Bonus — 13 dead knip exports trimmed + +In commit 4: + +- Narrowed file-local visibility (dropped `export`): `getTrendSentiment`, + `isPRWithin30Days`, `buildCoachMetricSourceType`, `countPassedSchedules`, + `getGithubConfig`, `isComparisonBaseline`, `DASHBOARD_LAYOUT_VERSION`, + `INTL_LOCALE_MAP`, `SIDE_EFFECT_NOTES_MAX`, `MEDICATION_CATEGORIES`, + `openApiBase`, `ALL_MOOD_GRANULARITIES`. +- Outright deletions: `CodexOAuthNotConfiguredError` (backwards-compat shim + that nothing has imported in any branch), `editMessageReplyMarkup` (Telegram + helper no dispatch path calls), and the stale `DISPLAY_TIMEZONE` re-export + from `src/lib/format.ts`. + +Reverted four candidate narrowings that ESLint reported as +"assigned but only used as a type" once their export was dropped +(`CHART_RANGE_PRESETS`, `CHANNEL_TYPES`, `CHECKLIST_ITEM_IDS`, +`moodLogSyncResponseSchema`) — these constants act as the source of truth for +neighbour `typeof X[number]` / `z.infer` type aliases that ARE +exported, so the lint rule legitimately flags them as value-dead. Keeping the +exports is the conservative call. + +## Knip delta + +| Category | Before | After | +| --- | --- | --- | +| Unused exports | 48 | 35 | +| Unused exported types | 52 | 52 | + +(I did not touch the unused-types bucket — that's almost entirely zod-schema +inferred `*Input` types and Prisma-row narrowings that have legitimate +downstream consumers in routes / iOS contract / OpenAPI doc generation, even +when knip can't see the indirect reference. They're better triaged with a +flag-by-flag audit in v1.4.42.) + +## Verification + +- `pnpm typecheck` — clean +- `pnpm lint` — clean (0 warnings, 0 errors) +- `pnpm vitest run src/` — 4717 passing, 1 pre-existing failure (see + reconcile callouts), 1 skipped (was 4719 before) + +## Reconcile callouts + +- `src/app/__tests__/dashboard-suspense-boundaries.test.ts` — + `wraps each tile-strip cell in a boundary` fails on the + v1.4.40 tip independently of my changes (verified by `git stash` + + re-run). The test pins the literal source `fallback={null}` but the + component now uses `fallback={}`. NOT introduced by + this phase. Fix is one regex tweak; should land in a separate hotfix + or get folded into the v1.4.41 release branch. +- `src/lib/rollups/measurement-rollups.ts` — left untouched per the + do-NOT-touch list (W-PERF-OPS-1 UNION cleanup in flight). Knip still + flags `isRollupFresh` there; that's a clean removal candidate for + v1.4.42 once that worktree merges. +- `src/app/api/insights/blood-pressure-status/**` + + `src/app/api/insights/weight-status/**` — untouched, no dead code + observed during incidental reads. + +## Follow-up for v1.4.42 + +These knip items are SAFE candidates I deferred to stay within the session +budget — each was spot-checked as truly unreferenced (no test, no e2e, +no script reference), but the cleanup is mechanically tedious and would +benefit from a fresh-context pass: + +- `tokenKind`, `describeInjectionSite`, `PROGRESS_TICK_RECORDS`, + `MAINTAINED_LOCALES`, `SUB_PAGE_METRIC`, `withBackgroundEventSafe`, + `listSupportedTimezones` re-export at `tz/resolver.ts`, + `AlertDialogMedia / AlertDialogOverlay / AlertDialogPortal`, + `AvatarBadge / AvatarGroup / AvatarGroupCount`, `badgeVariants`, + `buttonVariants`, `CardFooter / CardAction / CardDescription`, + `DialogOverlay / DialogPortal / DialogTrigger`, the six unused + `DropdownMenu*` exports, the five unused `Select*` exports, + `SheetTrigger`, `TableFooter / TableCaption`, `tabsListVariants` (these + shadcn surface-area exports may stay on purpose as a "library shape" + contract — needs Marc's call before removing). +- `isRollupFresh` in `measurement-rollups.ts` — pending W-PERF-OPS-1 merge. +- All 52 "unused exported types" — needs a flag-by-flag audit (zod + `z.infer<>` types are legitimately consumed via duck-typed JSON + bodies, the iOS native client contract, and OpenAPI doc generation). +- `recommendationSeveritySchema / aiCitationSchema / aiWarningSchema / + storyboardAnnotation*` in `src/lib/ai/schema.ts` — sit in the AI + schema surface; the matching types ARE exported and may be consumed + indirectly through the response validators. + +## Files touched + +- `src/app/api/dashboard/summary/route.ts` +- `src/app/api/medications/intake/route.ts` +- `src/app/insights/page.tsx` +- `src/components/charts/trend-card.tsx` +- `src/components/insights/personal-record-badge.tsx` +- `src/lib/ai/codex-oauth.ts` +- `src/lib/ai/feedback-attribution.ts` +- `src/lib/analytics/summaries-slice.ts` +- `src/lib/dashboard-layout.ts` +- `src/lib/feedback/publish-github.ts` +- `src/lib/format-locale.ts` +- `src/lib/format.ts` +- `src/lib/medication-category.ts` +- `src/lib/medications/scheduling/project-today-intakes.ts` (new) +- `src/lib/medications/side-effects/validators.ts` +- `src/lib/medications/window-status.ts` +- `src/lib/openapi/registry.ts` +- `src/lib/rollups/medication-compliance-rollups.ts` +- `src/lib/rollups/mood-rollups.ts` +- `src/lib/telegram.ts` diff --git a/.planning/research/rsc-migration-plan-v1.5.md b/.planning/research/rsc-migration-plan-v1.5.md new file mode 100644 index 000000000..e7a9ec1ed --- /dev/null +++ b/.planning/research/rsc-migration-plan-v1.5.md @@ -0,0 +1,313 @@ +# RSC Migration Plan — `app/page.tsx` (HealthLog v1.5) + +**Author:** W-RSC-RESEARCH (HealthLog v1.4.41 marathon) +**Status:** Research only — no source code changes +**Target release:** v1.5 minor (dedicated 2–3 day marathon, no parallel waves) +**Inputs:** +- `.planning/round-v1439-arch-qa-frontend.md` (frontend audit, Critical/High findings C1/C2/H2/H3/H4) +- `.planning/phase-W-RSC-v1440-report.md` (Suspense layer + queryKey factory shipped) +- `src/app/page.tsx` (1 495 lines, `"use client"` line 1) +- `src/app/layout.tsx` (already async RSC — locale + nonce resolve server-side) +- 30 sibling `"use client"` files under `src/app/` (samples: `insights/page.tsx`, `measurements/page.tsx`) + +--- + +## 1. Current state inventory + +### 1.1 Why `app/page.tsx` is `"use client"` today + +Hook surface that pins client-side rendering: + +| Hook / API | Site | Purpose | +| --- | --- | --- | +| `useAuth()` | line 208 | session + `user` profile (timezone, dateOfBirth, gender, heightCm, glucoseUnit) | +| `useTranslations()` / `useFormatters()` | 209–210 | i18n context (locale-aware number/date formatting) | +| `useState<…>` × 4 | 211–224 | quick-entry sheet state + 3 footer-portal DOM refs | +| `useAnalyticsQuery({ slice: "summaries" })` | 255 | **slim** analytics fetch (per-type DataSummary headlines) | +| `useAnalyticsQuery()` | 256 | **thick** analytics fetch (`bpInTargetPct*`, `glucoseByContext`) | +| `useMemo` (merge slim+thick) | 257–285 | `mergeSlimAndThickAnalytics(slim, thick)` | +| `useQuery(queryKeys.dashboardWidgets())` | 287 | layout (visibility + order of every widget) | +| `useQuery(queryKeys.moodAnalytics())` | 299 | mood summary + sparkline series | +| `useMemo` (hour-of-day) | 577–580 | greeting copy keyed on `user.timezone` | + +Plus interactive primitives: ``, ``, ``, ``, ``, ``. + +Plus per-tile `` boundaries (shipped v1.4.40 W-RSC) — already streaming-ready. + +### 1.2 Data fetched from the page + +- **Slim analytics** (`/api/analytics?slice=summaries`) — fast path; rollup-served, < 1 s warm. +- **Thick analytics** (`/api/analytics`) — full fan-out; carries `bpInTargetPct*` + `glucoseByContext`. +- **Dashboard widgets** (`/api/dashboard/widgets`) — layout config (per-user). +- **Mood analytics** (`/api/mood/analytics`) — mood summary + sparkline entries. + +Both `useAnalyticsQuery` calls share `caches.analytics` server-side; warm hits are free. + +### 1.3 Client-only patterns blocking RSC migration + +1. **TanStack Query everywhere** — `useQuery` × 4 on the page, plus every chart child re-fetches via `["chart-data", …]` / `["mood-chart-data"]`. RSC cannot host `useQuery`. +2. **`useState` for sheet open/close + footer portal refs** — interactive UI state. +3. **`useAuth()` is a client hook** (reads from a TanStack-Query-backed `["auth","me"]` slot). The session itself can be resolved server-side via the existing session helper that `app/layout.tsx` already uses for headers/cookies. +4. **`useTranslations()` + `useFormatters()` are client-side** — bound to a Providers tree-internal context. RSC i18n requires a parallel server-side `t()` reader. +5. **Inline IIFE in the JSX (`(() => { … })()` lines 737–1492)** — ~755 lines of tile/chart assembly; not a hook problem but every conditional reads from client-only `data` / `user` / `layout`. +6. **30 sibling `"use client"` pages** — same patterns at smaller scale. RSC migration of `page.tsx` is the lighthouse; siblings stay client unless promoted in later releases. + +### 1.4 Already in place (do not re-do) + +- `}>` per chart row + `` per tile-strip cell (v1.4.40 W-RSC). +- `queryKeys` factory routing for dashboard + chart hooks; `measurementDependentKeys` bundles `["chart-data"]` + `["dashboard-medication-compliance"]` prefixes (v1.4.40 W-RSC). +- `DASHBOARD_QUERY_OPTS` module-scope hoist; `getHourForTimeZone` memoised (v1.4.40 W-RSC audit-M2 + audit-H4). +- `RootLayout` is already async RSC — reads `headers()`/`cookies()` for locale + CSP nonce. + +--- + +## 2. Target architecture + +``` +app/page.tsx (Server Component, async) +├── resolveSession(cookies/headers) → user (RSC-safe helper) +├── cache() wrapper: +│ ├── getSlimAnalyticsForUser(userId) → /api/analytics slim equivalent +│ └── getDashboardWidgetsForUser(userId) → /api/dashboard/widgets equivalent +└── (server) + ├── (server — static) + ├── (client island) + │ ├── + │ └── × 3 + ├── (already self-gated client) + ├── (client; ready computed from server hint) + ├── }> + │ └── (server — renders TrendCard per tile) + │ └── (server — pure presentational, no hooks) + ├── }> + │ └── (streams thick via async server child) + ├── }> + │ └── (streams thick) + └── (server — composes the chart row) + ├── }> + │ └── (client — Recharts; reads chart-data via TanStack) + ├── }> + │ └── (client) + └── … (one Suspense + client island per chart) +``` + +Key contracts: + +- **Server Components** carry the static shell, the per-request slim+thick fetch, the TrendCard tiles (pure presentational), and the chart-row scaffolding. +- **``** boundaries (already shipped) wrap each data-dependent island. Each one streams independently. +- **Client islands**: `` (dropdown + sheets + forms), every chart (Recharts is client-only — no SSR), the tour launcher, the getting-started checklist. +- **`cache()`** wraps slim + widgets fetchers so a single React tree render only hits the DB once per request even if multiple server children read them. Use `unstable_noStore()` (or `export const dynamic = "force-dynamic"`) so per-request freshness wins over the route-segment cache. +- **TanStack Query stays** for client islands (chart-data, mood-chart-data, optimistic mutations, focus refetch). The dashboard page itself no longer hosts a `QueryClient` read of slim/thick analytics — those are server data passed in as props. + +--- + +## 3. Chunked migration steps + +Each step is shippable independently. Each step ends with a CI run + a manual deploy probe. + +### Step 1 — Carve out the static shell as a Server Component wrapper + +- **Goal:** `app/page.tsx` becomes a server component that renders the existing dashboard as a child client component. No data moves yet. +- **Touched files:** + - `src/app/page.tsx` (new — server) — `export const dynamic = "force-dynamic"`, resolves `user` via the same session helper `layout.tsx` uses, renders ``. + - `src/app/dashboard-client.tsx` (new — `"use client"`) — receives `user` as a prop, contains today's entire `DashboardPage()` body. +- **Risk:** low. Behaviour-identical refactor; `useAuth()` is replaced inside the client with `user` prop + a thin client-side cache hydrate (so client islands deeper in the tree still see `useAuth().user`). +- **Tests:** dashboard render snapshot, auth-prop hydration test, e2e dashboard.spec.ts. +- **Acceptance:** page renders byte-identical; `Application` tab shows no "use client" boundary at the route root. + +### Step 2 — Move slim analytics + widgets fetch to the Server Component + +- **Goal:** server fetches slim analytics + widgets, passes them as props; the client only fetches the thick slice. +- **Touched files:** + - `src/lib/analytics/server-fetchers.ts` (new) — `getSlimAnalytics(userId)` + `getDashboardWidgets(userId)`, both wrapped in React `cache()`. + - `src/app/page.tsx` — calls both fetchers, passes `slim` + `layout` as props. + - `src/app/dashboard-client.tsx` — replaces `useAnalyticsQuery({ slice: "summaries" })` + the `dashboardWidgets()` `useQuery` with prop reads. Keeps the thick `useAnalyticsQuery()` client-side. + - Optional: hydrate the TanStack cache with the server-fetched slim payload so client-side mutations + invalidations still reach the right slot (`hydrate({ queries: [...] })`). +- **Risk:** medium. The slim fetcher must mint the same `lastSeenByType` + `summaries` shape the route currently returns. Use the existing internal function (don't re-implement) — `/api/analytics?slice=summaries` calls into a service layer; lift that service layer into `server-fetchers.ts`. +- **Tests:** parity test (server fetch result == HTTP route result), updated dashboard render snapshot, mutation-invalidation test (after `POST /measurements` the dashboard refetches via TanStack invalidation OR the page revalidates via `revalidatePath("/")`). +- **Acceptance:** Network tab on cold mount shows one fewer client-side request; tile strip paints with the initial HTML. + +### Step 3 — Thick analytics streaming via `` + nested Server Component + +- **Goal:** the BD-Zielbereich tile + glucose-tiles + the row's deeper-thick consumers move into an async server child wrapped in ``. Initial HTML carries slim data + tile-strip skeleton for the thick-dependent tiles; the thick slice streams in as the second flush of the streaming response. +- **Touched files:** + - `src/components/dashboard/bp-in-target-tile-server.tsx` (new) — async RSC, awaits a `thickPromise` passed from the parent. + - `src/components/dashboard/glucose-tiles-server.tsx` (new) — same shape. + - `src/app/page.tsx` — kicks off `getThickAnalytics(userId)` (also `cache()`-wrapped) WITHOUT awaiting it; passes the promise down to the suspending children. +- **Risk:** high. Streaming requires the response to NOT be buffered by any reverse proxy. Coolify's Caddy-reverse-proxy default config buffers `text/html` responses by default — a prerequisite check is mandatory (§4.1). +- **Tests:** streaming probe (assert HTTP response body arrives in ≥ 2 flushes); per-tile suspense boundary unit test extended. +- **Acceptance:** BD-Zielbereich tile renders ~500 ms after the tile-strip in the network waterfall. + +### Step 4 — Each tile becomes its own Server Component island + +- **Goal:** the inline IIFE (lines 737–1492) is dissolved. Every tile is a self-contained server (or client, if interactive) component. The page assembles them via the layout-order array. +- **Touched files:** + - `src/components/dashboard/tiles/weight-tile.tsx` + - `src/components/dashboard/tiles/bp-sys-tile.tsx` + - `src/components/dashboard/tiles/bp-dia-tile.tsx` + - `src/components/dashboard/tiles/pulse-tile.tsx` + - `src/components/dashboard/tiles/body-fat-tile.tsx` + - `src/components/dashboard/tiles/mood-tile.tsx` + - `src/components/dashboard/tiles/sleep-tile.tsx` + - `src/components/dashboard/tiles/steps-tile.tsx` + - `src/components/dashboard/tiles/vo2-tile.tsx` + - `src/components/dashboard/tiles/glucose-tiles.tsx` + - `src/components/dashboard/tile-strip.tsx` (new — server, assembles tiles from layout order) +- **Risk:** medium. `getRangeColorClass` / `getRangeHint` / `tileCompareDelta` / `tileStaleDays` helpers move into per-tile files OR a shared server-safe helper module. Range / band derivation helpers (already pure) move to `src/lib/analytics/value-bands.ts` consumers cleanly. +- **Tests:** one render test per tile + the existing dashboard-suspense-boundaries.test.ts updated. +- **Acceptance:** React DevTools shows named components per tile (cf. audit L2). Initial HTML carries one tile's static frame per cell. + +### Step 5 — Trim client islands to interactive pieces only + +- **Goal:** every component that does NOT use a hook drops `"use client"`. Identify candidates: `TrendCard`, `TrendHint`, `EmptyState`, the page-header block, the welcome banner, the `` static frame (the dismiss button stays a client child). +- **Touched files:** ~15–20 component files, mostly removing the directive at line 1. +- **Risk:** low. Each removal is a one-line edit gated by typecheck + test. If a component transitively imports a client-only API (e.g. `next/dynamic`), it stays client. +- **Tests:** all existing tests stay green; bundle-analyzer probe confirms the dashboard route's client JS payload shrinks. +- **Acceptance:** bundle-analyzer report: route `/` client JS ≤ 60 % of pre-migration size (rough target — verify against actual numbers in step). + +--- + +## 4. Prerequisites + +### 4.1 Next.js streaming verification — Coolify proxy buffering + +- **Risk:** Caddy's `reverse_proxy` directive (Coolify's default) does NOT buffer by default for HTTP/1.1, BUT `flush_interval` defaults can collapse short flushes. Verify by deploying a one-route probe to staging and `curl --no-buffer -N https://staging/probe-stream` — assert chunks arrive ≥ 100 ms apart. +- **Mitigation:** if buffering is observed, set `flush_interval: -1` (immediate flush) on the dashboard route in the Caddy snippet. Document in `docs/infra/coolify-config.md`. +- **Owner:** infra wave; must complete BEFORE Step 3. + +### 4.2 Test fixture updates + +- **Vitest dashboard tests** today mount `DashboardPage` directly inside a `QueryClientProvider`. Step-1 split requires either: + - rendering `DashboardClient` directly (cleanest — tests already supply `user` via mocked `useAuth`), OR + - introducing a `` that simulates the RSC prop-injection. +- **Playwright e2e** (`tests/e2e/dashboard.spec.ts`) — no changes needed for Step 1; for Step 3 (streaming) extend with an `await page.waitForResponse` that asserts ≥ 2 chunks for the dashboard route. +- **Snapshot tests** — every dashboard snapshot regenerates once per step. Treat as expected churn. + +### 4.3 React Compiler compatibility + +- React Compiler is enabled in `next.config.js` (verify before Step 1). The compiler runs on client components; promoting components to server components removes them from the compiler's surface area (server components are not memoised by the compiler). No regression — but the bundle-analyzer comparison in Step 5 must compare apples-to-apples (post-compiler client bundle vs post-compiler client bundle). +- **Owner:** prerequisite verification in Step 1; if the compiler is NOT enabled the plan is unchanged. + +### 4.4 Session helper RSC-safety + +- `useAuth()` is client. The server-side equivalent is a `getCurrentUser()` helper that reads the JWT/session cookie via `cookies()`. Audit `src/lib/auth/` for an existing helper; if absent, factor one out of the API-route session middleware as the first sub-step of Step 1. + +### 4.5 i18n server-side reader + +- `useTranslations()` is a client hook. RSC pages need a server-side `t(locale, key)` reader that loads the same message bundle. The locale already resolves server-side in `layout.tsx` (`resolveInitialLocale`); reuse the same bundle loader. +- **Owner:** prerequisite for Step 1's static header / welcome banner. If the bundle loader is client-only today, factor an `import { getServerTranslations } from "@/lib/i18n/server"` out of the existing `Providers` tree. + +### 4.6 `cache()` + bounded TTL + +- React `cache()` dedupes within a single render. For request-level dedupe across multiple `await`s in the same render, that is exactly what's needed. +- For cross-request reuse (avoiding hitting the DB twice in two consecutive requests within ~1 minute), wrap the inner fetchers in `unstable_cache(fn, key, { revalidate: 60 })`. Verify the staleness window matches today's `DASHBOARD_QUERY_OPTS.staleTime: 60_000`. +- After a measurement / mood / medication-intake mutation, the relevant API route must call `revalidateTag("user::analytics")` (set the tag on the `unstable_cache` wrap). Otherwise the dashboard reads stale slim data for up to 60 s after a save. + +--- + +## 5. Risk register + +| ID | Risk | Severity | Mitigation | +| --- | --- | --- | --- | +| R1 | **Hydration mismatch on initial mount** — server-rendered tile values vs client-side TanStack-cache-derived values diverge on prop hydrate | **High** | Hydrate the TanStack QueryClient with the server payload at the boundary (``). One mount-time assertion per tile in tests. | +| R2 | **Performance regression** — server compute path heavier than the client cache-warm hit it replaces | High | Step-2 bench gate: compare TTFB + LCP against pre-migration baseline; reject if LCP degrades > 100 ms. Server slim fetch must hit the rollup tier (already `< 100 ms` warm). | +| R3 | **SSR data freshness** — `cache()` + `unstable_cache` TTL drifts from TanStack staleTime | Medium | Pin TTL = 60 s in both layers; mutations call `revalidateTag(...)` AND `queryClient.invalidateQueries()`. Single integration test asserts post-mutation freshness end-to-end. | +| R4 | **Test fragmentation** — every step rewrites a chunk of the dashboard tests | Medium | Treat test churn as expected per-step deliverable; CI green-gate is non-negotiable per step. Plan budgets 20 % per-step time for test updates. | +| R5 | **Coolify proxy buffers the streamed response** | High (blocks Step 3) | §4.1 prerequisite probe before Step 3. If buffering can't be defeated, ship Steps 1+2+4+5 only; Step 3 becomes a v1.6 sub-release. | +| R6 | **`useAuth()` removal breaks deeper client islands** that read `user.timezone` etc. | Medium | Keep `useAuth()` as a thin client-side hook that hydrates from a server-injected `` at the page boundary. Forms / charts continue to call `useAuth()` unchanged. | +| R7 | **30 sibling client pages** — touching `app/page.tsx` ripples through shared layouts | Low | Migration scope is `/` only. Sibling pages stay client; the `AuthShell` + `Providers` chain stays unchanged. | +| R8 | **React Compiler bundle skew** post-migration vs pre-migration | Low | Bundle-analyzer comparisons in Step 5 must compare same compiler-on config. | + +--- + +## 6. Recommended scope + +- **Release:** v1.5 minor (NOT a patch — the architectural shift is observable in production logs + Network tab). +- **Duration:** 2–3 day dedicated marathon. +- **In-flight work:** none. No parallel waves on the dashboard tree; cross-agent commit drift (cf. v1.4.40 W-RSC report and memory note `feedback_cross_agent_commit_drift`) would corrupt step boundaries. +- **PR cadence:** one PR per step (5 PRs total). Each merges to `develop`, deploys to staging, gets a 24-hour soak before the next step ships. +- **Rollback:** every step is reversible by reverting the merge commit. Steps 1+2 are pure refactors with prop-equivalent semantics. + +--- + +## 7. Out-of-scope (explicitly) + +1. **Replacing TanStack Query entirely.** It stays for client-island data (`["chart-data", …]`, `["mood-chart-data"]`, mutations, focus refetch). The migration ONLY hoists the page's own slim+thick reads to the server. +2. **Removing the slim/thick split.** Both slices stay — they're the streaming envelope. The server fetches slim eagerly and kicks off thick concurrently for the `` flush. +3. **Migrating sibling `"use client"` pages** (`/insights`, `/measurements`, `/medications`, `/mood`, `/targets`, `/achievements`, etc.). 30 client pages remain client; this plan covers `/` only. +4. **Replacing Recharts with a server-renderable alternative.** Charts stay client (`next/dynamic({ ssr: false })`). Server tiles + client charts coexist behind a single `` per row. +5. **Custom ESLint `no-restricted-syntax` rule for queryKey factory** — deferred from v1.4.40 W-RSC; not blocking this migration. +6. **Long-tail factory migration** (154 bare sites) — deferred from v1.4.40; not blocking. +7. **Service Worker / VersionPoller changes** — VersionPoller stays as-is. + +--- + +## 8. Per-step deliverables checklist (for the future executing agent) + +### Step 1 (Day 0.5) +- [ ] `getCurrentUser()` server helper exists and is unit-tested +- [ ] `getServerTranslations(locale)` exists and matches client `t()` output +- [ ] `src/app/page.tsx` is a server component (no `"use client"`) +- [ ] `src/app/dashboard-client.tsx` holds today's body, receives `user` + `locale` props +- [ ] All existing tests pass +- [ ] Network tab unchanged (still ~10 requests on cold mount) +- [ ] Commit: `refactor(dashboard): carve server-component shell around dashboard-client` + +### Step 2 (Day 1) +- [ ] `src/lib/analytics/server-fetchers.ts` exports `getSlimAnalytics(userId)` + `getDashboardWidgets(userId)`, both wrapped in `cache()` + `unstable_cache({ revalidate: 60, tags: ["user::analytics", "user::widgets"] })` +- [ ] `app/page.tsx` awaits both, passes as props +- [ ] `dashboard-client.tsx` drops the slim `useAnalyticsQuery({ slice: "summaries" })` + widgets `useQuery` +- [ ] `` hydrates the TanStack cache with the server payload +- [ ] Mutation API routes call `revalidateTag(...)` on the relevant tags +- [ ] Network tab on cold mount: one fewer client request +- [ ] Commit: `feat(dashboard): server-fetch slim analytics + widgets, hydrate client cache` + +### Step 3 (Day 1.5) +- [ ] Coolify streaming probe green (§4.1) +- [ ] `bp-in-target-tile-server.tsx` + `glucose-tiles-server.tsx` are async RSCs +- [ ] `app/page.tsx` passes a `thickPromise` (NOT awaited) to those children +- [ ] HTTP response arrives in ≥ 2 flushes (curl `--no-buffer` probe) +- [ ] Commit: `feat(dashboard): stream thick analytics through Suspense` + +### Step 4 (Day 2) +- [ ] 10 tile files under `src/components/dashboard/tiles/` +- [ ] `tile-strip.tsx` assembles them from the layout order +- [ ] Inline IIFE in `dashboard-client.tsx` reduced to JSX-only +- [ ] React DevTools shows named components per tile +- [ ] Commit: `refactor(dashboard): one server component per tile` + +### Step 5 (Day 2.5) +- [ ] Bundle-analyzer report: route `/` client JS ≤ 60 % of pre-migration size +- [ ] `"use client"` removed from all components that don't use hooks (target: ≥ 15 files) +- [ ] All existing tests pass +- [ ] Commit: `refactor: trim client islands to interactive components only` + +### Final +- [ ] CHANGELOG entry under v1.5 — "Dashboard rebuilt on React Server Components for instant initial paint" +- [ ] Marc-voice release-notes blurb (no AI mention, no PII) +- [ ] 24-hour production soak before flipping the v1.5 release tag + +--- + +## 9. Estimated v1.5 effort + +| Phase | Days | +| --- | --- | +| Step 0 (prereqs: server helpers, i18n reader, Coolify probe) | 0.5 | +| Steps 1 + 2 (server shell + slim fetch) | 1.0 | +| Step 3 (streaming thick) | 0.5 | +| Step 4 (per-tile servers) | 0.5 | +| Step 5 (client-island trim + bundle verify) | 0.5 | +| **Total** | **3.0 dedicated days** | + +Buffer: +0.5 day for test churn / Coolify proxy debug. Real envelope: **2.5–3.5 days**, single autonomous marathon. + +--- + +## 10. References + +- Frontend audit C1, C2, H2, H3, H4: `.planning/round-v1439-arch-qa-frontend.md` +- v1.4.40 W-RSC report (Suspense + factory shipped, RSC deferred): `.planning/phase-W-RSC-v1440-report.md` +- Current `app/page.tsx`: 1 495 lines, `"use client"` line 1 +- Current `app/layout.tsx`: async RSC, locale + nonce resolved server-side +- 30 sibling client pages under `src/app/` — out of scope diff --git a/.planning/round-audit-marathon-closure.md b/.planning/round-audit-marathon-closure.md index dd5bd2d97..948d65f80 100644 --- a/.planning/round-audit-marathon-closure.md +++ b/.planning/round-audit-marathon-closure.md @@ -20,6 +20,10 @@ Shipped: 2026-05-17 → 2026-05-18 (thirteen consecutive releases — capped by | v1.4.38.6 | Boot-backfill discovery SQL fix — the widened v1.4.38.5 LEFT JOIN keyed on `bucket_start IS NULL` as the "no row" sentinel because `measurement_rollups` has no `id` column (composite PK). Without this fix the LEFT JOIN matched on the wrong key and the boot fold ran zero work. Single-line correctness fix; perf-verify continued into v1.4.38.7. | [v1.4.38.6](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.6) | | v1.4.38.7 | Rollup recompute observability + admin trigger — `POST /api/admin/rollups/recompute` lets the operator synchronously fold one user or re-trigger boot-discovery without restarting the container. Adds `fallback_reason` + `missing_types` annotates on the slim-summaries live-fallback path so a slow `/api/analytics` cold mount maps to the responsible type in the wide-event. Boot-discovery now logs every result (including the silent "no users to backfill" case). | [v1.4.38.7](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.7) | | v1.4.38.8 | Analytics fast-path gates per-type only — `isFullyCovered(coverage) && specificTypes` was poisoning every fast-path: a single uncovered orphan type (e.g. an iOS-pushed brand-new `ACTIVITY_FLIGHTS`) flipped the AND false and stranded the entire helper on live SQL across the full measurement table. `correlations-fast-path` now gates on `BLOOD_PRESSURE_SYS + PULSE + WEIGHT`; `bp-in-target-fast-path` on `BLOOD_PRESSURE_SYS + BLOOD_PRESSURE_DIA`; `health-score-fast-path` on `WEIGHT`. Cold-mount `/api/analytics` on power-user accounts drops from 30–75 s to ~3–5 s. Coverage-probe semantics unchanged. | [v1.4.38.8](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.38.8) | +| v1.4.40 | Comprehensive Critical+High architecture closure plus iOS PB30 enablement — eleven-wave parallel marathon closing every Critical + High from the v1.4.39 architecture-QA quadrology in one release per the operator's directive ("alle Critical+High in den nächsten finalen release, kein deferral, zeitlicher Aufwand egal"). Pool starvation root cause closed: `pLimit(4)` on the 15-way `Promise.all` fan-out in `buildAnalyticsResponse` plus `pg.Pool max=20` raised from the library default 10 (Wave-C chart-tile first-paint **+7.3 s → +1.6 s expected** on the empirical fixture). Six insights routes swapped to the mood-rollup tier (3 inline, 3 deferred with documented tier-shape reasons); `Measurement.deletedAt` full-wire across 16+ readers + the rollup writer (iOS soft-delete unblocked for PB30); WMY readers now live for `avg30LastYear` (was hardcoded null) + slope90 via MONTH-tier; per-tile `` boundaries plus `queryKey` factory enforcement (mood-analytics + mood-chart-data dedup); iOS SB-3 bilingual paired-on-one-page Privacy Policy, SB-4 AASA file at `/.well-known/apple-app-site-association`, SB-5 APNs `interruption-level: time-sensitive` for medication reminders only (gated on AP-2 `.p8` install), SB-6 `/api/notifications/status` per-category `lastDeliveredAt`, SB-7 four-branch test pin, SB-10 `POST /api/consent/ai` with append-only invariant + 64 KB Buffer-byte cap; ghost cleanup **−1 177 LOC** (TELEGRAM_CLEANUP_QUEUE dead worker, 3 orphan routes, 9 dead exports, 3 dead i18n keys); tz helpers consolidated into `src/lib/tz/`; `src/lib/rollups/` umbrella absorbing 7 files with 68 importer rewrites; knip CI workflow as release-branch gate. Six QA reviewer parallel + five reconcile fixes (test regex align, 6 insights `deletedAt`, `Promise.allSettled` recompute wrap, compare-toggle factory, consent `Buffer.byteLength`). Tests 4 525 → **4 726** (+201). Migration 0074 ConsentReceipt additive-clean. Cross-agent commit drift recurred a 4th time → per-agent git-worktree isolation now hard rule (`feedback_marathon_worktree_isolation.md`). | [v1.4.40](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.40) | +| v1.4.39.4 | Dashboard symmetry for daily-schedule intake projection — `/api/dashboard/summary` compliance tile mirrors the intake-route projection PR #191 landed in v1.4.39.3. Reuses `expandTodayIntakes` plus `createMany({ skipDuplicates: true })` race-guard against concurrent intake-route hits minting the same `(userId, medicationId, scheduledFor, REMINDER)` row before the existence probe converges. Plus `medication_intake_events` unique-by-source constraint migration `0073` underlying the `skipDuplicates` semantics, and a `recomputeUserMedicationCompliance` hook after the bulk insert closes the v1.4.39.3 compliance-rollup hook gap on the dashboard surface. Tests 4 648 → 4 662. | [v1.4.39.4](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.4) | +| v1.4.39.3 | Three distinct root causes from the post-v1.4.39.2 e2e regression — `slim?.summaries ?? thick?.summaries` empty-object short-circuit broke the dashboard tile-strip + chart row whenever slim resolved empty (new `merge-slim-thick.ts` helper with object-emptiness discriminator); `MeasurementList` `fmt.integer` silently truncated non-grouped decimal readings since v1.4.37 W7c (pre-existing regression unmasked by the dashboard break); Playwright `**/api/analytics` glob did not match `/api/analytics?slice=summaries` (regex `/\/api\/analytics(\?|$)/` swap across 10 specs). 8 failing e2e → 0 failing e2e. Unit tests 4 654 → 4 662. | [v1.4.39.3](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.3) | +| v1.4.39.2 | Two issues post-v1.4.39.1 — `/api/measurements?source=rollup` short-circuited on `rollupRows.length > 0` so a partially-converged rollup partition returned the sparse rollup instead of falling through (per-request coverage probe gated on rollup sparsity; when rollup < 3 rows on ≥ 7-day window, `COUNT(DISTINCT date_trunc('day', measured_at))` against `measurements` for the same window; if live > rollup, inline-fold the partition via `recomputeUserRollups` and re-read so this request returns the converged rows). Plus the dashboard mother query blocking every per-type tile on the heavy `/api/analytics` thick envelope: dashboard now mounts two `useAnalyticsQuery` calls in parallel — slim (`?slice=summaries`) feeds the per-type tile strip + `lastSeenByType`, thick feeds `bpInTargetPct*` + `glucoseByContext` (slim resolves first → tile strip paints; thick streams BD-Zielbereich + glucose tiles in afterwards). | [v1.4.39.2](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.2) | | v1.4.39.1 | Rollup tier catches Withings + import + admin-restore write paths — Marc reported "Noch nicht genügend Daten" on dashboard tiles at the 30-point range but the same tiles rendered at 7 or 90. Live-log probe of `/api/measurements?type=BLOOD_PRESSURE_SYS&aggregate=daily&source=rollup` returned `total: 2` for the trailing 30 days; WEIGHT (manually entered via the route that fires the hook) returned 19. Three measurement write paths bypassed the rollup write hook since the v1.4.36 chart `source=rollup` fast-path landed: Withings sync (`sync.ts`, `sync-activity.ts`, `sync-sleep.ts`), `/api/import`, admin backup restore. v1.4.39's boot backfill caught up once on deploy, then immediately re-staled the recent days because the hook still wasn't wired and the per-type-only discovery query no longer re-surfaced the account. Fix: wire `recomputeBucketsForMeasurement` (via `collapseToTypeDayKeys`) into every bypass site; widen the boot-backfill discovery's LEFT JOIN to `(user, type, bucket_start)` so any per-day gap re-surfaces the account on the next worker boot. Per-Withings-sync cost: ≤30 DAY recomputes per 30-day catch-up, best-effort, never fails the user's sync. Tests 4 640 → 4 648. | [v1.4.39.1](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39.1) | | v1.4.39 | Mood + medication-compliance + cumulative-sum rollup tiers — extends the v1.4.38 fast-path posture to the two remaining cold-mount hot spots. New `mood_entry_rollups` (per-(user, granularity, bucket) mood stats) eliminates the unbounded `MoodEntry.findMany` walk on `/api/mood/analytics` (12.7 s → ~200 ms expected). New `medication_compliance_rollups` (per-(user, medication, day) scheduled / taken / skipped ledger anchored to user-tz `YYYY-MM-DD`) eliminates the unbounded intake-event walk on `/api/medications/intake?scope=compliance` (3.2 s → ~200 ms expected). New nullable `measurement_rollups.sum_value` column lets cumulative metrics (`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE / TIME_IN_DAYLIGHT / ACTIVE_ENERGY`) skip per-type JS aggregation in the dashboard sparkline + `groupBy=day` paths. Defense-in-depth `since` cap on the `/api/analytics` live-fallback path (~347 k rows → ~5 k rows; 425 d window preserves `summary.avg30LastYear`). New WEEK / MONTH / YEAR reader helpers ready for the v1.5 multi-year trend card. Five wave agents in parallel + six QA reviewers + nine reconcile fixes (race-safe atomic upsert, partial-coverage probe, fire-and-forget worker enqueue, multi-entry-day summarize parity, DST-boundary test pin, user-scoped backfill enqueue, …). Tests 4 524 → 4 640 (+116). Squash to main clean. | [v1.4.39](https://github.com/MBombeck/HealthLog/releases/tag/v1.4.39) | diff --git a/.planning/round-v1440-QA-product-lead-findings.md b/.planning/round-v1440-QA-product-lead-findings.md new file mode 100644 index 000000000..17d39fd18 --- /dev/null +++ b/.planning/round-v1440-QA-product-lead-findings.md @@ -0,0 +1,357 @@ +# v1.4.40 QA — Product-Lead Review + +Reviewer: Product Lead (read-only, strategic). +Inputs: 11 wave reports (W-POOL, W-RSC, W-INSIGHTS, W-DELETED, +W-WMY-WIRE, W-GHOSTS, W-INFRA, W-PRIVACY, W-CONSENT, W-AASA, +W-APNS-NOTIFY), the v1.4.39 architecture-QA quadrology +(`round-v1439-arch-qa-{frontend,infra-db,ghosts,organization}.md`), +`round-v1439-empirical-trace.md`, `round-v1440-backlog.md`, +`git log c9d5479b..HEAD` (54 commits on `develop`). + +## Strategic posture + +v1.4.40 is the first **architecture-debt-close** release of the cycle: +the four parallel auditors that ran post-v1.4.39 surfaced a coherent +"sound-with-debt" picture (rollup tier landed; half the read surface +never migrated; per-chart fan-out blocks paint; soft-delete half-wired; +WMY write-only; dead-code accretion across five marathons), and Marc +explicitly directed "don't defer Critical+High to a later release." The +marathon delivered against that directive — every Critical from every +audit closed in-band, plus the v1.4.39.4 compliance-rollup hook gap +that surfaced mid-marathon, plus SB-3/4/5/6/7/10 from the iOS team's +server-backlog. The shape of the release is structurally different +from v1.4.36-39 (which were perf-tier shipments inside a stable +architecture): v1.4.40 is a **read-path retrofit + soft-delete +invariance + organizational umbrella** release, with the iOS-foundation +work (consent receipts, AASA, time-sensitive APNs, per-event status +ledger) landing as touch-disjoint side-cars rather than the main payload. + +The release deliberately stays inside additive-only contracts. Zero +breaking API changes; one new table (`ConsentReceipt` from W-SCHEMA +preflight); four new endpoints (3× consent, 1× notifications/status +extension); structural moves (`src/lib/rollups/` umbrella absorbs 7 +files across 3 directories, 68 import sites rewritten); perf fixes +(p-limit(4) on the 15-way analytics fan-out + `pg.Pool` max 10→20). +The "consume what we already wrote" theme runs through three waves: +W-INSIGHTS swaps three Insights routes onto the mood-rollup tier +(v1.4.39's writer-only landing), W-WMY-WIRE wires summaries-slice + +health-score onto the MONTH bucket (v1.4.34.4's writer-only landing), +W-DELETED makes 13 reader paths honour the `deletedAt` column iOS +v0.5.4 has been writing since 2026-05-11. Each is the kind of debt +that only an outside-perspective audit pass surfaces; collectively +they restore "what's written equals what's read" as an invariant. + +v1.5 readiness is **green from this release's perspective**. The iOS +team is genuinely unblocked: SB-3 (privacy policy DE+EN at `/privacy`) ++ SB-4 (AASA at `.well-known/apple-app-site-association`) + SB-5 +(time-sensitive APNs) + SB-6 (per-event status ledger) + SB-7 +(registration-status pinning) + SB-10 (consent receipts) all shipped. +The only iOS-side caveat is **AP-2**: SB-5's time-sensitive flag is +inert until the `.p8` APNs key is installed on production Coolify +env (`APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_BUNDLE_ID` / `APNS_KEY`). +SB-8 (dashboard tile-visibility migration) is investigation-only per +the audit decision, and SB-9 (streak deprecation) is optional — neither +gates App-Store submission. iOS v0.5.4 does not break against +v1.4.40: every shipped contract change is additive (new endpoints, +extended response envelopes with backward-compatible field additions). + +## Perf claim audit + +| Endpoint / surface | Claim | Evidence | Confidence | +|---|---|---|---| +| `/api/analytics` thick + 6× chart-tile burst | First-paint of chart row 7.3 s → ~1.6 s (empirical trace §F1) | p-limit(4) wrap on per-type Promise.all + Prisma `pg.Pool` max 10→20 + new analytics-route concurrency test asserting `peak ≤ 4` and timing bound `40ms ≤ elapsed < calls×20ms` | **Medium** — the test pins the structural cap, but the 7.3 s → 1.6 s wall-clock is an extrapolation from the trace's pool-stall hypothesis. Post-deploy verify against Marc's tenant required. | +| `/api/analytics` thick single cold | Absolute regression 10-15 % (4 batches of 15-way work) | Audit estimate; not measured | **Medium** — accepted trade-off because the 60 s LRU collapses warm mounts to a Map lookup; only the cold first-mount pays the penalty. | +| `/api/insights/targets` cold | ≤ 500 ms (was 12 s — unbounded mood walk + 347 k-row distinct sort) | rollup-fast-path + 365-day distinct floor + 4 route tests pinning rollup-vs-coverage-fallback shape | **Medium** — extrapolation from the `/api/mood/analytics` 12.7 s→200ms parity. The distinct-sort fix is the dominant share; mood-tier swap is the second-order win. | +| `/api/insights/comprehensive` cold | 90-day mood walk → rollup tier (≤ 90 row reads) | rollup + coverage-fallback parity tests | **High** — read-shape change with parity contract. | +| `/api/insights/generate` AI feature payload | Mood block bounded to ≤ 1 800 rows (was every entry ever) | features.test.ts extended with rollup mock + warm-up stub | **High** — payload shape change with test. | +| `/api/dashboard/summary` `avg30LastYear` field | Now populated via MONTH-tier read (was hardcoded `null`) | `computeAvg30LastYearMap` helper + 4 new tests; `slim_summaries.year_over_year_types` annotate visible in production wide-events | **High** — semantic-correctness fix (UI control was unreachable). | +| `/api/health-score` weight long-window | MONTH-tier read added alongside the 37-day DAY read; `healthScore.weightLongWindow` annotate | 3 long-window tests + parallel-read shape | **High** for correctness + annotate; **N/A** for wall-clock (additive read, not replacement). | +| Dashboard cold mount HTTP requests | 10-12 parallel → 9-11 (mood-chart queryKey dedup eliminates one) | `1dd1a9a7` re-keys mood-chart to `queryKeys.moodAnalytics()` + factory-bypass test guard | **High** — TanStack dedup is deterministic when keys match. | +| Soft-delete invariance | Tombstoned rows excluded from 13 reader tiers | 3 integration tests (analytics summaries / dashboard summary / rollup recompute) pin the contract; 8 atomic fix commits | **High** — every read pin uses a 99.0 sentinel that would have exploded `max` if the filter leaked. | +| Dashboard per-tile Suspense boundaries | Per-tile streaming, no row-blocking | 5 new structural tests pin the boundaries + memoized `userTimezone` + module-scope `DASHBOARD_QUERY_OPTS` | **High** for structural guarantee; **subjective UX** for visible win — needs prod observation. | +| Dead-code purge | -1 177 source lines across 9 atomic commits | knip CI gate now enforces `files,dependencies,binaries,unlisted`; 7 dead exports + 6 i18n keys + 2 orphan routes + 1 dead pg-boss queue + 2 dup tz helpers removed | **High** — line count + knip exit-0 are deterministic. | + +The pattern across the marathon is consistent: **structural pins (Suspense +boundaries, factory enforcement, concurrency caps, soft-delete filters, +queryKey dedup, rollup-tier read swaps) all have unit / integration test +coverage**, while wall-clock estimates ride the post-deploy +`round-v1440-perf-verify.md` window. This is the standard for the +"close the audit" releases (v1.4.34.4, v1.4.38, v1.4.39) — three of +those four had soft claims that the post-deploy verify caught. The +release notes should phrase chart-row first-paint as "expected ~1.6 s" +and Insights cold mounts as "expected ≤ 500 ms" until verified. + +## Release-readiness verdict + +- **Ship as v1.4.40: YES.** All eleven waves landed across 54 commits + on `develop`. Quality gates green per each report (54 / 54 typecheck + ok, 11 / 11 lint clean on touched files modulo the pre-existing + `app/page.tsx:577` which W-INFRA Thread 2 closed at commit + `8974e773`, and the consent route's tracked pre-existing warning). + Test delta +85 unit (`4 631 → 4 716` per W-INSIGHTS / W-RSC / + W-DELETED / W-WMY-WIRE / W-CONSENT / W-APNS-NOTIFY / W-PRIVACY / + W-AASA / W-POOL contributions; W-GHOSTS removed test files for + deletions). No new API contract breaks; one additive migration + (`ConsentReceipt` from W-SCHEMA); structural moves under + `src/lib/rollups/` keep public exports byte-equal (68 import sites + rewritten cleanly per W-INFRA verification). Rollback to v1.4.39.4 + is clean — every rollup-tier read carries a documented live + fallback; consent endpoints are append-only so revoke is reversible; + AASA / privacy / APNs payload changes are additive. + +- **Critical + High audit close — verified:** + - **Frontend Critical C1 (per-chart fan-out)** → W-POOL caps the + analytics fan-out at 4 concurrent + W-RSC streams chart tiles + individually via Suspense. Empirical-trace #1 (pool starvation) + closed. + - **Frontend Critical C2 (chart-data not in + `measurementDependentKeys`)** → W-RSC adds `chart-data` and + `dashboard-medication-compliance` to the bundle + 16-assertion + factory enforcement test. + - **Frontend High H1 (154 bare queryKeys)** → W-RSC migrates the + dashboard + chart subset + ships a factory-bypass test guard + over `src/components/charts`, `src/app/page.tsx`, + `src/hooks/use-auth.ts`. Long-tail 154-site migration deferred + to v1.4.41 (opt-in expansion of `guardedRoots`). + - **Frontend High H2 (no Suspense, no useSuspenseQuery)** → W-RSC + wraps every tile + chart row in `` boundaries; full + RSC migration of `app/page.tsx` deferred to v1.4.41 with + documented migration path (Suspense boundaries are the + prerequisite, now in place). + - **Frontend High H4 (`getHourForTimeZone` no memo)** → W-RSC + memoises on `userTimezone`; W-INFRA Thread 2 fixes the React + Compiler regression that resulted. + - **Infra+DB Critical #1 (WMY write-only)** → W-WMY-WIRE consumes + MONTH buckets in `summaries-slice` (avg30LastYear) + + `health-score-fast-path` (weight long-window), with + cross-consumer routing parity test pinning the 90/365/1 095 day + routing. + - **Infra+DB Critical #2 (6 unbounded mood walks)** → W-INSIGHTS + closed three (`insights/targets`, `insights/comprehensive`, + `lib/insights/features`); deferred three (`cards` — no mood + query; `glp1-timeline` — needs per-entry tags; `gamification/ + achievements` — needs Berlin-anchored day-key). The deferred + three are properly explained (tier-shape mismatches that the + v1.5 per-user-tz tier resolves), not skipped. + - **Infra+DB Critical #3 (`Measurement.deletedAt` half-wired)** → + W-DELETED filters 13 reader tiers (rollups recompute + 4 + summaries-slice queries + correlations + bp-fast-path + 3 + health-score + 8 comprehensive-aggregator + dashboard summary + + measurements list + series + Coach snapshot); 3 integration + tests pin the contract end-to-end. + - **Infra+DB Critical #4 (compliance-rollup hook gap on bulk + create)** → W-INSIGHTS wires `recompute` after + `medications/intake?scope=today` and `dashboard/summary` + `projectAndReadTodaysIntakes`. Set-coalescing prevents + fan-out storms. + - **Infra+DB Critical #5 (`/api/analytics` route deletedAt + leaks)** → W-INFRA Thread 1 picked up the three sites W-POOL + + W-DELETED both skipped (glucose context summaries, sleep-stage + breakdown, `fetchMeasurementSeriesChunked`). Closed. + - **Ghost-Hunter dead code** → W-GHOSTS removed 1 177 lines + across 9 atomic commits (TELEGRAM_CLEANUP_QUEUE; orphan + intake-summary route; duplicate monitoring/{umami,glitchtip}/test + routes; 7 dead exports; 18 dead i18n keys across 6 locales; + consolidated `startOfUtcDay` + `wallClockInTz`). Kept 3 false + positives with documented rationale (`detectAnomalies` + transitively used; `ensureUserMedicationComplianceFresh` — see + below; `readWeek/Month/YearRollups` wired by W-WMY-WIRE). + - **Org-audit Strategic Recommendation #1 (`src/lib/rollups/` + umbrella)** → W-INFRA Thread 3 moved 7 files across 3 + directories under `src/lib/rollups/`; 68 import sites rewritten; + typecheck + 93 rollup tests green; pure relocation, zero + behavioural change. + - **Org-audit Strategic Recommendation #2 (`src/types/` DTO + promotion)** + **#3 (prompt directory unification)** → + Deferred to v1.4.41 per Marc's directive in `W-INFRA`'s + "Items deferred" section (avoid clashing with in-flight + imports + needs its own wave). Documented carry-over. + +- **Carry-overs to v1.4.41:** + - W-INSIGHTS deferred swaps (`cards/route.ts` no-op; `glp1-timeline` + mood-tag aggregation; `gamification/achievements` Berlin-anchored + day-key) — gated on v1.5 per-user-tz mood tier. + - W-INSIGHTS SB-7 follow-up: `/api/auth/check-user` discovery + branches (the directive's mention of user-exists / passkey-only + / email-fallback maps to that route, not `/registration-status` + which W-INSIGHTS pinned). + - W-RSC RSC migration of `app/page.tsx` — Suspense scaffolding is + in place; the migration is now a one-pass composition swap. + - W-RSC custom ESLint `no-restricted-syntax` rule for queryKey + factory — test-based guard substitutes today; rule is the + long-term enforcement. + - W-RSC 154-site queryKey factory migration long tail (admin, + settings, medications, integrations directories) — opt-in + expansion of `guardedRoots`. + - W-INFRA Thread 3 follow-up: `src/types/` DTO promotion + prompt + directory unification. + - W-INFRA Thread 4 follow-up: enforce knip on `exports` + `types` + once the historic 487 unused exports / 52 unused types are + cleared incrementally; delete the 3 ignored barrel files + (`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, + `src/lib/logging/index.ts`) once their dedicated tests can also + be removed. + - W-CONSENT iOS Settings hook-up (iOS-side) + AI feature gate + read-side using `latestActiveReceipt(userId, kind)`. + - W-APNS-NOTIFY `NotificationDispatch` table for the other 6 event + types (only `MOOD_REMINDER` has a ledger today; the other six + return `null` until the table lands). + - W-WMY-WIRE: `computeLongWindowSummary` still has no production + consumer (the in-marathon wire-in fans out through internal + helpers, not the v1.4.39 public export). Production-call-site + on the export remains the v1.5 multi-year UI consumer's job. + - F-M-02 (rollup writers' dead `tx` parameter from v1.4.39 + backlog), F-M-03 (partial index `sum_value IS NULL`), F-M-05 + (`RollupBucketRow` shape consolidation), Specialist-M01 + (drop legacy-NULL UNION arm post-convergence), Simplifier + dead exports (`isCumulativeType`, `readCumulativeDaySums`, + `resolveBucketSum`, `ensureUserMedicationComplianceFresh`) — + rolled forward from v1.4.39 backlog; none of these gate iOS work. + - v1.5 architectural backlog (cross-tz per-user-tz bucketing, P5 + per-source rollup, P8 slope-window SQL move) — unchanged from + v1.4.38 closure. + +- **v1.5 readiness:** unblocked. + - SB-3 / SB-4 / SB-5 / SB-6 / SB-7 / SB-10 closed. + - SB-8 deferred as investigation-only per audit decision (not a + blocker). + - SB-9 optional (streak deprecation has no functional dependency). + - AP-2 caveat: SB-5's time-sensitive APNs flag is inert until the + `.p8` key is installed on production. **Release notes must + carry this verbatim** so the iOS team / Marc don't chase the + code when "medication reminders don't break through Focus" + surfaces post-deploy. + - iOS v0.5.4 compatibility verified: every shipped contract is + additive (new endpoints, backward-compatible response envelope + extensions). No new breaking endpoints. + - Coolify auto-deploy fix from v1.4.38.4 is still in effect; the + knip CI gate W-INFRA added runs on `push to main` + PRs targeting + `main`, so the gate does not interfere with develop velocity. + +## Risk register + +- **Wall-clock perf wins are audit estimates, not measurements** — + empirical-trace #1 (7.3 s pool stall → 1.6 s) is grounded in a + Playwright-driven local trace against a Marc-shape fixture (82 490 + measurements × 8 types) but not against the production tenant. + Mitigation: post-deploy `round-v1440-perf-verify.md` against Marc's + tenant; release-notes phrase wins as "expected" until verified; + rollback to v1.4.39.4 is clean (rollup reads have live fallback; + Suspense boundaries are additive; concurrency cap is per-request). + +- **Cross-agent commit-attribution drift — 4th recurring occurrence + (process bug)** — W-APNS-NOTIFY's SB-6 work landed inside W-DELETED's + `1bcaae47` dashboard-summary commit when the pre-commit hook + absorbed staged-but-not-yet-committed parallel-wave changes. + W-WMY-WIRE caught and reset a similar staging accident before push + (`git reset --soft HEAD~1` recovery). W-RSC's factory-routing + commit `8187d549` similarly drifted into the parallel APNS commit. + This is the 4th recurring occurrence (also flagged in + `project_v1437_final_web_release.md`, v1.4.38 marathon, v1.4.39 + product-lead review). **Mitigation for v1.4.41 marathon: per-agent + `git worktree` is now a hard rule, not a recommendation.** Marc-Voice + memory `feedback_marathon_worktree_isolation.md` does not exist yet; + recommend creating it with the directive "every parallel wave runs in + a dedicated worktree via `git worktree add`; no shared cwd across + agents." Add worktree-per-wave to the `release-marathon` skill's + standard flow. The release-notes / CHANGELOG sweep for v1.4.40 needs + to pull SB-6 details from `1bcaae47` even though the commit message + says `fix(dashboard-summary): exclude soft-deleted in sparkline and + streak queries` — the diff carries +67 lines on the notifications + status route + a new 181-line test file beyond what the title + suggests. + +- **Soft-delete contract — coverage gap on out-of-scope readers** — + W-DELETED explicitly punted on read-only export / admin / + doctor-report / gamification / reminder-worker / pr-detection-worker + / withings paths. Risk: a v1.5 export bug-report could include + tombstoned rows in a CSV; the doctor-report PDF could show a + tombstoned reading. Mitigation: documented in W-DELETED's "NOT + touched" section; schedule a v1.4.41 W-DELETED-2 sweep covering the + read-only surfaces. Low user-facing impact today (Marc has not + reported soft-delete usage), but iOS v0.5.4's by-external-ids sync + is already capable of writing `deletedAt`. + +- **APNS `.p8` env-var gap (AP-2)** — SB-5's time-sensitive + interruption-level is inert until the production Coolify env has + `APNS_KEY_ID` + `APNS_TEAM_ID` + `APNS_BUNDLE_ID` + one of + `APNS_KEY` / `APNS_KEY_FILE`. The dispatcher gates every send on + `loadApnsConfig()` returning non-null. Mitigation: release-notes + must carry the AP-2 dependency verbatim; the iOS team should not + attempt to verify the time-sensitive flag until the env is + installed. Recommend: pre-deploy Coolify env check as a release-day + gate. + +- **W-CONSENT iOS Settings hook-up — uncalled today** — the consent + endpoints (`POST /api/consent/ai`, `GET /api/consent/ai/latest`, + `DELETE /api/consent/ai/latest`) are tested + audit-logged + GDPR + Art. 7 compliant, but no client calls them yet. Risk: app-store + reviewer following the Privacy Policy may exercise the "AI + deaktivieren" toggle and find it doesn't actually revoke. Mitigation: + iOS Settings hook-up before App-Store submission; release-notes + flag the contract for the iOS team. Documented in W-CONSENT report + as "Notes for downstream waves". + +- **`ensureUserMedicationComplianceFresh` ambiguity** — W-GHOSTS held + this in the false-positives list per the W-INSIGHTS brief saying it + was being wired in parallel. W-INSIGHTS's report doesn't mention + wiring it — only the `recomputeMedicationComplianceForEvent` hook + path lands. Risk: dead-but-undeleted export survives in production + bundle. Mitigation: trivial follow-up grep in v1.4.41 — either drop + or wire. No functional impact. + +- **`computeLongWindowSummary` still uncalled** — v1.4.39 W-WMY's + public export, intended for a v1.5 multi-year UI consumer, still has + no production caller after W-WMY-WIRE's marathon (the wire-in fanned + out through internal helpers `computeAvg30LastYearForType` / + `computeAvg30LastYearMap` because the use case is per-type-with-data + not single-type). Risk: dead-tested code in production bundle. + Mitigation: noted in v1.4.39 backlog `F-M-01`; v1.5 "year-in-mood" + tile or Coach long-window prompt is the intended consumer; cheap to + retain. + +- **knip CI gate exclusion list** — W-INFRA Thread 4 ships knip with + `exports` + `types` deliberately excluded from the failure set + (487 unused exports + 52 unused types historic backlog would have + made the gate red on day one). Risk: a new dead export ships + silently because the gate doesn't enforce `exports`. Mitigation: + Enforce knip on `exports` + `types` once the historic backlog is + cleared incrementally — v1.4.41 / v1.4.42 should pick one module + slice per release for cleanup. + +- **Cross-tz proper-fix still v1.5** — the v1.4.38 cheap path + (runtime guard with live fallback for non-near-UTC) remains in + place; v1.4.39 mood + compliance rollups inherit the UTC-anchor + trade-off; v1.4.40 didn't touch this. Risk: non-Berlin tenants + silently fall through to live SQL for some tiers. Mitigation: + documented across the rollup-tier audits; v1.5 per-user-tz + bucketing closes the gap; Marc's tenant is fine. + +- **Semver call — patch bump (1.4.40) confirmed** — features added: + 1 new table (`ConsentReceipt`), 4 new endpoints (3× consent, 1× + notifications/status extended response), structural moves (umbrella + rollups directory, queryKey factory entries), perf fixes + (concurrency cap, pg.Pool max bump), 11 wave reports' worth of + audit-debt closure. Zero API contract breaks. Per Marc's + conservative-semver doctrine + (`feedback_semver_conservative.md` — bugfix-heavy releases with + additive-only features stay on patch), this is squarely additive + inside the existing minor cycle → **v1.4.40 is the correct call** + (v1.5 stays reserved for the iOS Swift sprint). + +- **Release-marathon retrospective items** — beyond the worktree + isolation directive above: + - Eleven-wave dispatch with touch-disjoint partitioning worked + cleanly (one collision caught + reset before push; one + pre-commit-hook fold absorbed across waves but no functional + loss). The "DO NOT touch" sections in each wave brief were the + load-bearing discipline. + - The W-INFRA Thread 1 pickup of W-POOL + W-DELETED gaps proves + the value of a sequential cleanup pass after the parallel + waves land. Recommend: every multi-wave marathon ends with a + `W-INFRA`-shape cleanup wave that re-walks the audit findings + against the merged state. + - knip CI gate (W-INFRA Thread 4) is the structural answer to + "another marathon, another 1 000+ lines of dead code". The + gate now enforces forward; the historic backlog needs a + cleanup cadence. diff --git a/.planning/round-v1440-QA-security-findings.md b/.planning/round-v1440-QA-security-findings.md new file mode 100644 index 000000000..e33c784db --- /dev/null +++ b/.planning/round-v1440-QA-security-findings.md @@ -0,0 +1,81 @@ +# v1.4.40 Security Review + +**Reviewer**: security (read-only) +**Range**: `v1.4.39.4..develop` (HEAD `a35e9b30`), 55 commits, 11 waves +**Scope**: new SQL surface (migration 0074 / `ConsentReceipt`), 4 new API routes (consent POST/GET/DELETE, notifications/status), AASA well-known, privacy page, APNs interruption-level, deletedAt full-wire, mood-rollup insights swap, knip CI + +## Critical + +_None._ + +## High + +_None._ + +## Medium + +### M-1 — Consent artefact cap is by character count, not bytes + +`src/lib/validations/consent.ts:40` enforces `.max(64 * 1024)` on the artefact string. Zod `.max()` on a string counts UTF-16 code units, not bytes. The doc-comment claims "64 KB" but the practical worst-case is up to ~256 KB on the wire if a client deliberately stuffs 4-byte UTF-8 code points (or surrogate pairs). The published contract is "base64 PDF or signed JWT" — both pure ASCII — so a well-behaved client never gets close, and Next.js' default ~1 MB request limit bounds the upstream parser regardless. Severity is Medium rather than High because the audit-table abuse vector is small and Next still caps body buffering, but the cap should use a byte-count helper (`Buffer.byteLength(artefact, "utf8") <= 64 * 1024`) before insert to honour the comment. + +- File: `src/lib/validations/consent.ts:33-40` + +## Low + +### L-1 — `safeJson` has no explicit body-size cap + +`src/lib/api-response.ts:48-61` calls `request.json()` without bounding the body. Next.js' default request limit (~1 MB on a Node runtime) is the only ceiling. Consent POST is documented as a few-KB-to-32-KB payload; an attacker can still buffer up to ~1 MB before the artefact's own `.max()` check fires. Combined with M-1, this is the layer that defines the real worst-case in-memory footprint. Acceptable for now; consider a `route.config` body-size override on consent + future write endpoints. + +### L-2 — APNs `category` echoed to the device equals the event-type + +`src/lib/notifications/senders/apns.ts:456` sets `category: payload.eventType`. The same string is already passed via `data.eventType`, so the category field does not add new disclosure — but the iOS app receives the full canonical category name (`MOOD_REMINDER`, `WITHINGS_SYNC_FAILED`, etc.) on every push. That's authenticated user → their own device, so it is not a tenant leak. Flagged Low only because the brief asked the field be reviewed; the gating itself is correct. + +## Item-by-item verdict (brief) + +1. **AASA payload** (`src/app/.well-known/apple-app-site-association/route.ts`): clean. Static constant containing the published Team ID + Bundle ID (`S8WDX4W5KX.dev.healthlog.app`), `applinks` matcher `["*"]`, `webcredentials` for passkey ceremony. No route enumeration, no host-specific data. **PASS**. + +2. **Privacy page** (`src/app/privacy/page.tsx`, 2356 lines added): no Marc-last-name + health-figure pair, no BD-Zielbereich values, no measurement counts. Public references to `mbombeck@gmail.com` + GitHub handle `MBombeck` + Hetzner are GDPR-disclosure norms ("controller contact"), not internal/PII per `feedback_no_pii_in_user_facing.md`. No Coolify/IPs/secrets. **PASS**. + +3. **Consent endpoints**: + - POST `src/app/api/consent/ai/route.ts`: `requireAuth()` first, Zod-validates body, `createReceipt(user.id, …)`. Audit-log fired on grant. + - GET/DELETE `src/app/api/consent/ai/latest/route.ts`: `requireAuth()` first, every `latestActiveReceipt` / `revokeLatest` / `latestActiveReceiptsByKind` call passes `user.id` from the session — no caller-supplied user-id parameter. Idempotent delete returns 200 (correct). + - `serialiseReceipt` strips `artefact` from the API response — only the opaque audit blob lives in the DB; never echoed back over the wire. **PASS** (modulo M-1). + +4. **Notifications status** (`src/app/api/notifications/status/route.ts`): `requireAuth()` first, both `prisma.notificationChannel.findMany({ where: { userId: user.id } })` and `loadEventStatuses(user.id)` (which scopes `moodReminderDispatch` by the same userId). The events map shape is per-user; no cross-tenant delivery counts. **PASS**. + +5. **APNs interruption-level** (`src/lib/notifications/senders/apns.ts:439`): `const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";` — strict equality on a single event-type. Only that branch sets `interruptionLevel: "time-sensitive"` + `priority: 10`. All other event-types stay on the iOS default `active` level. Focus modes (including Sleep) silence them as expected. Telegram `replyMarkup` leak protected by `IOS_METADATA_ALLOWLIST` (lines 528-535). **PASS**. + +6. **deletedAt full-wire**: every changed read path threads `deletedAt: null`: + - `src/app/api/measurements/route.ts` — GET list, GET aggregated, raw-SQL aggregator (added `AND m."deleted_at" IS NULL`). + - `src/app/api/measurements/series/route.ts` — BP-pair and single-kind branches. + - `src/app/api/dashboard/summary/route.ts` — raw 7-day sparkline SQL, groupBy(type), 365-day raw streak SQL. + - `src/app/api/analytics/route.ts` — `fetchMeasurementSeriesChunked`, glucose context, sleep-stage breakdown. + - `src/lib/insights/features.ts` (latest-by-type) — `deletedAt: null` added. + The dedicated `sync/state` route is documented as the sole consumer that intentionally surfaces tombstones (for iOS reconcile). **PASS**. + +7. **Insights mood-rollup swap** (`src/app/api/insights/comprehensive/route.ts`, `src/app/api/insights/targets/route.ts`): `ensureUserMoodRollupsFresh(userId)` + `readMoodDayRollups(userId, since)` both scope by userId. The internal raw-SQL writer (`src/lib/rollups/mood-rollups.ts:188-235`) uses parameterised `${userId}` throughout the `WITH aggregate … WHERE m."user_id" = ${userId}` upsert + delete pair. Cold-start fallback in `comprehensive/route.ts:128-152` retains `where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }`. **PASS**. + +8. **Migration 0074** (`prisma/migrations/0074_v1440_consent_receipts/migration.sql`): table `consent_receipts` with `user_id` FK → `users(id)` `ON DELETE CASCADE ON UPDATE CASCADE`. Wrapped in `IF NOT EXISTS` + `DO $$ BEGIN … EXCEPTION WHEN duplicate_object` guards (idempotent, mirrors 0067/0070/0071). Compound index `(user_id, created_at DESC)`. Cascade is correct — a user erasure under GDPR Art. 17 drops the corresponding consent receipts, completing the deletion. **PASS**. + +9. **knip CI** (`.github/workflows/knip.yml`): `permissions: contents: read` (least-privilege). No `secrets` references, no env exposure, no third-party uploader steps. `pnpm install --frozen-lockfile`, `pnpm db:generate`, `pnpm knip` — no remote artefact write. **PASS**. + +## Surface delta summary + +| Path | Risk | Verdict | +| --- | --- | --- | +| `prisma/migrations/0074_v1440_consent_receipts/migration.sql` | Schema (FK cascade) | PASS | +| `src/app/.well-known/apple-app-site-association/route.ts` | Public unauthenticated | PASS | +| `src/app/privacy/page.tsx` | Public unauthenticated | PASS (no PII) | +| `src/app/api/consent/ai/route.ts` | Authenticated write | PASS (M-1 below cap shape) | +| `src/app/api/consent/ai/latest/route.ts` | Authenticated read/delete | PASS | +| `src/app/api/notifications/status/route.ts` | Authenticated read | PASS | +| `src/lib/notifications/senders/apns.ts` | Outbound channel | PASS | +| `src/lib/rollups/mood-rollups.ts` | Internal SQL | PASS (param `${userId}` throughout) | +| `src/lib/insights/features.ts` + `comprehensive-aggregator.ts` | Internal read | PASS | +| `.github/workflows/knip.yml` | CI | PASS | + +## Brief-back (≤200 words) + +v1.4.40 ships a clean security surface. The new `ConsentReceipt` write path (`POST /api/consent/ai`) authenticates first, Zod-validates, stores opaque artefacts, and strips them from the response. The latest-receipt reader and revoke endpoints scope every query by the authenticated `user.id` — no caller-supplied user-id, no cross-tenant leak. Migration 0074 cascades on user delete, satisfying GDPR Art. 17. The AASA file ships only the published Team ID + Bundle ID. The privacy page contains no Marc-PII / no operator-internal info (Hetzner is the actual public hosting fact, GitHub handle + email are deliberate controller-contact disclosure). APNs interruption-level is correctly gated to `MEDICATION_REMINDER` via strict-equality on the event-type; the existing `IOS_METADATA_ALLOWLIST` keeps Telegram's `replyMarkup` from leaking into APNs userInfo. Every changed read path threads `deletedAt: null` (raw SQL and Prisma alike). Mood-rollup swap's raw SQL uses parameterised `${userId}`. knip CI runs with `contents: read` only, no secret access. + +One Medium (M-1): consent artefact cap is character-count, not byte-count — practical impact is small (artefacts are ASCII-only) but the comment promises bytes; ship-blocker NO, follow-up YES. Two Low items noted for future hardening. diff --git a/.planning/round-v1440-QA-senior-findings.md b/.planning/round-v1440-QA-senior-findings.md new file mode 100644 index 000000000..957d8eadf --- /dev/null +++ b/.planning/round-v1440-QA-senior-findings.md @@ -0,0 +1,299 @@ +# v1.4.40 Senior-dev review — findings + +Scope: `git diff v1.4.39.4..develop` (55 commits, ~8 100/2 265 lines, +140 files) across 11 waves (`W-POOL`, `W-DELETED`, `W-INFRA`, +`W-INSIGHTS`, `W-PRIVACY`, `W-AASA`, `W-APNS-NOTIFY`, `W-CONSENT`, +`W-RSC`, `W-WMY-WIRE`, `W-GHOSTS`). + +Review method: read all 11 wave reports + 5 v1.4.39 architecture-QA +anchors; spot-checked the highest-risk diffs (`api/analytics/route.ts`, +`lib/db.ts`, `lib/rollups/measurement-rollups.ts`, `app/page.tsx`, +`lib/notifications/senders/apns.ts`, `.well-known/apple-app-site-association/route.ts`); +swept every production `prisma.measurement.*` call site for `deletedAt: +null`; verified five random importer rewrites under the new +`@/lib/rollups/*` umbrella; ran `pnpm typecheck`, `pnpm lint`, `pnpm +knip`, the rollup test suite (93/93), the wave-focused subset (320/320 +across 8 + 32 files), and `pnpm test --run` (full unit suite). + +--- + +## Critical (release-blocking) + +### C1 — Full unit suite is RED on develop: 1 failing test (W-RSC ↔ W-INFRA Thread 2 drift) + +`pnpm test --run` reports **`1 failed | 4725 passed | 1 skipped`** at +HEAD `8e9a7891`. The failure is in +`src/app/__tests__/dashboard-suspense-boundaries.test.ts:78`: + +```js +expect(src).toMatch( + /const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*user\?\.timezone\s*\][\s\S]*?\);/, +); +``` + +W-RSC (commit `3cacfcf9`) wrote the dashboard memo as `useMemo(() => +…, [user?.timezone])`. W-INFRA Thread 2 (commit `8974e773`) had to +lift `const userTimezone = user?.timezone` to a local *before* the +memo to satisfy React Compiler's +`preserve-manual-memoization` rule (optional-chain expression +inferred a less specific property than declared). The post-fix code +at `src/app/page.tsx:577-580` reads `useMemo(…, [userTimezone])`, +which the regex (`user\?\.timezone`) cannot match. + +The W-RSC and W-INFRA reports both flagged "cross-agent commit-message +drift" as a recurring pattern; this is the same pattern producing an +actual test failure, not just a misattributed commit. Marc directive +explicitly requires `pnpm test` to be green at tag time. + +**Fix:** widen the regex to accept `[userTimezone]` as the dep array, +or update both reads to `userTimezone` and lift the local before the +memo so the canonical form is single-source. Either is a one-line +edit; the test was specifically written to be structurally +load-bearing, so updating it without re-introducing the assertion's +purpose is correct. + +**Severity rationale:** every release marathon to date enforces +"green CI" as a gate. Shipping with a red suite would breach Marc's +PII/CHANGELOG/release discipline and erode trust in the gate. + +--- + +## High (must-fix before tag) + +### H1 — Soft-delete leaks remain in the Insights cluster reader surface + +W-DELETED explicitly excluded `src/app/api/insights/**` and +`src/lib/insights/{glp1-plateau,pulse-status,features,…}.ts` with the +note that W-INSIGHTS was "actively modifying for the mood-rollup +swap" and would filter on re-touch. The W-INSIGHTS phase report does +not mention adding `deletedAt: null` to any of the seven +`prisma.measurement.findMany` sites in the cluster. + +Confirmed by spot-grep (production callers, excluding generated + +tests): + +| File | Line | Status | +| --- | --- | --- | +| `src/app/api/insights/targets/route.ts` | 181, 205, 1195 | **no `deletedAt: null`** | +| `src/app/api/insights/cards/route.ts` | 73 | **no `deletedAt: null`** | +| `src/app/api/insights/generate/route.ts` | 136 | **no `deletedAt: null`** | +| `src/lib/insights/features.ts` | 378 | **no `deletedAt: null`** | +| `src/lib/insights/glp1-plateau.ts` | 72 | **no `deletedAt: null`** | +| `src/lib/insights/pulse-status.ts` | 157 | **no `deletedAt: null`** | +| `src/lib/insights/comprehensive-aggregator.ts` | (raw SQL) | filtered | +| `src/lib/ai/coach/snapshot.ts` | 495-500 | filtered | + +These are user-facing surfaces (tile rendering, AI Coach prompts, +GLP-1 plateau alerts, pulse-status insight). Once iOS soft-deletes +its first reading, the tombstoned row still contributes to: + +- `/api/insights/targets` 30-day average + 1-year "latest ever" tile + values (the audit's Critical Finding #2 fix that landed for the + distinct-floor only). +- `/api/insights/cards` the 90-day mood/BP/weight/pulse window the + iOS-adapter card stream consumes. +- `/api/insights/generate` the AI feature payload (so the model + grounds against deleted data). +- The GLP-1 plateau detector + pulse-status helper that feed Coach + + the dashboard tile-strip. + +This is the same bug class the audit's Critical Finding #3 +documented; W-DELETED closed the dashboard + analytics + summaries ++ rollup tiers; the Insights cluster is the only family left +leaking. Either W-INSIGHTS missed the secondary directive or +W-DELETED's deferral note wasn't picked up. + +**Fix:** add `deletedAt: null` to all six `findMany` `where` clauses +above. Three of them already have an `m."deleted_at" IS NULL` sibling +in their adjacent rollup-tier branch (comprehensive-aggregator), so +the team-style pattern is locked in. One commit, one test (extend the +existing `tests/integration/measurement-soft-delete.test.ts`). + +**Severity rationale:** iOS sync ships in v1.5 P1 against locked +contracts. A tombstone leak in Insights would manifest the moment a +user soft-deletes their first reading from the iOS app and refreshes +the dashboard. The W-DELETED integration test pins three contracts; +extending to insights/targets is the cheapest closure of the gap. + +--- + +## Medium (should-fix; otherwise v1.4.41 backlog) + +### M1 — `Promise.all` over `recomputeMedicationComplianceForEvent` is correct, but the comment in the route is misleading + +`src/app/api/medications/intake/route.ts:177-186` + +`src/app/api/dashboard/summary/route.ts:362-376` both call +`Promise.all(recomputeJobs)` with no outer try-catch. + +The comment "best-effort: errors stay caught inside the helper" is +accurate — `recomputeMedicationComplianceForEvent` swallows every +error in its internal try-catch at line 261 and logs an annotate ++ console.error. The helper's signature returns `Promise` +that never rejects. So `Promise.all` is safe in practice. + +**Issue:** the contract is implicit — a future refactor that changes +the helper to re-throw (or a sibling helper added with the same shape +but different error handling) would silently break both bulk write +paths. The convention in `by-external-ids/route.ts:135-137` is the +opposite — an explicit `try { … } catch (err) { console.warn(…) }` +wrapper around the loop. + +**Fix:** either (a) mirror the by-external-ids wrapper pattern for +symmetry, or (b) add an inline comment pointing at line 261 of +`medication-compliance-rollups.ts` so the implicit contract is +discoverable. Cheap follow-up; not release-blocking. + +### M2 — Insights cluster's measurement findMany sites are also unbounded windows in places + +`src/lib/insights/features.ts:378-383` walks `measurement.findMany` +with a conditional `sinceCutoff` — when the caller passes no +`sinceDays`, the query is unbounded. The W-INSIGHTS report claims +the post-swap fallback is 1-year-bounded; the actual code still +admits an unbounded path. Same pattern as the audit High Finding 2 +that the wave was meant to close. + +`src/app/api/insights/generate/route.ts:136-140` likewise has no +`measuredAt` filter at all — it walks every measurement for the +type per AI generation call. + +`src/lib/insights/glp1-plateau.ts:72-80` is windowed (90-day +`PLATEAU_WINDOW_DAYS`). + +`src/lib/insights/pulse-status.ts:157-168` is bounded by `take: 365`. + +**Fix:** wave's stated 1-year floor needs to be enforced in +`features.ts` (else `extractMeasurementFeatures()` walks 5-year +history on every Coach prompt mint) and ideally in `generate/route` +(or document why generate intentionally walks the full history — +which may be load-bearing for trend math). Defer to v1.4.41 unless +Marc wants to fold into the H1 fix above. + +### M3 — Compliance-rollup recompute calls fire in serial within Promise.all but the helper itself runs serially against pg + +`recomputeMedicationComplianceForEvent` (line 245) does one +`prisma.medicationIntakeEvent.findMany` + one `prisma.medicationCompliance*.upsert` +per call. With 5 meds × 3 schedules × 1 day, the wave's `Set` +coalesce already reduces to N=5 (one per medication). N=5 parallel +upserts to pg is well within budget. No issue today; flag because +the comment in W-INSIGHTS' phase report claims "worst case +medications × 1" — true for the coalesce step, but the parallel pg +write fan-out is N, not 1, and shares the same `pg.Pool` that W-POOL +just raised to 20. The combined cold-mount fan-out (analytics × 4 + +compliance × 5 + slim/thick × 2 + tile-strip × 6) stays under the +ceiling. No fix needed. + +### M4 — Knip's `exports` + `types` baseline (487 + 52 historic) is deferred + +W-INFRA Thread 4 deliberately omits `exports` and `types` from the +knip failure set this release to avoid red CI on day one. The +ignored backlog is sizeable. The wave intent is "enforce +incrementally". Risk: a new dead export today sits in the noise +unnoticed for waves. Not release-blocking; the gate is additive over +the previous zero-coverage state. + +**Fix:** open a v1.4.41 wave that picks one slice (`src/lib/analytics`, +say) and turns on `exports` + `types` enforcement scoped to that +slice via `knip.json` per-workspace config. The current +`ignore`/`ignoreDependencies` pattern already proves the per-slice +opt-in approach works. + +--- + +## Low (cosmetic / future-proofing) + +### L1 — AASA route has no charset preservation test for non-ASCII operator names + +The AASA test pins `Content-Type: application/json` exactly (no +charset). The current response body is pure ASCII so there's no +encoding hazard. If a future maintainer adds umlauts (e.g. an +operator field carrying "Nürnberg"), the JSON response would still +serialise UTF-8 by default but the missing `charset` could trip a +DPA reviewer's parser. Out of band for AASA itself (Apple's +`swcd`/aasa-validator refuse `charset` annotations), but worth a +comment in the route file noting the deliberate omission. + +### L2 — Privacy policy `data-slot` markers good for tests but unwired in UI + +W-PRIVACY notes `data-slot="privacy-last-updated"` is "the canonical +hook for any in-app 'policy updated' surface (currently unwired — +would be a v1.5 nice-to-have)". Carrying a test contract for an +unwired UI hook is fine, but if v1.5 doesn't ship the consumer the +hook drifts. Schedule the consumer wire-up alongside the v1.5 iOS +sprint. + +### L3 — Cross-agent commit drift acknowledged in 3 wave reports + +W-APNS-NOTIFY, W-WMY-WIRE, W-RSC each flag commit-attribution drift. +The C1 finding above shows the pattern producing real test breakage, +not just message-level confusion. W-RSC's suggested mitigation +(`git worktree`-per-wave) is the right structural fix. Flag for the +v1.4.41 marathon kick-off directive — single-shared-cwd model leaves +the index race open whenever two parallel agents' `git add`s land in +the same second. + +### L4 — Knip whitelist carries 3 files for a future "delete with their test" pass + +`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, +`src/lib/logging/index.ts`. W-INFRA notes the deletion of these is +deferred pending verification that their dedicated tests are also +removable. Cheap follow-up. + +--- + +## Wave-by-wave verdict + +| Wave | Verdict | Notes | +| --- | --- | --- | +| W-POOL | Ship | `pLimit(4)` correctly preserves 60s LRU cache wrap (per-call instance, not module-level). `pg.Pool max=20` interaction safe (5 × 20 = 100 = Postgres `max_connections` default). | +| W-DELETED | Ship | Scoped to the audit's user-facing critical surfaces. The deferred export/admin/doctor-report/withings + iOS-sync paths are correctly out of scope. **But the W-INSIGHTS hand-off (insights/**) leaked — see H1.** | +| W-INFRA | Ship | Umbrella move clean (zero orphan re-exports — verified by grep on `@/lib/measurements/rollups` etc.). All 93 rollup tests pass post-restructure. Random 3-file importer spot-check (`insights/targets`, `mood-entries`, `insights/comprehensive`) all bind to `@/lib/rollups/mood-rollups`. Knip CI gate scoped sensibly. **Thread 2 broke W-RSC's test — see C1.** | +| W-INSIGHTS | Patch then ship | Mood-rollup swap on the three target routes lands cleanly + compliance-rollup hook gap closed with proper coalescing + SB-7 pinned. **But missed `deletedAt: null` on every prisma.measurement findMany in the cluster — see H1.** | +| W-PRIVACY | Ship | Bilingual paired-section pattern is the right call (legal documents must not depend on JS for locale switching). 17 tests cover the 9 SB-3 requirements + PII discipline. | +| W-AASA | Ship | Payload matches `S8WDX4W5KX.dev.healthlog.app` exactly. App-ID-parity assertion guards against split rotation. Content-Type discipline (no charset) honoured for Apple's swcd. | +| W-APNS-NOTIFY | Ship | Conditional `interruption-level: time-sensitive + priority 10` scoped to MEDICATION_REMINDER only. Parameterised test pins all 6 other event-types do NOT bypass Focus. Backwards-compat `events` map preserves channels shape. **AP-2 .p8 env-var caveat correctly flagged for release notes.** | +| W-CONSENT | Ship | Append-only invariant pinned in test; revoke-then-regrant leaves both rows; idempotent DELETE returns 200 + `receipt: null`. | +| W-RSC | Patch then ship | Per-tile Suspense boundaries genuinely stream independently (the parent gate is the slim/thick analytics merge, which is now correctly split per the v1.4.39.2 commit; the Suspense layer is structural-future-proofing for the v1.4.41 RSC migration). `mood-chart` queryKey dedup eliminates one round-trip. Factory-bypass guard test is a smart cheaper-than-ESLint move. **C1 affects this wave's test asset.** | +| W-WMY-WIRE | Ship | Three previously-dead WMY readers now have production traffic via `summaries-slice` + `health-score-fast-path`. Linear-composability parity test pins the cross-consumer routing contract. | +| W-GHOSTS | Ship | Net ~1 177 lines removed across 9 atomic commits. False positives correctly kept (`detectAnomalies` transitively reachable, the three WMY readers wired by sibling wave). `startOfUtcDay` + `wallClockInTz` consolidation eliminates a sibling-drift risk. | + +--- + +## Quality gates (run during this review) + +| Gate | Status | Notes | +| --- | --- | --- | +| `pnpm typecheck` | green | clean, no errors | +| `pnpm lint` | green | zero warnings, zero errors | +| `pnpm knip --include files,dependencies,binaries,unlisted` | green | zero output, exit 0 | +| `pnpm test --run src/lib/rollups/__tests__` | green | 93/93 passing | +| `pnpm test --run` (full unit) | **RED** | 1 failed / 4725 passed / 1 skipped — see C1 | +| `pnpm test --run` wave subsets | green | 320/320 across analytics, db, notifications, consent, well-known, privacy, insights, medications, dashboard, registration-status | + +--- + +## Brief-back (≤200 words) + +**v1.4.40 is one fix away from ship-ready.** The full unit suite is +red on `develop` because W-INFRA Thread 2 lifted `userTimezone` to a +local in `app/page.tsx` to satisfy the React Compiler memo rule, but +the W-RSC dashboard-suspense-boundaries test still pins the original +`[user?.timezone]` dep shape (C1). One-line regex update or one-line +code edit fixes it. + +The structural release is otherwise solid: 11 waves landed touch- +disjoint, the umbrella move under `@/lib/rollups/*` is clean with +zero orphan imports, AASA payload matches the iOS contract, the +APNs time-sensitive opt-in is correctly scoped to MEDICATION_REMINDER +only, the consent CRUD + append-only invariant pins SB-10, the +per-tile Suspense boundaries are structurally sound, and the +WMY-reader wire-in finally lights up the previously-dead MONTH +buckets. + +The one substantive scope gap is **H1** — the Insights cluster +(`/api/insights/targets`, `cards`, `generate` + the +`lib/insights/{features,glp1-plateau,pulse-status}` helpers) still +walks `prisma.measurement.findMany` without `deletedAt: null`, +because W-DELETED deferred to W-INSIGHTS and W-INSIGHTS missed the +secondary directive. Cheap to close (6 sites, one commit, one test). + +**Ship-readiness verdict:** patch C1 + H1, then tag. diff --git a/.planning/round-v1440-QA-simplifier-findings.md b/.planning/round-v1440-QA-simplifier-findings.md new file mode 100644 index 000000000..eb6d94e39 --- /dev/null +++ b/.planning/round-v1440-QA-simplifier-findings.md @@ -0,0 +1,53 @@ +# v1.4.40 QA — Simplification Findings + +Reviewed `git log --oneline v1.4.39.4..develop` (55 commits across 11 +waves). Focus on what the parallel waves missed: orphan stubs, naming +drift, dead exports still around, near-duplicate code, and bare-array +queryKeys that slipped past W-RSC's factory migration. + +No automated edits were applied — every candidate either touches +multiple wave outputs (deserves a cross-wave reconcile), intersects a +v1.5 contract anchor flagged in the wave reports, or is large enough +that Marc's voice in the commit message matters. + +## Suggested removals + +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:54` — `isCumulativeType` — exported, tested, still no production caller after the umbrella move. Carried forward unchanged from the v1.4.39 simplifier backlog. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:83` — `readCumulativeDaySums` — exported + tested, no production caller. Same v1.4.39 carry-forward. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-cumulative.ts:117` — `resolveBucketSum` — exported + tested, no production caller. The `dashboard/summary/route.ts:441` nested ternary still re-implements the same shape inline. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-read-wmy.ts:125,137,149` — `readWeekRollups` / `readMonthRollups` / `readYearRollups` — only the wmy test file imports them; the production `readBestGranularityRollups` walker uses the internal `readGranularity`. Either downgrade to non-exported helpers or inline into the tests. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/analytics/summaries-slice.ts:659` — `computeLongWindowSummary` — only the file's own test suite imports it. Flagged for v1.5 multi-year-card wiring in the v1.4.40 backlog (F-M-01); still ships as dead code in `develop`. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/medication-compliance-rollups.ts:469` — `ensureUserMedicationComplianceFresh` — the boot-backfill cascade fully owns the cold-mount path; the "kept for symmetry" wrapper has no real caller. v1.4.39 carry-forward. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/mood-rollups.ts:177` — `tx?: PrismaTxOrClient` parameter on `recomputeMoodRollupForEntry` is still unused by every call site (audited 19 references — none pass `tx`). Backlog item F-M-02 from v1.4.39 reconcile. Confidence: certain. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/medication-compliance-rollups.ts:159,250` — `tx?: PrismaLike` parameter on `recomputeMedicationComplianceForEvent` + `recomputeUserMedicationCompliance` likewise dead at every call site. Confidence: certain. + +## Suggested dedups + +- `/Users/marc/Projects/HealthLog/src/app/api/medications/intake/route.ts:94-187` ↔ `/Users/marc/Projects/HealthLog/src/app/api/dashboard/summary/route.ts:295-387` — the entire "find active medications → `expandTodayIntakes` → diff against existing → `createMany skipDuplicates` → fan-out `recomputeMedicationComplianceForEvent` per distinct `(medication, dayKey)`" block is replicated near-verbatim across both routes (W-RSC's audit-L4 hook gap closure shipped the rollup recompute fan-out in both files but did not extract the shared helper). Both files even share the "v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4" comment. Suggested action: extract `projectTodayIntakesAndRecompute(userId, userTz, todayStart, todayEnd)` into `src/lib/medications/scheduling/today-projection.ts` and have both routes call it. Drift here is a real failure mode — the v1.4.39 intake-route projection landed without a matching dashboard hook, which is the bug audit-L4 had to chase. +- `/Users/marc/Projects/HealthLog/src/components/charts/mood-chart.tsx:142` ↔ `/Users/marc/Projects/HealthLog/src/components/charts/health-chart.tsx:261` — `movingAverageByPoints` is byte-identical across the two chart components. W-GHOSTS removed the lib-level `movingAverage` (29bfcc67) because nothing consumed the windowed-by-points variant; both chart files carry their own private copy. Suggested action: extract a shared `src/lib/charts/moving-average.ts` with two functions (`byPoints` + the un-windowed reducer) so a future "switch from MA-7 to EMA-7" lands in one place. Pure helper, deterministic, trivial to unit-pin. +- `/Users/marc/Projects/HealthLog/src/components/charts/mood-chart.tsx:162` (`buildTrendLine`) — only used inside mood-chart today, but the health-chart `computeTrendLine` neighbour does the same linear-regression-over-day-offset math against the same date/value pair shape. Suggested action: lift `buildTrendLine` to `src/lib/charts/trend-line.ts` alongside the moving-average extract above; converge the health-chart variant in a follow-up so both consumers stay byte-stable. Confidence: medium — a quick side-by-side diff should confirm whether the two are line-for-line identical or merely shape-compatible. + +## Suggested simplifications + +- **queryKey factory carry-forward** — W-RSC migrated `app/page.tsx`, the three chart components, `use-auth`, and the insights pages but ~50 bare-array `queryKey: [...]` literals remain across `src/components/settings/*`, `src/app/notifications/page.tsx`, `src/app/auth/login/page.tsx`, `src/app/medications/page.tsx`, `src/components/medications/*`, `src/app/targets/page.tsx`, etc. The factory-bypass guard test (`src/lib/__tests__/query-keys.test.ts`) only walks `src/components/charts`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Recommendation: in a follow-up minor (v1.4.40.1 or v1.4.41 backlog) widen the factory walker to all of `src/app` + `src/components` and either migrate the bare arrays or extend `queryKeys` with `notifications.preferences()`, `auth.login()`, `passkeys()` (already there), `settings.global()`, etc. The audit-H1 + audit-L4 class of bugs the v1.4.40 marathon chased was exactly factory drift — the work is half done. +- **Rollup directory naming** — the seven files in `src/lib/rollups/` split into two patterns: writers (`measurement-rollups`, `mood-rollups`, `medication-compliance-rollups`) and readers (`measurement-read`, `measurement-read-wmy`, `measurement-read-cumulative`, `measurement-coverage`). The mood + medication paths have no equivalent reader file because their read logic lives in the analytics routes. Suggested action: either rename the writers to `-write` (so the convention is `-`) or accept the asymmetry and add a one-line module comment to each writer explaining the read side lives in `app/api//...`. The current state confuses the "where do I add the next reader?" question. +- `/Users/marc/Projects/HealthLog/src/app/api/dashboard/summary/route.ts:441` — the cumulative-vs-spot `useSum ? (row.sum_value !== null ? Number(row.sum_value) : Number(row.mean) * Number(row.count)) : Number(row.mean)` nested ternary still lives where the v1.4.39 simplifier flagged it. CLAUDE.md forbids nested ternaries. Once the four `measurement-read-cumulative.ts` dead exports above are either wired or removed, this is the call site that should be reading `resolveBucketSum(row)`. +- `/Users/marc/Projects/HealthLog/src/lib/rollups/measurement-rollups.ts` boot-backfill discovery `UNION` over `r2."sum_value" IS NULL` — the legacy-NULL backfill arm is still live after the v1.4.39 single-tenant convergence. Backlog F-M-03 (v1.4.39 → v1.4.40 carry-forward) flags this for either a partial-index add or removal. Marc's tenant has converged; safe to schedule the arm for removal in v1.4.41 + add a TODO with the cut-off date. + +## Applied + +None. Every candidate above either: + +1. Intersects a test contract (`measurement-read-cumulative.test.ts`, `measurement-read-wmy.test.ts`, `summaries-slice.test.ts`) that should be migrated in lockstep so the deletion lands green CI without a separate "drop dead test" commit. +2. Pairs with a v1.5 wiring intent Marc signed off on in the wave reports (computeLongWindowSummary → multi-year-card, cumulative readers → analytics-route batch path). +3. Is a cross-file dedup (the medication today-projection block, the moving-average extract) whose target module needs Marc's naming input before the move. + +Listing as suggestions keeps Marc's voice in the eventual commit messages and lets the reconcile decide which carry-forwards land in v1.4.40 vs the v1.4.41 backlog. + +## Cross-check against marathon scope + +- **W-INFRA umbrella move** — clean. `grep` for old `lib/measurements/rollup` / `lib/mood/rollups` / `lib/medications/compliance-rollups` returns zero callers; no orphan re-export stubs. Tests moved with the source files. +- **W-GHOSTS purge** — `movingAverage`, `weeklyAverages`, `mmolToMgdl`, `berlinDayKey`, `typesMissingCoverage`, `readCumulativeDaySumsBatch`, `TELEGRAM_CLEANUP_QUEUE`, `/api/medications/intake-summary`, `/api/monitoring/{umami,glitchtip}/test` all confirmed gone from `develop`. Prisma schema has no orphan indexes pointing at the deleted routes. +- **CHANGELOG.md** — single reference to "v1.4.40" is a forward-reference at line 185 ("v1.4.40 backlog"); no premature `## [1.4.40]` heading. Release endgame can land it cleanly. +- **Soft-delete test consolidation** — `tests/integration/measurement-soft-delete.test.ts` (310 LOC, W-DELETED) is the single coordinating test; the per-tier `analytics`, `dashboard`, `rollup` assertions live inside one file. No fragmentation found. +- **`prisma/migrations/0074_v1440_consent_receipts`** — additive only, idempotent guards mirror 0067/0070/0071, includes the FK + index. Clean. diff --git a/.planning/round-v1440-QA-specialist-findings.md b/.planning/round-v1440-QA-specialist-findings.md new file mode 100644 index 000000000..9e4d8ee16 --- /dev/null +++ b/.planning/round-v1440-QA-specialist-findings.md @@ -0,0 +1,181 @@ +# Round v1.4.40 — Prisma + perf specialist findings + +Read-only review. Scope: W-POOL, W-DELETED, W-INFRA (threads 1/3/4), +W-INSIGHTS (compliance recompute + mood-rollup swap), W-WMY-WIRE, +W-CONSENT. Anchored to `.planning/round-v1439-empirical-trace.md`. + +--- + +## CRITICAL (none) + +No release-blocking perf or data-integrity defect found. + +--- + +## HIGH + +### H-1 — Bulk recompute hook treats failures as fire-and-forget without surfacing +`src/app/api/medications/intake/route.ts:171-186` and the mirror in +`src/app/api/dashboard/summary/route.ts` `Promise.all(recomputeJobs)` +the recompute calls **without** a catch wrapper. The W-INSIGHTS report +states "errors stay caught inside the helper" but if +`recomputeMedicationComplianceForEvent` ever throws (DB blip, missing +medication row), the `await Promise.all` rejects → the intake POST +returns 5xx **after** the projection `createMany` already committed. +This is asymmetric with the surrounding `auditLog(...).catch(() => {})` +pattern. v1.4.41 candidate: wrap each push in `.catch(err => logger…)` +or wrap the `Promise.all` in `.catch(() => {})` to preserve the +"best-effort" contract the comment promises. Worst-case today is a +recoverable transient blip surfacing as a request failure, not data +loss. + +--- + +## MEDIUM + +### M-1 — `deletedAt: null` plan validation — partial index deferred +Every `measurement.findMany` post-W-DELETED now carries +`(user_id, type, measuredAt range, deletedAt IS NULL)`. The existing +`@@index([userId, type, measuredAt])` (prisma/schema.prisma:501) still +serves these queries; the `deleted_at` filter is applied post-index +as a heap-level discard. With ~0 tombstones on Marc's tenant today +the discard cost is nil. **No partial index needed before iOS sync +emits its first tombstones**. Once iOS Apple-Health soft-delete reaches +1–5 % of rows the trade-off shifts; v1.4.41+ candidate: add +`CREATE INDEX measurements_live ON measurements(user_id, type, measured_at) + WHERE deleted_at IS NULL`. Defer until row-count justifies. + +### M-2 — Pool ceiling capacity vs cluster topology +`max=20` per `pg.Pool` × N Node processes. Next.js standalone (Coolify +default) = 1 process per container, so single container = 20 slots +against Postgres' stock `max_connections=100`. Five concurrent +power-users worst-case = 100 = the wall. The brief mentioned dialling +down via `DATABASE_POOL_MAX`; production env should explicitly set this +to 20 (not rely on the default) once container count climbs above 4 or +PgBouncer/transaction-pooling lands. Document in deploy notes. + +### M-3 — Slope from MONTH-bucket means is linear-composable but NOT +identical to slope from DAY samples (W-WMY-WIRE) +`avg30LastYear` via `readBestGranularityRollups(type, 395)` reads +`[now-395d, now-365d)` MONTH buckets and count-weighted-averages them. +For mean, this is mathematically equal to mean over the underlying DAY +rows ✓. The health-score `weightLongWindow` is documented as +**annotate-only** (not consumer-visible math) — that's the right call +because a regression slope over monthly means differs from a slope over +daily samples even with linear composability (the means lose intra-month +variance, regression slope tightens). Wave-report claim of +"linear-composability holds" is correct for **mean**; would be misleading +if a future wave wires MONTH-tier means into a slope/r² computation. +Flag for v1.5 Coach drawer integration when slope90 actually consumes a +WMY tier. + +--- + +## LOW + +### L-1 — p-limit error semantics verified +`pLimit(4)` (p-limit v7.3.0) propagates the wrapped promise's +rejection through to the caller, so `Promise.all(types.map(t => +typeFetchLimit(() => …)))` still rejects on first failure. Existing +error path preserved. ✓ + +### L-2 — Empirical 7.3s → 1.6s claim +Plausible. Pre-fix Wave-C blocked at thick-analytics-release (~7.3s); +post-fix the chart tiles unblock from pool-slot availability (max=20) +rather than analytics drain, so first-paint ≈ Wave-B settle (1.1s) + +tile RTT (~150ms × bounded queuing) → 1.6s. Both p-limit AND max=20 +contribute; either alone would unblock Wave-C, the pair is +belt-and-braces. The thick-analytics ~10-15% wall-clock penalty math +checks out: 15-way fan-out across 4 lanes = 4 batches; if all types +were equal-cost it would be 4x slower, but the slowest type dominates +each batch — so the penalty is closer to (slowest_4 / slowest_1) ≈ 1.1-1.2x. + +### L-3 — Bulk compliance recompute volume +After `createMany(missing)` the Set-coalesced +`recomputeMedicationComplianceForEvent` calls fire one per +`(medicationId, dayKey)` tuple. Worst case for Marc's tenant ≈ +medications × 1 day = ~5-10 round-trips, each walking a +bounded ~30-day intake-event window. Safe. ✓ + +### L-4 — Mood-rollup tier swap row-bound check +`src/app/api/insights/targets/route.ts` + `comprehensive/route.ts` + +`lib/insights/features.ts` — verified each new path consumes +`readMoodDayRollups` (bounded `count(*) ≤ days_in_window`) instead of +unbounded `moodEntry.findMany`. Coverage-fallback bounds added: 30d on +`targets`, 90d on `comprehensive`, 1y on `features.ts`. All bounded. +Worst-case row count drop from unbounded → ≤365. ✓ + +### L-5 — `findMany distinct` floor (W-INSIGHTS, targets route) +365-day floor on the previously-unbounded `findMany({distinct})` — +justified in the wave-report (slow-moving metrics like BODY_FAT need a +year window for the "current" tile). Bounded scan + uses the existing +`(user_id, type, measured_at)` index. ✓ + +### L-6 — ConsentReceipt index alignment +`@@index([userId, createdAt(sort: Desc)])` matches the +`latestActiveReceipt(userId, kind)` query exactly: index scan by +userId, walk descending, filter on `revokedAt IS NULL` + `kind=…` post- +fetch. Append-only invariant pinned in the test suite. ✓ + +### L-7 — 64KB artefact cap +`ARTEFACT_MAX_BYTES = 64 * 1024` in `src/lib/validations/consent.ts:33`. +Reasonable for signed PDF or JWT; protects against pathological body +sizes. ✓ + +### L-8 — Rollup umbrella move is pure relocation +Spot-checked 3 importer rewrites +(`@/lib/measurements/rollups` → `@/lib/rollups/measurement-rollups`): +analytics route, insights features, mood-chart consumer. No call-shape +changes, no public-API drift. 58 sites consume the umbrella; zero +remaining references to the old paths. ✓ + +### L-9 — Knip CI trigger +`.github/workflows/knip.yml` triggers on `push: branches=[main]` and +`pull_request: branches=[main]`. **Does NOT run on PRs targeting +`develop`**. Marc's branch model is develop → main releases-only; +contributors developing on feature branches against develop won't see +the gate until the release PR lands. Acceptable for the current solo-dev +workflow, but if/when external contribution opens up, extend the +`pull_request.branches` to include `develop` so dead-code is caught +before merge to the long-lived branch. The `unlisted` + `binaries` + +`files` + `dependencies` gates are appropriate; deferring `exports` + +`types` is the right call given the 487/52 baseline. + +--- + +## INFORMATIONAL + +### I-1 — Compliance-rollup recompute bound on Marc-sized tenants +The `recomputeUserMedicationCompliance(userId, days)` helper walks the +trailing `medicationIntakeEvent` window. On Marc's tenant (~30-90 +rows/day × bounded day window) this is cheap; the Set-coalesced fan-out +in W-INSIGHTS keeps it that way. Safe as the directive predicted. ✓ + +### I-2 — DST drift carry-over from rollup-tier swap +W-INSIGHTS report flags the UTC `bucketStart` vs Berlin-anchored +`MoodEntry.date` divergence on DST nights. The bound is one calendar +day for Berlin tenants on fall-back. v1.5 per-user-tz bucket migration +closes the gap. Inherited from v1.4.38; not introduced this wave. + +### I-3 — Cross-wave commit drift +Two waves (W-POOL, W-APNS-NOTIFY, W-RSC, W-WMY-WIRE) report +cross-agent commit-message drift — same pattern memorised from v1.4.37. +The functional diffs are intact (verified via spot-check `git show`), +but release-notes / CHANGELOG generation needs to walk **diff bodies**, +not commit subjects, this release. Out of perf scope; flagging because +it impacts what an auditor sees in git history. + +--- + +## Verdict + +W-POOL fix is correct, empirical claim is plausible, error semantics +preserved. W-DELETED full-wire is comprehensive and index-aligned +(partial index deferral is the right call). W-INFRA threads 1/3/4 are +clean. W-INSIGHTS row-bounds verified. W-WMY-WIRE is annotate-only on +the slope path, which sidesteps the variance-loss concern. W-CONSENT +shape + index aligned with the "latest receipt" query. Ship. + +One v1.4.41 follow-up: wrap recompute Promise.all in a catch (H-1). +Two long-tail follow-ups: partial index when tombstones materialise +(M-1), extend knip CI trigger to develop branch (L-9). diff --git a/.planning/round-v1440-QA-ux-findings.md b/.planning/round-v1440-QA-ux-findings.md new file mode 100644 index 000000000..1e4186448 --- /dev/null +++ b/.planning/round-v1440-QA-ux-findings.md @@ -0,0 +1,321 @@ +# v1.4.40 — UX / response-shape QA findings + +**Reviewer scope:** UX + byte-shape verification across W-RSC, +W-INSIGHTS, W-PRIVACY, W-AASA, W-APNS-NOTIFY, W-CONSENT. +**Range:** `v1.4.39.4` → develop HEAD (`8e9a7891`), 55 commits. +**Mode:** read-only. + +--- + +## Severity legend + +| Band | Meaning | +| ---- | ------- | +| **BLOCKER** | Ships a regression iOS / clients can observe — must fix before release. | +| **HIGH** | UX or shape concern that will surface as a paper-cut within a week. | +| **MEDIUM** | Cosmetic / hygiene; track for v1.4.41 unless cheap to fold in. | +| **LOW** | Note for the record; no action expected this cycle. | +| **PASS** | Verified clean. + +--- + +## 1. Per-tile Suspense boundaries (W-RSC) + +**Band: PASS — with two MEDIUM observations.** + +- `src/app/page.tsx:1455` wraps every tile cell in + `` and `:1486` wraps every chart cell in + `}>`. Each cell sits inside its + own `
` wrapper so the boundary is a true + per-cell island, not a shared row gate. Verified against + `src/app/__tests__/dashboard-suspense-boundaries.test.ts`. +- The chart-row fallback uses ``, which is the same + primitive the `next/dynamic({ loading: … })` contract on the three + chart components already paints during JS chunk resolution — so + the fallback layout matches the eventual real chart layout. No CLS + expected. + +- **MEDIUM — tile-strip fallback is `null`, not a tile-shaped + skeleton.** Today the tile body is synchronous (every TrendCard + reads from the merged `data` memo gated on the slim + thick + analytics queries on the parent), so the `null` never paints. But + the moment a future RSC hoist makes any TrendCard suspend, the + user will see a 0-px hole inside the grid track until the cell + resolves, which on a wrapped Pixel 5 layout will collapse the row + height mid-paint (CLS risk). The wave's own report flags this as + "structural no-op today" — keep an eye on it the moment one tile + becomes async. +- **MEDIUM — dashboard still relies on `useMemo` over `slim + thick` + parallel queries (page.tsx:255–285) rather than independent + ``-driven streaming.** The per-tile boundary is + defensive scaffolding; the actual progressive paint Marc wanted + ("tile-strip first, BD-Zielbereich + glucose tiles stream after") + is still implemented via two `useQuery` calls + an in-component + `useMemo` merge that is **all-or-nothing on slim**: if slim's + `summaries` resolves with content the tile strip paints + immediately, but BD-Zielbereich + glucose remain `undefined` + until the thick query lands. That matches the v1.4.39.2 contract + and is fine for v1.4.40, but **the W-RSC report itself recommends + the RSC migration for v1.4.41** — confirm that lands on the + v1.4.41 backlog. Per-tile Suspense is the prerequisite, not the + end state. + +--- + +## 2. queryKey factory enforcement (W-RSC) + +**Band: HIGH — one factory-bypass leak that the test guard does NOT +catch.** + +- `src/components/comparison/compare-toggle.tsx:29,55,75` reads / + invalidates **`["user", "dashboardWidgets"]` as a bare literal**. + Same shape as `queryKeys.dashboardWidgets()` so the cache actually + dedups today (good), but the wave's own factory-bypass guard + in `src/lib/__tests__/query-keys.test.ts:173–177` only walks + `src/components/charts`, `src/app/page.tsx`, `src/hooks/use-auth.ts`. + A rename of `dashboardWidgets` to anything else would silently + break the comparison toggle's read of the saved baseline. +- **Recommendation:** extend `guardedRoots` to include + `src/components/comparison` (cheap, 1-line PR) **or** migrate the + three sites to `queryKeys.dashboardWidgets()`. This sits squarely + in scope for the wave's "no other endpoint is double-queried with + different keys" verification. + +- **PASS** — every other surface the wave touched routes through the + factory: `app/page.tsx` (moodAnalytics, dashboardWidgets), + `components/charts/mood-chart.tsx` (moodAnalytics), + `components/charts/health-chart.tsx` (chartData), and + `components/charts/medication-compliance-chart.tsx` + (dashboardMedicationCompliance). +- **PASS** — searched `src` for `"mood-chart-data"` — only matches + are doc/comment references in `src/components/charts/mood-chart.tsx:319` + and `src/lib/query-keys.ts:141,146`. The actual queryKey is + `queryKeys.moodAnalytics()`. The cold-mount dashboard + chart no + longer fire two requests against `/api/mood/analytics`. + +- **LOW** — 154-site long tail outside the guarded scope (settings, + admin, integrations, medications pages, notifications, etc.) + still uses bare-literal keys. Wave-deferred to v1.4.41 explicitly. + +--- + +## 3. Six insights route swaps (W-INSIGHTS) + +**Band: PASS — three landed, three explicitly deferred with rationale.** + +The directive listed six routes; only three of the six are actual +mood-walk sites that the rollup tier can replace. + +### 3a. `/api/insights/targets` — **PASS**, byte-shape preserved + +Response envelope at `src/app/api/insights/targets/route.ts:1297–1313`: +- `{ targets, pageSummary, bpDiastolic, profile }` — **identical to + v1.4.39.4**. +- `targets[].type === "MOOD_SCORE" | "MOOD_STABILITY"` still surfaces + the same `current` / `average30` / `consistency7d` / + `streakDays` / `insufficientData` keys. +- The rollup-tier branch + coverage-fallback branch + cold-start + branch all produce the same `ConsistencyOutput` shape. +- Test file `src/app/api/insights/targets/__tests__/route.test.ts` + (new in `f8de4b05`) pins rollup-fast-path, coverage-fallback, + no-mood, and the 365-day `distinct` floor. + + - **DST drift note (documented):** rollup `bucketStart` is + UTC-anchored; legacy `MoodEntry.date` is TZ-anchored. For Berlin + tenants whose mood timestamps don't straddle UTC boundary the + two day-keys agree; DST fall-back nights diverge by one calendar + day. Pinned in the existing `/api/mood/analytics` route-parity + test. v1.5 per-user-tz bucketing closes the gap. Accept. + +### 3b. `/api/insights/comprehensive` — **PASS**, byte-shape preserved + +Response envelope (`route.ts:415–438`): 22 top-level keys +(`summaries`, `bmi`, `bmiClassification`, `bpClassification`, +`bpPctInTarget`, `bpTargets`, `weightBpCorrelation`, `scatterData`, +`bpMedicationCorrelation`, `bpMedicationScatterData`, `moodSummary`, +`moodBpCorrelation`, `moodBpScatterData`, `moodWeightCorrelation`, +`moodWeightScatterData`, `moodPulseCorrelation`, `moodPulseScatterData`, +`medications`, `alerts`, `hasProvider`, `dataSpanDays`, +`totalMeasurements`) — **all unchanged**. +- The `moodEntries: moodEntryCount` rename lives **only inside the + `annotate({ meta: {…} })` audit-log block** (route.ts:410), not in + the response envelope. iOS / web consumers see no change. + +### 3c. `/api/insights/generate` (via `lib/insights/features.ts`) — **PASS** + +- The downstream prompt builder (`buildUserPrompt`) consumes the + same `AggregatedFeatures.mood` block (`avg7`, `avg30`, `latest`, + `trend30`, `totalEntries`, `coverage`). The rollup tier produces + the same shape; the v1.4.39 `/api/mood/analytics` already shipped + the rollup-tier semantic so this swap inherits the same parity + envelope. + +### 3d. Three routes explicitly deferred — **PASS as deferred** + +- `/api/insights/cards/route.ts` — no mood query (iOS adapter that + shares alert-rule input shape). +- `/api/insights/glp1-timeline/route.ts` — reads per-entry + `tags: string[]`, which the rollup tier doesn't carry. Already + 90-day bounded. +- `/api/gamification/achievements/route.ts` — needs per-entry + Berlin-anchored `date` key, which the rollup's UTC `bucketStart` + shifts on DST fall-back nights. Bounded by a 2026-02-20 anchor so + worst-case row count is manageable. + +Deferral rationale documented in `phase-W-INSIGHTS-v1440-report.md` +§"Rolled-up scope decisions" and accepted. + +--- + +## 4. Privacy page (W-PRIVACY) + +**Band: PASS — readable on Pixel 5 viewport, operator email +appropriate.** + +- `src/app/privacy/page.tsx` — bilingual paired-section layout + inside a `
` shell. **`max-w-3xl` (768 px) is intentional + per `IW9` comment** (line 198) — legal/long-form columns read + better at 70-80 chars per line. On a 393 px Pixel 5 viewport that + caps line length at the container width (≈ 393 − 32 = 361 px) so + text wraps to a comfortable ~50-60 chars per line. +- Typography: H2 sits at `text-xl md:text-2xl`, body at `text-sm + md:text-base leading-relaxed` — matches the rest of the app's + prose tokens. The Deutsch heading sits as an `uppercase tracking- + wider` eyebrow above each block, English collapsed under a + `
` with a rotating disclosure indicator. Skim-readable. +- **PASS — Operator email** `mailto:mbombeck@gmail.com` at section 11 + is appropriate for a single-operator self-hosted install per + feedback memo `feedback_no_pii_in_user_facing.md`: the email **is** + the contact channel, not a functional alias. The DPA-recommended + postal-address-on-request pattern is explicit in the body. A + function alias (`privacy@…`) would imply a team that doesn't + exist. +- Test file `src/app/privacy/__tests__/page.test.tsx` (17 tests) + pins the bilingual title, 11 paired sections, HK data-flow path, + AI-off-by-default + named providers, consent endpoint + 5-year + retention literal, TLS + HSTS, deletion route + cascade. PII rule + still enforced. + +- **LOW** — collapsed-English `
` means an Apple US reviewer + has to click 11 disclosure triangles to read the English policy + end-to-end. The W-PRIVACY rationale was explicit ("German body + first, English under a labelled `
`"); flagging as a + reviewability cost without recommending a change. + +--- + +## 5. AASA file (W-AASA) + +**Band: PASS.** + +- `src/app/.well-known/apple-app-site-association/route.ts:43–49` + responds with **`Content-Type: application/json`** (no `charset=` + parameter — Apple's `swcd` / `aasa-validator` silently refuse + anything annotated with charset). Verified against the test + `src/__tests__/api/well-known.test.ts` which pins the exact + string. +- **`Cache-Control: public, max-age=3600`** — one-hour TTL pairs + cleanly with Apple's CDN mirror at + `app-site-association.cdn-apple.com/a/v1/`. Reasonable. +- Payload shape matches SB-4: `applinks.details[0].appID` and + `webcredentials.apps[0]` share the same `S8WDX4W5KX.dev.healthlog.app` + prefix via local constant. Test pins that the two App IDs cannot + drift. + +--- + +## 6. Notifications status endpoint (W-APNS-NOTIFY SB-6) + +**Band: PASS, with one HIGH iOS-coordination flag.** + +- `src/app/api/notifications/status/route.ts:73–91` initialises the + `events` map with `Object.fromEntries(EVENT_TYPES.map(...))` — so + **every known category is always present** in the response (with + `{ lastDeliveredAt: null }` when no ledger source has data). iOS + can iterate the keyspace without conditional plumbing. ✓ +- `MOOD_REMINDER.lastDeliveredAt` reads from `MoodReminderDispatch` + ledger; every other category currently returns `null`. +- Test file `src/app/api/notifications/status/__tests__/route.test.ts` + pins the four required behaviours: 401 unauth, every-known-event- + type-present, MOOD_REMINDER populates from latest dispatch row + + scoped to caller, `channels` shape preserved. + +- **HIGH (informational only) — only `MOOD_REMINDER` has a per-event + ledger.** Per the W-APNS-NOTIFY report: `MEDICATION_REMINDER`, + `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, + `SYSTEM_ALERT`, `PERSONAL_RECORD` return `null` until a future + `NotificationDispatch` table lands. **iOS v0.5.4 will render + "Never" rows for every category except MOOD_REMINDER even on + active accounts**. Coordinate with iOS so the empty-state copy + reflects this — or accept that "Never" is the right UI for the + v1.4.40 window. Not a blocker; iOS contract is shape-stable. + +--- + +## 7. Consent endpoint (W-CONSENT) + +**Band: PASS.** + +- Response envelope **`{ data, error, meta }`** is enforced + uniformly: `POST /api/consent/ai` returns + `apiSuccess({ id, receipt })` (route.ts:46), `GET .../latest` + returns `apiSuccess({ kind, receipt })` or + `apiSuccess({ ai_full, ai_insights_only, ai_coach })` for the + full keyspace, `DELETE .../latest` returns `apiSuccess({ kind, + receipt })` or `apiSuccess({ revoked })` for the master sweep. + All shapes consistent with the project's `apiSuccess`/`apiError` + envelope convention. +- **POST response strips `artefact`** — `serialiseReceipt` + (route.ts:68–84) explicitly omits the up-to-64-KB opaque token + from the wire response. Test pins `expect(body.data.receipt).not + .toHaveProperty("artefact")` (route.test.ts:159). ✓ +- 64 KB artefact cap enforced in `consentPostBody` Zod schema; test + pins the 400 response when exceeded. +- **Append-only invariant** test in the latest-route spec asserts + `prisma.consentReceipt.delete` is never called — revoke flips + `revokedAt` only. Audit trail intact. + +- **LOW** — `serialiseReceipt` is imported across files via + `import { serialiseReceipt } from "../route"` (latest/route.ts:32) + — fine for now, but if the project moves to a stricter + per-route-handler boundary the helper should move to + `src/lib/consent/serialise.ts`. Cosmetic. + +--- + +## 8. iOS contract impact + +**Band: PASS — every new / swapped endpoint is shape-additive for +iOS v0.5.4.** + +| Endpoint | Change | iOS v0.5.4 impact | +| -------- | ------ | ----------------- | +| `/.well-known/apple-app-site-association` | Populated `applinks.details[0]` (was empty array) | **None** until iOS sets the `applinks:` entitlement (their PB30 phase). | +| `/api/notifications/status` | Added `events: Record` alongside existing `channels: ChannelStatus[]` | **Additive.** Existing destructure of `data.channels` works untouched. Spec-compliant per SB-6. | +| `/api/consent/ai` (POST), `/api/consent/ai/latest` (GET/DELETE) | New routes | **Net new** — iOS v0.5.4 doesn't call them yet. Hook-up tracked in iOS PB30. | +| `/api/insights/targets`, `/comprehensive`, `/generate` | Internal swap to mood-rollup tier | **Byte-identical envelope.** iOS Cards adapter unaffected. | +| `/api/notifications/status` adds `interruption-level=time-sensitive` for MEDICATION_REMINDER on the send path (apns.ts), not on this endpoint | Server-side payload field | **Gated on `.p8` APNs key install on Coolify env.** Until that env-secret lands, every dispatch returns `apns_not_configured`. Release notes call this out. | +| Dashboard query-key factory changes (`queryKeys.chartData`, `queryKeys.dashboardMedicationCompliance`) | Internal | **Web-only.** iOS doesn't touch TanStack Query cache. | + +**No breaking change for iOS v0.5.4.** Universal Links light up +once iOS ships the `Associated Domains` entitlement. + +--- + +## Summary verdict + +| Surface | Band | Action | +| ------- | ---- | ------ | +| Per-tile Suspense | PASS (2 MED) | Track tile-fallback skeleton + RSC migration for v1.4.41. | +| queryKey factory | **HIGH** | Extend `guardedRoots` to `src/components/comparison` OR migrate compare-toggle.tsx — 1 commit. | +| Insights swaps (3 landed, 3 deferred) | PASS | Accepted. | +| Privacy page | PASS (1 LOW) | Ship. | +| AASA | PASS | Ship. | +| Notifications status | PASS (1 HIGH info) | Coordinate iOS empty-state copy. | +| Consent endpoints | PASS (1 LOW) | Ship. | +| iOS contract impact | PASS | No breakage for v0.5.4. | + +**Releasable.** The one HIGH is a 5-minute factory-bypass cleanup +on `compare-toggle.tsx`; everything else is either passing or +explicitly deferred with rationale. diff --git a/.planning/round-v1440-perf-verify.md b/.planning/round-v1440-perf-verify.md new file mode 100644 index 000000000..ad1556247 --- /dev/null +++ b/.planning/round-v1440-perf-verify.md @@ -0,0 +1,94 @@ +# v1.4.40 — Post-deploy perf verification + +Live deploy: **2026-05-21T12:55:02Z**. Squash-merge commit on `main`: +`1114cf7e`. Docker workflow `26221332323` succeeded on both +`linux/amd64` and `linux/arm64`. Coolify deploy fired both via the +workflow's auto-trigger step and via the explicit MCP deploy +(idempotent, converged to the same state). + +Migration `0074_v1440_consent_receipts` applied cleanly at boot. + +## Unauthenticated probe (orchestrator side) + +```bash +$ curl -s https://healthlog.bombeck.io/api/version +{"data":{"version":"1.4.40", ...},"error":null} +``` + +Confirmed live at 12:55 UTC. + +## What this release was supposed to fix + +The empirical trace + the architecture-QA quadrology pointed at one +empirical root cause and three architectural Criticals: + +| # | Root cause | Wave | Expected delta | +| --- | --- | --- | --- | +| 1 | Prisma pool starvation on `/api/analytics` thick (≥8 of 10 default slots held for 6.5 s) | W-POOL | Wave-C chart-tile first-paint **+7.3 s → +1.6 s** | +| 2 | 6 insights routes unbounded `MoodEntry.findMany` | W-INSIGHTS | 5-10 s cold-mount estimate (3 routes done in-band, 3 deferred with documented reasons) | +| 3 | `Measurement.deletedAt` half-wired → tombstoned rows surface | W-DELETED + W-INFRA T1 | iOS soft-delete now correct end-to-end | +| 4 | WMY rollup buckets write-only → 3× write amplification | W-WMY-WIRE | `avg30LastYear` populated (was null), slope90 via MONTH-tier | + +## What gets verified by Marc's first authenticated session + +The wide-event annotates emit per-surface `path: "rollup" | "live"`. +The slim/thick LRU caches stay warm for 60 s after the first hit. The +dashboard tile-strip + chart row should now paint progressively — per +W-RSC's per-tile `` boundaries — instead of in one burst. + +Coolify log grep targets: +- `path:"rollup"` on `/api/analytics`, `/api/dashboard/summary`, + `/api/mood/analytics`, `/api/medications/intake?scope=compliance`, + `/api/insights/{targets,comprehensive,generate}` +- `meta.dashboard.sub_*_ms` annotates (W-F observability) +- `meta.analytics.bp_aggregate.live_since` (W-SINCE annotates) + +## iOS PB30 enablement verification + +The iOS team's SERVER-BACKLOG asks for SB-3/4/5/6/7/10. Surfaces live: + +- **SB-3** Privacy Policy at `https://healthlog.bombeck.io/privacy` — bilingual paired-on-one-page. App-Store-Connect URL field gets this. POLICY_VERSION = 1.4.40. +- **SB-4** AASA file at `https://healthlog.bombeck.io/.well-known/apple-app-site-association` — strict `application/json`, contains `S8WDX4W5KX.dev.healthlog.app` + `applinks` + `webcredentials`. +- **SB-5** APNs `interruption-level: time-sensitive` for `MEDICATION_REMINDER` payloads ONLY. **AP-2 prerequisite**: the `.p8` APNs auth key must be installed in Coolify env (`APNS_KEY` + `APNS_KEY_ID` + `APNS_TEAM_ID` + `APNS_BUNDLE_ID`) before SB-5 has any observable effect. Until then the server skips the APNs send entirely (`loadApnsConfig()` returns null). +- **SB-6** `GET /api/notifications/status` returning per-category `lastDeliveredAt` map. MOOD_REMINDER populated from `MoodReminderDispatch`; other categories return `null` until per-category ledgers ship (v1.4.41 follow-up). +- **SB-7** `/api/auth/registration-status` 4-branch behavior pinned in `auth-registration-status.test.ts`. The hint endpoint mentioned in the iOS brief (`/api/auth/check-user`) is out-of-scope for v1.4.40; flagged for v1.4.41. +- **SB-10** `POST /api/consent/ai` + `GET /api/consent/ai/latest` + `DELETE /api/consent/ai/latest`. Append-only invariant verified. 64 KB Buffer-byte cap (security M1 reconcile fix). + +## Operator action required for SB-5 + +```bash +# On apps01: +$ docker exec -it healthlog_app sh +$ # Then set the four env vars via Coolify UI: +$ # APNS_KEY = +$ # APNS_KEY_ID = M9WAFLNC2U +$ # APNS_TEAM_ID = S8WDX4W5KX +$ # APNS_BUNDLE_ID = dev.healthlog.app +$ # APNS_PRODUCTION = true (when ready for prod APNs) +$ # Save + redeploy the app. +``` + +After install: send a test medication-reminder push from the admin +console; iOS should receive `apns-priority: 10` + +`interruption-level: time-sensitive` and bypass Focus mode. + +## Carry-overs to v1.4.41 (deferred clean) + +Documented in `project_v1440_marathon_outcome.md` memory + the 6 QA +finding files. Highest-impact items: + +- W-DELETED-2 sweep for export/admin/doctor-report/gamification readers +- W-INSIGHTS-2 for cards/glp1-timeline/gamification (needs per-user-tz bucketing first) +- `src/types/` DTO promotion + prompt-directory unification (org-audit recs #2 + #3) +- Full RSC migration of `app/page.tsx` (Suspense boundaries shipped as prerequisite) +- ESLint custom rule for `queryKeys` factory enforcement (test-guard substitutes for now) +- `/api/auth/check-user` SB-7 follow-up + +## Marathon retrospective + +Cross-agent commit-attribution drift recurred a 4th time (across +v1.4.37 / v1.4.38 / v1.4.39 / v1.4.40). The marathon's parallel-waves +pattern is sound; the shared-tree pattern is the failure mode. +Per-agent `git worktree` isolation is now a hard rule for any +release-marathon with 3+ parallel implementation agents +(`feedback_marathon_worktree_isolation.md` in both memory locations). diff --git a/.planning/sb-8-dashboard-layout-merge-semantics.md b/.planning/sb-8-dashboard-layout-merge-semantics.md new file mode 100644 index 000000000..404bdb1a4 --- /dev/null +++ b/.planning/sb-8-dashboard-layout-merge-semantics.md @@ -0,0 +1,86 @@ +# SB-8 — Dashboard tile-visibility merge semantics + +**Owner:** W-IOS-COORD (v1.4.41) +**Route:** `src/app/api/dashboard/widgets/route.ts` +**Resolver:** `src/lib/dashboard-layout.ts` (`resolveDashboardLayout` + `DEFAULT_DASHBOARD_LAYOUT`) +**Status:** investigation-only — no code change. Documents the contract a +future widget-introduction migration must honour. + +## How the layout is stored + +`User.dashboardWidgetsJson` (Prisma `Json?`, default `null`). A null / +missing value means "no user customisation, use defaults". A populated +value is a `DashboardLayout` blob shaped by the Zod schema in the PUT +route. The resolver in `src/lib/dashboard-layout.ts` is the single +read-side merge point. + +## Merge semantics on read (`resolveDashboardLayout`) + +1. **Null/empty input** → return `DEFAULT_DASHBOARD_LAYOUT` verbatim. +2. **Known widget ids** → keep the user's `visible` / `tileVisible` / + `order` exactly as stored. +3. **Unknown widget ids** (stale entries from a removed widget) → dropped + silently. +4. **New widgets present in `DASHBOARD_WIDGET_IDS` but absent from the + user's blob** → appended at the end of `widgets[]` with the default + layout's `visible`, `tileVisible`, and `order` values. This is the + key behaviour any new-widget migration must rely on. The resolver + does this on every read; no DB migration is required when a new + widget id ships. +5. **`tileVisible` omitted** → mirrors `visible` for back-compat with + pre-v1.4.15 saved layouts. +6. **Unknown `comparisonBaseline` / `chartOverlayPrefs` keys** → clamped + to defaults so a stale client cannot poison the dashboard. + +## Merge semantics on write (`PUT /api/dashboard/widgets`) + +1. The Zod schema requires the **full** `widgets[]` array — the route + does NOT support partial updates. Clients must read the current + layout, mutate, and PUT the result. +2. `chartOverlayPrefs` is preserved automatically when omitted: the + route re-reads the persisted blob and re-attaches the existing + prefs before writing (see lines 137–151). +3. `comparisonBaseline` is **not** preserved-on-omit — an omitted + field clamps to the default. Clients that want to keep an existing + baseline must round-trip the value. +4. `DELETE` resets to defaults by writing `Prisma.JsonNull` (not + `null` — Prisma's typed null sentinel). + +## Future-migration guidance + +Adding a new widget id is **migration-free** when these rules hold: + +- Add the id to `DASHBOARD_WIDGET_IDS`. +- Add a default entry to `DEFAULT_DASHBOARD_LAYOUT.widgets[]` with the + desired `visible`, `tileVisible`, and a unique `order`. +- That's it. Every existing user gets the new widget appended on next + GET. No `prisma migrate` step is needed for the layout blob. + +Removing a widget id is also migration-free: + +- Drop the id from `DASHBOARD_WIDGET_IDS` and from + `DEFAULT_DASHBOARD_LAYOUT`. +- The resolver drops the orphan entry on next read. A subsequent PUT + rewrites the blob without it. + +**Anti-pattern (do NOT do):** a "one-shot migration" that rewrites every +`User.dashboardWidgetsJson` to overlay a new tile-visibility default +will clobber every user's customisation. The resolver's append-only +merge is intentionally designed so we never need such a migration. + +## Open audit gap closed + +The original audit note flagged "what happens when a future migration +introduces a new tile and we want it visible by default for existing +users". Answer: do nothing — the resolver handles it. If a tile must +be visible-by-default for existing users, set `visible: true` in +`DEFAULT_DASHBOARD_LAYOUT`. The user's saved blob never has the new +id, so the resolver's append step uses the default. + +## References + +- `src/app/api/dashboard/widgets/route.ts` — PUT schema + chart-overlay + preservation logic. +- `src/lib/dashboard-layout.ts` — `resolveDashboardLayout`, + `serializeDashboardLayout`, `DEFAULT_DASHBOARD_LAYOUT`. +- `src/lib/__tests__/dashboard-layout.test.ts` — resolver coverage. diff --git a/.planning/sb-9-streak-deprecation.md b/.planning/sb-9-streak-deprecation.md new file mode 100644 index 000000000..c772803e1 --- /dev/null +++ b/.planning/sb-9-streak-deprecation.md @@ -0,0 +1,30 @@ +# SB-9 — Streak endpoint deprecation decision + +**Owner:** W-IOS-COORD (v1.4.41) +**Decision:** **already retired — nothing to deprecate.** + +## Investigation + +iOS v1.4.40 PB1 noted it stops calling `/api/streak/*`. The backlog +flagged that the server might want to either keep responding 200 for +the web app or fully retire the routes. + +`ls src/app/api/streak` returns `No such file or directory`. There +are no `route.ts` files under that path. No web frontend code paths +fetch `/api/streak/...`. The only `streak` references in the API are +internal computation inside `src/app/api/insights/targets/route.ts` +(`streakDays`, `streakHighlight`) which is a derived field on an +unrelated payload, not a standalone route. + +## Conclusion + +There is no `/api/streak/*` surface to deprecate. The iOS team's PB1 +note refers to a route family that either never existed on this server +or was removed before the audit. No code action required. + +## Action + +- No code change. +- Documented here so a future audit doesn't re-open the question. +- iOS team can safely treat `/api/streak/*` as 404 — the web app does + not depend on the route either. diff --git a/.planning/v1441-handoff.md b/.planning/v1441-handoff.md new file mode 100644 index 000000000..26c37bfb3 --- /dev/null +++ b/.planning/v1441-handoff.md @@ -0,0 +1,53 @@ +# v1.4.41 Marathon Handoff — fresh session needed + +**Session paused 2026-05-21 at orchestrator-context 83% (32% remaining).** Develop pushed to origin (`ac5ecd39`). v1.4.40 still live in prod (`1114cf7e`). No code conflict. + +## What landed on develop (14 commits ahead of v1.4.40) + +| Wave | Commits | Done | +|---|---|---| +| W-SKILL-UPDATE | none (skill file outside repo) | hard rule for git-worktree | +| W-RSC-RESEARCH | none (research only) | `.planning/research/rsc-migration-plan-v1.5.md` | +| W-IOS-COORD | `99a801f9` `6c2eadb3` `dde79b04` `59befdca` | SB-7 endpoint + SB-8/9 docs + widgets 422 investigation | +| W-DELETED-2 | `a62b9498` `5296a612` `cb8f74e4` `d0bdc4b8` | export/doctor-report/gamification deletedAt filter + integration test | +| W-ORG | `bba38b8c` `dc66dfcf` `8a56f482` | AnalyticsData + BackupRow + prompt-dir unification | +| W-FRONTEND-FACTORY | `0bf07abd` | 25 factory entries + auth/notifications/settings migrated + walker extended + tile CLS | +| W-PROCESS-DOCS | `70c50268` (+ eslint/knip mixed into `8a56f482`) | ESLint custom rule + pg.Pool scaling doc + knip staged | +| reports | `ac5ecd39` | phase reports | + +## What did NOT land (context-bail, 0 code committed) + +| Wave | Why | Re-dispatch priority | +|---|---|---| +| **W-INSIGHTS-HOT** | bailed before any file mod — context exhaustion | **HIGH — 14s response on `/api/insights/{blood-pressure,weight}-status` is the only user-visible v1.4.41 item Marc would feel TODAY** | +| W-PERF-OPS | bailed after reading 1 file. Item 1 (UNION cleanup) is a 5-line delete in `src/lib/rollups/measurement-rollups.ts:778-783`. Item 2 (Withings off-response) is bigger. | Medium — Item 1 fast standalone, Item 2 separate wave | +| W-SIMPLIFIER | bailed mid-discovery on prompt deletion | Medium — re-verify 8 dead exports, projectTodayIntakesAndRecompute helper extraction, drop unused `tx?` param | + +## Known issues on current develop + +- **TypeScript red**: pre-existing in `src/app/page.tsx` per W-DELETED-2 + W-ORG reports (likely from W-FRONTEND-FACTORY's tile CLS edit colliding with W-ORG's AnalyticsData inline-removal). Fix in next session. +- **dashboard-suspense-boundaries.test.ts regex** mentioned by W-ORG as failing — same area. +- **Commit attribution drift** continues: `8a56f482` (W-ORG's prompt commit) absorbed W-PROCESS-DOCS's eslint + knip edits. Functional outcome correct, message wrong. Worktree-isolation hard rule landed but couldn't apply to in-flight v1.4.41 waves. + +## What next session needs to do + +1. **Fix typecheck red on `src/app/page.tsx`** — first thing, blocks everything +2. **Re-dispatch W-INSIGHTS-HOT** (fresh agent, dedicated context) — ship the 14s route fix +3. **Re-dispatch W-PERF-OPS** Item 1 standalone (5-line cleanup, fast). Item 2 (Withings off-response) optional defer to v1.4.42 +4. **Re-dispatch W-SIMPLIFIER** for the 3 cleanup items +5. **Knip exports/types gate flip** — once Simplifier+Insights-Hot land, `pnpm knip --reporter compact` should be clean → flip in `.github/workflows/knip.yml` +6. **6 QA reviewer** parallel +7. **Reconcile + release endgame**: bump `1.4.40` → `1.4.41`, CHANGELOG, squash-to-main, tag, GH release, Coolify deploy + +Estimated: 2-3 hours fresh session (smaller scope than the v1.4.40 marathon). + +## Process bug confirmed (5th occurrence) + +Cross-agent commit drift hit again in v1.4.41 (W-PROCESS-DOCS's eslint+knip absorbed into W-ORG's prompt commit). The `release-marathon` skill update from W-SKILL-UPDATE now makes git-worktree the hard rule — apply NEXT session. The fix is documented in `~/.claude/skills/release-marathon/SKILL.md` and `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/feedback_marathon_worktree_isolation.md`. + +## Live production state (unchanged) + +- `https://healthlog.bombeck.io/api/version` → `1.4.40` +- v1.4.40 marathon closure docs all in place +- AP-2 APNs `.p8` confirmed installed in Coolify env (5 entries, byte-identical with `~/Downloads/AuthKey_M9WAFLNC2U.p8`) +- iOS v0.5.4 hitting prod normally; the 14s insights routes will resolve in v1.4.41 diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b5588b8..7859ddc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.4.41] — 2026-05-21 — iOS perf hotfix, soft-delete completeness, tree hygiene + +v1.4.40 closed the architecture-level Critical+High audit findings and shipped the iOS PB30 backend prerequisites. v1.4.41 is the follow-up: one user-visible perf hotfix on the iOS-facing insights endpoints (consolidated into a shared timeout-stub helper that now backs all seven status routes), three remaining soft-delete reader-tier gaps closed (the W-DELETED-2 invariant pinned by integration tests), type-consolidation across analytics + backups, a four-branch iOS-onboarding discovery endpoint with per-IP rate-limiting, and a code-hygiene pass that retires the v1.4.39 W-SUM legacy-NULL UNION discovery arm, extracts the today-intake projection helper, and trims a batch of dead exports. + +Eight touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W-*-v1441-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1441-QA-*-findings.md` covers the cross-cutting verdict that gated this tag. + +### Added + +- **`POST /api/auth/check-user`** — four-branch discovery endpoint for iOS onboarding (`not_found` / `passkey_only` / `email_fallback` / `exists`). Lets the iOS client surface the correct flow (register vs sign-in vs passkey vs recovery) on the first identifier entry without a separate "do you have an account" prompt. Per-IP rate-limited (30 / 15 min) mirroring `/api/auth/passkey/login-options`; no enumeration leak in the response shape beyond what the sibling routes already disclose. Identifier matched verbatim against `username` + `email` so a `MixedCase@Example.com` registration resolves through the route. +- **`src/lib/insights/persist-timeout-stub.ts`** — one shared helper for the timeout-stub persist + cache short-circuit pattern. Now backs all seven `*-status` routes (bmi, blood-pressure, weight, pulse, mood, medication-compliance, general). Each route's stub is keyed to today's Berlin day; the next mount short-circuits at the cache lookup instead of re-racing the same provider call. Replaces ~120 LOC of duplicate `prisma.auditLog.create({ … timeout: true })` blocks. +- **`src/lib/medications/scheduling/project-today-intakes.ts`** — single canonical home for the today-intake projection + idempotent `createMany` + per-`(med, day)` compliance recompute that previously lived inline in both `/api/medications/intake?scope=today` and `/api/dashboard/summary`. ~200 inline lines collapse to a 145-line helper + two short call sites; the helper returns `{ projected, backfilled }` so the existing `annotate` telemetry continues to ride the same payload. + +### Changed + +- **Types consolidated.** `AnalyticsData` (four named per-surface shapes) lifts into `src/types/analytics.ts`. `BackupRow` / `BackupsList` lifts out of the admin route handler into `src/types/backups.ts`. Prompt helpers under `src/lib/insights/prompt*.ts` unified into `src/lib/ai/prompts/`. queryKey factory expanded with `auth`, `notifications`, and `about` migrations. +- **queryKey factory enforcement (W-PROCESS-DOCS).** A real ESLint rule (`eslint-plugins/healthlog/queryKey-factory.js`) replaces the v1.4.40 test-guard substitute for fail-fast IDE/CI feedback. The guarded surface mirrors the test-guard's `guardedRoots` exactly — `src/components/{charts,comparison}/`, `src/app/page.tsx`, `src/hooks/use-auth.ts`, `src/app/auth/`, `src/app/notifications/`, `src/components/settings/about-section.tsx`. Future waves extend both lists in lockstep. +- **`/streak/*` formally deprecated** per the SB-9 follow-up: the endpoint no longer responds; the legacy iOS build path returns `404`. +- **Operator: pg.Pool sizing guidance for multi-container deploys.** Added a section under `docs/operator/` documenting how `DATABASE_POOL_MAX` interacts with horizontal-replica counts and the in-tree `p-limit(4)` analytics fan-out cap. +- **Per-tile Suspense fallback now layout-stable.** The dashboard tile-strip Suspense boundary swaps its prior `null` fallback for an `aria-hidden` placeholder div that mirrors the trend-card chrome (`bg-card border-border rounded-xl p-4 md:p-6`). The tile body is synchronous today so the fallback rarely paints, but a future RSC hoist of any tile slot would otherwise leave the grid track empty and trigger CLS as the cell paints in. The structural pin in `src/app/__tests__/dashboard-suspense-boundaries.test.ts` was updated to match. + +### Fixed + +- **Insights status routes — recurring ~14 s warm response on iOS.** The v1.4.37 bmi-status pattern persists a sentinel `auditLog` row on a 20 s AI-provider stall so the next mount short-circuits at the cache lookup. The blood-pressure and weight status routes shipped without that persist and re-raced the same provider call on every reload. Both routes now route through the shared `persistTimeoutStubAndReturn` helper, as do the four remaining sibling routes (general, pulse, mood, medication-compliance) that carried the same bare-fallback shape and would have leaked the same paper-cut on any of their respective provider stalls. Response envelopes are byte-compatible — iOS v0.5.4 contract preserved. The medication-compliance route's cache-read picked up a stub-row recogniser so its `{ summary, medications }` envelope short-circuits cleanly on the helper's `text` + `timeout: true` payload. +- **`/api/auth/check-user` identifier mismatch.** The route called `identifier.toLowerCase()` before the `OR`-match on `username` / `email`, but `registerSchema` applies no transform on write — both columns are stored exactly as typed. A user registered as `MixedCase@Example.com` would never resolve and iOS onboarding would route them to the sign-up branch despite an existing account. The route now queries the identifier verbatim; tests pin both the casing invariant and the rate-limit 429 path. +- **Soft-delete invisibility in three remaining reader tiers.** The v1.4.40 audit closure wired `deletedAt: null` filtering through eleven core read paths. The remaining three — `/api/export` bundle reads, `/api/gamification/achievements` queries, and the doctor-report PDF aggregator — are now also filtered. Integration tests under `src/app/api/export/__tests__/soft-delete-filter.test.ts` pin the invariant on all five W-DELETED-2 reader tiers (the prior pass shipped assertions for three out of the five the file header listed). +- **Tree hygiene — retired v1.4.39 W-SUM legacy-NULL UNION arm.** The `sum_value IS NULL` discovery arm in `enqueueBootTimeRollupBackfill` was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated the writer change. Production data has since converged, so the arm is removed; per-day missing coverage remains the sole discovery anchor. The read-side `mean × count` fallback in `/api/dashboard/summary` retains legacy-row readability for any self-host operator that has not converged. +- **Lint warnings cleared, unused `tx?` params dropped, 13 dead exports trimmed.** Five `@typescript-eslint/no-unused-vars` warnings on `src/app/insights/page.tsx` and `src/lib/analytics/summaries-slice.ts` removed (leftover from the v1.4.37.2 GROUP BY rewrite). `recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`, and `recomputeMedicationComplianceForEvent` lose their dead optional `tx?: Prisma.TransactionClient` parameter — no call site ever passed one. Thirteen knip-flagged dead exports narrowed or removed (project-wide knip drops from 48 → 35 unused exports; the exported-types bucket at 52 is deferred to v1.4.42 — most are zod-`infer` downstream types consumed via JSON or the iOS contract and require a per-flag audit). Two mid-file `import type` lines (`src/app/insights/page.tsx`, `src/components/onboarding/getting-started-checklist.tsx`) lifted to the top-of-file import block; the duplicate `DataSummary` import alias in `src/types/analytics.ts` + `src/app/page.tsx` collapsed to a single canonical name. + +### Performance + +Anchored on the v1.4.41 W-INSIGHTS-HOT / W-RECONCILE-INSIGHTS wave reports; numbers ride the post-deploy window. + +- **Seven insights `*-status` routes: subsequent-mount path 14–20 s → ~50 ms.** Once the AI provider has stalled once that day, the timeout-stub short-circuit eliminates the re-race for the rest of the day. The first-of-day stall still spends the 20 s budget (same as bmi-status pre-v1.4.41). The recurring case the iOS client hits is the subsequent-mount path — eliminated on every status route the dashboard touches. + +### Operator notes + +- **No migration.** No env-var change. No API contract break for iOS v0.5.4 — every existing response shape is byte-compatible; the new route (`POST /api/auth/check-user`) is an additive surface; the timeout-stub fallback writes use the existing `audit_log` table. +- **AP-2 .p8 key install is now closed.** The APNs `time-sensitive + priority 10` payload (shipped in v1.4.40 SB-5) is effective as of v1.4.41 deploy — the `.p8` private key is installed in the Coolify secret store. `aps_last_error` should no longer surface as `auth-failed` on `/api/notifications/status`; the iOS team can verify Focus-bypass behaviour against the live deploy. +- **`/api/dashboard/widgets` 422 on iOS is an iOS payload-shape mismatch, not a server validator gap.** W-IOS-COORD traced the recurring 422 to one of three iOS payload candidates (unknown widget id, out-of-range `order`, missing required field). The server validator is correct and additive-safe. The iOS team picks up the investigation against the next iOS build; v1.4.42 will land multi-issue Zod-error diagnostics so a future shape mismatch reports every offending field instead of just the first. +- **Knip exports / types tier remains staged.** Post-v1.4.41 baseline: 35 unused exports + 52 unused exported types. The `--include` flag flip to enforcing mode is deferred to v1.4.42 once the `knip.json` ignore block has been triaged (the remainder is dominated by zod-`infer` downstream types, shadcn surface area exports, and `typeof X[number]` alias backings — each needs a per-flag audit). +- `pnpm test --run` green at 4732 passing / 1 skipped (4733 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip --include files,dependencies,binaries,unlisted` all green. + ## [1.4.40] — 2026-05-21 — Comprehensive Critical+High architecture closure and iOS PB30 enablement v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumulative metrics, and closed the dashboard read paths the rollup tier replaced. v1.4.40 is the architecture-closure release on top of that base: the Prisma pool starvation root cause documented in the v1.4.39 empirical trace is fixed at the source, the soft-delete invisibility contract is now consistent across every reader tier (the audit's Critical Finding #3), the per-tile Suspense boundaries that let dashboard chart tiles stream independently are in place, the queryKey factory has CI enforcement that catches bare-literal regressions, and the iOS PB30 backend prerequisites (Apple App-Site Association, AI consent receipts CRUD, time-sensitive APNs payload, public privacy page, notifications/status surface) are live so the iOS v0.5.x sprint can land its dependent screens without further backend churn. diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 581ea43e3..3b3c975a3 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -50,3 +50,74 @@ dependency is Postgres; both wait on `db: condition: service_healthy`. reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental cross-mode invocation aborts immediately instead of silently doubling cron load. + +## Postgres connection-pool sizing (v1.4.40 W-POOL) + +Every container that boots the HealthLog image opens its own +`pg.Pool` against the configured `DATABASE_URL`. The pool ceiling is +**20 connections per container by default** (raised from the library +default of 10 in v1.4.40 to keep the dashboard's analytics fan-out +from starving every other query during a cold mount). + +Plan total Postgres slots as **container_count × `DATABASE_POOL_MAX`**. +A stock Postgres 16 container ships with `max_connections = 100`, so +the safe envelope is: + +| Web replicas | Worker replicas | Total containers | Pool slots used | Headroom under 100 | +| ------------ | --------------- | ---------------- | --------------- | ------------------ | +| 1 | 0 | 1 | 20 | 80 | +| 1 | 1 | 2 | 40 | 60 | +| 2 | 1 | 3 | 60 | 40 | +| 3 | 1 | 4 | 80 | 20 | +| 4 | 1 | 5 | 100 | 0 (do not exceed) | + +Once the table tips into the "0 headroom" row, every other client of +the same Postgres — `psql` sessions, ad-hoc backups, the Prisma CLI +during a migration deploy — will get `FATAL: sorry, too many clients +already`. Either raise `max_connections` on the Postgres side, lower +`DATABASE_POOL_MAX`, or front the database with PgBouncer in +transaction-pooling mode. + +### Overriding the per-container pool ceiling + +Set `DATABASE_POOL_MAX` to a positive integer on every container that +needs a non-default ceiling — both web and worker pick up the same +env var: + +```yaml +# docker-compose.yml — example for a 6-container deployment +services: + app: + image: ghcr.io/mbombeck/healthlog:latest + environment: + DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100 + HEALTHLOG_PROCESS_TYPE: web +``` + +Rules of thumb: + +- Each web replica's hottest path (`/api/analytics` fan-out, capped at + `p-limit(4)` per v1.4.40 W-POOL) consumes 4 concurrent slots. Keep + `DATABASE_POOL_MAX ≥ 8` so a single power-user request never + exhausts the pool of one container. +- Worker replicas mostly use 1 connection per active pg-boss job; + `DATABASE_POOL_MAX = 8` is plenty unless you raised the pg-boss + `teamConcurrency`. +- If you raise Postgres `max_connections` past 100, prefer raising + `DATABASE_POOL_MAX` over adding more containers — fewer, fatter + Node processes amortise the V8 footprint better than many thin + ones. + +### Why the default is 20 + +The v1.4.39 empirical cold-mount trace showed thick `/api/analytics` +holding ≥ 8 of the 10 default pool slots for 6.5 s on a 347 k-row +tenant, starving every other dashboard query for the duration. The +20-slot default — paired with the `p-limit(4)` cap on the analytics +fan-out — keeps 16 slots free for the rest of the dashboard while +still sitting well under Postgres's 100-slot stock ceiling. + +The implementation lives in `src/lib/db.ts → getPoolMax()`; the +rationale and the audit trail live in +`.planning/round-v1439-empirical-trace.md § B2` and the v1.4.40 +W-POOL phase report. diff --git a/eslint-plugins/healthlog/queryKey-factory.js b/eslint-plugins/healthlog/queryKey-factory.js new file mode 100644 index 000000000..7caffd1ec --- /dev/null +++ b/eslint-plugins/healthlog/queryKey-factory.js @@ -0,0 +1,134 @@ +/** + * @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array + * factory enforcement. + * + * v1.4.41 W-PROCESS-DOCS — promoted from the test-guard substitute + * (`src/lib/__tests__/query-keys.test.ts`, factory-bypass guard added + * in v1.4.40 W-RSC) to a real ESLint rule. The test-guard substitute + * stays in place as a belt-and-braces safety net for repo-walk + * coverage of files the ESLint config might miss; this rule fires at + * authoring time inside the IDE and at lint CI time. + * + * The rule flags any object-property `queryKey: [ … ]` or + * `mutationKey: [ … ]` whose value is a bare ArrayExpression — i.e. + * the bypass shape that drove audit-H1 in `.planning/round-v1439- + * arch-qa-frontend.md`. Acceptable shapes: + * + * - `queryKey: queryKeys.something(arg)` — factory call + * - `queryKey: someLocal` — indirect identifier + * - `queryKey: cond ? queryKeys.a() : queryKeys.b()` — conditional via factory + * + * Files outside `WHITELISTED_FILES` are exempt entirely — the + * test-guard substitute holds the "guarded surface" line until a + * future wave extends both the guard and this whitelist together. + * + * @see src/lib/query-keys.ts — factory home (do not lint as offender) + * @see src/lib/__tests__/query-keys.test.ts — test-guard substitute + * @see .planning/phase-W-PROCESS-DOCS-v1441-report.md + */ + +"use strict"; + +// Files that are ALLOWED to declare a literal-array queryKey. Mirrors +// the test-guard substitute's `guardedRoots` allowlist inverted: every +// file under those roots MUST use the factory, every file outside is +// exempt for now. Path matches use `String#includes` semantics against +// the absolute filename — keep entries as posix-style suffix paths. +const FACTORY_HOME_FILES = [ + // The factory definition file is allowed to declare literal keys — + // that's the whole point of the factory. + "src/lib/query-keys.ts", + // The factory's own unit-test file constructs literal keys to assert + // the factory output shape. + "src/lib/__tests__/query-keys.test.ts", +]; + +// Directories under which any `queryKey: [ … ]` literal is an error. +// Extend in lockstep with the test-guard substitute's `guardedRoots`. +const GUARDED_DIRECTORIES = [ + "src/components/charts", + "src/components/comparison", + // v1.4.41 W-FRONTEND-FACTORY — auth + notifications migrated to the + // factory. Mirror the test-guard's guardedRoots so IDE/lint CI fail + // at the same boundary the unit-test walker does. + "src/app/auth", + "src/app/notifications", +]; + +// Individual guarded files outside the directories above. +const GUARDED_FILES = [ + "src/app/page.tsx", + "src/hooks/use-auth.ts", + // v1.4.41 W-FRONTEND-FACTORY — about-section migrated. + "src/components/settings/about-section.tsx", +]; + +function isGuarded(filename) { + // Factory home is never an offender even if it shows up under a + // guarded directory by accident. + for (const home of FACTORY_HOME_FILES) { + if (filename.endsWith(home)) return false; + } + for (const dir of GUARDED_DIRECTORIES) { + if (filename.includes(`/${dir}/`)) return true; + } + for (const file of GUARDED_FILES) { + if (filename.endsWith(file)) return true; + } + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const queryKeyFactoryRule = { + meta: { + type: "problem", + docs: { + description: + "Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.()` from `src/lib/query-keys.ts` instead.", + recommended: false, + }, + messages: { + bareArray: + "Bare-array `{{prop}}: [ … ]` bypasses the queryKeys factory. Import from `@/lib/query-keys` and call `queryKeys.()` so cache-invalidation bundles stay in lockstep.", + }, + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isGuarded(filename)) { + return {}; + } + + function check(node) { + // `node` is a Property like `queryKey: [ … ]`. Only flag the + // bare ArrayExpression form — identifiers, call expressions, + // conditional expressions, etc. are all acceptable indirection + // through the factory. + if (!node.value || node.value.type !== "ArrayExpression") return; + + const keyName = + node.key.type === "Identifier" + ? node.key.name + : node.key.type === "Literal" + ? node.key.value + : null; + if (keyName !== "queryKey" && keyName !== "mutationKey") return; + + context.report({ + node, + messageId: "bareArray", + data: { prop: keyName }, + }); + } + + return { + Property: check, + }; + }, +}; + +module.exports = { + rules: { + "queryKey-factory": queryKeyFactoryRule, + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d1b..37d18ab88 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; +import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js"; const eslintConfig = defineConfig([ ...nextVitals, @@ -12,7 +13,22 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // v1.4.41 — ignore Claude Code session worktrees so a mid-marathon + // worktree on an older commit can't poison `pnpm lint` locally. + // CI never sees this path; the rule is for the dev environment. + ".claude/**", ]), + // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array + // `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the + // files the queryKeys factory currently guards. Promotes the + // test-guard substitute from v1.4.40 W-RSC to a real ESLint rule + // so IDE + CI both fail fast on a factory bypass. See + // `eslint-plugins/healthlog/queryKey-factory.js` for the + // whitelist + rationale. + { + plugins: { healthlog: healthlogPlugin }, + rules: { "healthlog/queryKey-factory": "error" }, + }, ]); export default eslintConfig; diff --git a/package.json b/package.json index 5d59a9ac5..46c3439f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.40", + "version": "1.4.41", "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", diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts new file mode 100644 index 000000000..b82c03cd9 --- /dev/null +++ b/src/__tests__/api/auth/check-user/route.test.ts @@ -0,0 +1,161 @@ +/** + * v1.4.41 W-IOS-COORD SB-7 follow-up — pin the four-branch discovery + * contract for `POST /api/auth/check-user`. The iOS onboarding screen + * branches on `branch` to decide which credential form to render; any + * regression that drops a branch silently makes the wrong form appear. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 15 * 60 * 1000, + }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "@/app/api/auth/check-user/route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; + +interface CheckUserBody { + data: { + branch: "not_found" | "passkey_only" | "email_fallback" | "exists"; + hasPasskey: boolean; + hasPassword: boolean; + }; +} + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/auth/check-user", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as Parameters[0]; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now() + 15 * 60 * 1000, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("POST /api/auth/check-user", () => { + it("returns `not_found` when no account matches the identifier", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + const res = await POST(makeRequest({ identifier: "ghost@example.com" })); + const body = (await res.json()) as CheckUserBody; + expect(res.status).toBe(200); + expect(body.data.branch).toBe("not_found"); + expect(body.data.hasPasskey).toBe(false); + expect(body.data.hasPassword).toBe(false); + }); + + it("returns `passkey_only` when the user has passkeys and no password", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "u1", + passwordHash: null, + _count: { passkeys: 2 }, + } as never); + const res = await POST(makeRequest({ identifier: "marc" })); + const body = (await res.json()) as CheckUserBody; + expect(body.data.branch).toBe("passkey_only"); + expect(body.data.hasPasskey).toBe(true); + expect(body.data.hasPassword).toBe(false); + }); + + it("returns `email_fallback` when the user has a password (regardless of passkey count)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "u2", + passwordHash: "argon2id$…", + _count: { passkeys: 1 }, + } as never); + const res = await POST(makeRequest({ identifier: "marc" })); + const body = (await res.json()) as CheckUserBody; + expect(body.data.branch).toBe("email_fallback"); + expect(body.data.hasPasskey).toBe(true); + expect(body.data.hasPassword).toBe(true); + }); + + it("returns `exists` when the user has neither passkey nor password (recovery path)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "u3", + passwordHash: null, + _count: { passkeys: 0 }, + } as never); + const res = await POST(makeRequest({ identifier: "marc" })); + const body = (await res.json()) as CheckUserBody; + expect(body.data.branch).toBe("exists"); + expect(body.data.hasPasskey).toBe(false); + expect(body.data.hasPassword).toBe(false); + }); + + it("422s when identifier is missing or blank", async () => { + const res = await POST(makeRequest({})); + expect(res.status).toBe(422); + }); + + it("queries the identifier exactly as iOS sends it (no case-fold)", async () => { + // Register stores email + username verbatim (no `.toLowerCase()` + // transform in `registerSchema`); folding here would route a + // mixed-case existing account to the sign-up branch. + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + await POST(makeRequest({ identifier: "MixedCase@Example.com" })); + expect(prisma.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [ + { username: "MixedCase@Example.com" }, + { email: "MixedCase@Example.com" }, + ], + }, + }), + ); + }); + + it("returns 429 when the per-IP rate-limit is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 15 * 60 * 1000, + }); + const res = await POST(makeRequest({ identifier: "anyone@example.com" })); + expect(res.status).toBe(429); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 8b3f03e47..aa1a62d25 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -47,15 +47,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { ); }); - it("wraps each tile-strip cell in a `` boundary", () => { + it("wraps each tile-strip cell in a `` boundary with a layout-stable placeholder", () => { const src = load(PAGE_PATH); - // `{entry.node}` — the tile - // body is synchronous today so a `null` fallback never paints, - // but the boundary primes the row for a future RSC hoist of any - // tile slot. Pinning the literal keeps the structural contract - // visible in CI. + // v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null` + // fallback for an `aria-hidden` placeholder div that mirrors the + // trend-card chrome. The tile body is synchronous today so the + // fallback rarely paints, but a future RSC hoist of any tile slot + // would otherwise leave the grid track empty and trigger CLS as + // the cell paints in. The structural pin guards the contract. expect(src).toMatch( - /\s*\{entry\.node\}\s*<\/Suspense>/, + /\s*\{entry\.node\}\s*<\/Suspense>/, ); }); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 689c7a442..06fb9bf70 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -10,34 +10,13 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts` +// so callers (in particular `components/admin/backups-section.tsx`) don't +// have to reach across the component → route-handler layer boundary. +import type { BackupRow, BackupsList } from "@/types/backups"; export const dynamic = "force-dynamic"; -export interface BackupRow { - id: string; - userId: string; - username: string; - type: string; - /** - * Size of the encrypted blob in bytes — useful for capacity planning. - * The blob itself is never returned. - */ - sizeBytes: number; - createdAt: string; -} - -export interface BackupsList { - rows: BackupRow[]; - /** - * Soft retention hint — the worker is configured for weekly backups - * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and - * the model upserts in-place per (userId, type), so each user has - * exactly one current snapshot. The frontend uses this to label the - * grid; no server-side enforcement. - */ - retentionDays: number; -} - export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.backups.list" } }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index 8bba954da..1fd192828 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -38,7 +38,9 @@ export const GET = apiHandler(async () => { appSettings, ] = await Promise.all([ prisma.user.count(), - prisma.measurement.count(), + // v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from the + // admin status count so the dashboard tile reflects live data only. + prisma.measurement.count({ where: { deletedAt: null } }), prisma.medication.count(), prisma.medicationIntakeEvent.count(), prisma.apiToken.count({ where: { revoked: false } }), diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts new file mode 100644 index 000000000..5af669687 --- /dev/null +++ b/src/app/api/auth/check-user/route.ts @@ -0,0 +1,114 @@ +/** + * `POST /api/auth/check-user` — four-branch discovery for iOS onboarding. + * + * v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs + * to know, given a typed identifier (username or email), what the next + * UX step should be: + * + * - `not_found` — no account exists; show the sign-up screen. + * - `passkey_only` — account exists, has at least one Passkey, no + * password hash. Show "Sign in with Passkey". + * - `email_fallback` — account exists, has a password hash (with or + * without a Passkey). Show password field plus + * a "Use Passkey" affordance when applicable. + * - `exists` — account exists with no usable credential + * (neither passkey nor password). Treat as + * recovery path; show "Reset access" hint. + * + * The route is intentionally narrow: + * + * - Accepts `{ identifier: string }` (either an email or a username). + * - Returns `{ branch, hasPasskey, hasPassword }` — booleans are + * included so the iOS client can render a "or sign in with Passkey" + * button alongside the password field without a second round-trip. + * - Never leaks PII. The response shape is the same regardless of + * whether the identifier matched (callers learn account-existence + * either way — that is the explicit contract iOS needs). The + * handler does NOT echo the identifier back. + * - Per-IP rate-limited (30 requests / 15 min) so the by-design + * enumeration contract cannot be brute-forced. Mirrors the + * `/api/auth/passkey/login-options` throttle shape. + * - The identifier is queried EXACTLY as iOS sends it (no + * `.toLowerCase()` normalisation). Email + username columns are + * stored verbatim by `/api/auth/register`, so case-folding here + * would route legitimate existing users to the sign-up branch. + * A future server-side normalisation pass on register write is + * the long-term fix. + */ +import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; + +const bodySchema = z.object({ + identifier: z.string().trim().min(1).max(254), +}); + +export type CheckUserBranch = + | "not_found" + | "passkey_only" + | "email_fallback" + | "exists"; + +export const POST = apiHandler(async (request: NextRequest) => { + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit( + `auth:check-user:${ip}`, + 30, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return NextResponse.json( + { data: null, error: "Too many requests. Please try again later." }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return apiError("identifier required", 422); + } + + const identifier = parsed.data.identifier; + + // Match on either username or email — both are unique in the schema. + const user = await prisma.user.findFirst({ + where: { + OR: [{ username: identifier }, { email: identifier }], + }, + select: { + id: true, + passwordHash: true, + _count: { select: { passkeys: true } }, + }, + }); + + if (!user) { + annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } }); + return apiSuccess({ + branch: "not_found" satisfies CheckUserBranch, + hasPasskey: false, + hasPassword: false, + }); + } + + const hasPasskey = user._count.passkeys > 0; + const hasPassword = Boolean(user.passwordHash); + + let branch: CheckUserBranch; + if (hasPasskey && !hasPassword) branch = "passkey_only"; + else if (hasPassword) branch = "email_fallback"; + else branch = "exists"; + + annotate({ action: { name: "auth.check-user" }, meta: { branch } }); + return apiSuccess({ branch, hasPasskey, hasPassword }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 816e122e4..27895ba19 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -48,8 +48,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { expandTodayIntakes } from "@/lib/medication-schedule"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -271,26 +270,15 @@ function buildContext( } /** - * v1.4.39 W-SERVER-FIX-2 — project today's active schedules through - * the canonical `expandTodayIntakes` helper, idempotently backfill any - * missing `MedicationIntakeEvent` rows, and re-read the today-window - * so the caller sees both pre-existing rows + the freshly minted ones. + * v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently + * backfill any missing `MedicationIntakeEvent` rows via the shared + * helper, then re-read the today-window so the caller sees both + * pre-existing rows + the freshly minted ones. * - * Mirrors the post-fix behaviour of `/api/medications/intake?scope= - * today` so the iOS Dashboard tile (fed by this route) and the iOS + * Mirrors the behaviour of `/api/medications/intake?scope=today` (same + * helper) so the iOS Dashboard tile (fed by this route) and the iOS * Erfassen sheet (fed by the intake route) converge on the same row - * set the moment a daily med becomes active. Without this projection - * the dashboard route returned `[]` for daily meds (`daysOfWeek = null`) - * even after the intake route's projection landed, leaving the iOS - * Dashboard tile in its "Heute nichts geplant" empty state all - * morning. - * - * `skipDuplicates: true` guards against a concurrent intake-route hit - * minting the same `(userId, medicationId, scheduledFor, REMINDER)` - * row in the gap between the existence probe and the `createMany`. - * The schema-level `@@unique` is the structural backstop; this flag - * is the defense-in-depth that keeps the route returning 2xx instead - * of bubbling a constraint violation during the race window. + * set the moment a daily med becomes active. */ async function projectAndReadTodaysIntakes( userId: string, @@ -298,89 +286,12 @@ async function projectAndReadTodaysIntakes( todayStart: Date, todayEnd: Date, ): Promise> { - const activeMedications = await prisma.medication.findMany({ - where: { userId, active: true }, - select: { - id: true, - schedules: { - select: { - id: true, - medicationId: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - }, - }, - }, - }); - const projected = expandTodayIntakes( - activeMedications.flatMap((m) => m.schedules), - new Date(), + await projectTodayIntakesAndRecompute({ + userId, userTz, - ); - - if (projected.length > 0) { - const existing = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { medicationId: true, scheduledFor: true }, - }); - const existingKey = new Set( - existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), - ); - const missing = projected.filter( - (p) => - !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), - ); - if (missing.length > 0) { - await prisma.medicationIntakeEvent.createMany({ - data: missing.map((m) => ({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - takenAt: null, - skipped: false, - // `REMINDER` mirrors the intake-route projection — the same - // `IntakeSource` literal the reminder worker uses for - // RED-phase rows. Doctor-report + analytics filters stay - // byte-stable across both code paths. - source: "REMINDER", - })), - skipDuplicates: true, - }); - - // v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4. - // Mirrors the intake-route call site: after a bulk projection - // backfill, fire one rollup recompute per distinct - // `(medication_id, dayKey)` so the rollup row reflects the new - // `scheduled` count before the next read. Best-effort: errors - // stay caught inside the helper so a populator hiccup doesn't - // surface as a 5xx on the dashboard summary. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - // Defence in depth (specialist H1): the helper swallows internally - // today, but any future refactor that lets a throw escape would - // turn the dashboard summary into a 5xx. `allSettled` keeps the - // best-effort contract explicit at the call site so the request - // stays 200-OK regardless of helper behaviour. - await Promise.allSettled(recomputeJobs); - } - } + todayStart, + todayEnd, + }); return prisma.medicationIntakeEvent.findMany({ where: { diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts index ec175f797..ca24d22f7 100644 --- a/src/app/api/doctor-report/availability/route.ts +++ b/src/app/api/doctor-report/availability/route.ts @@ -60,6 +60,10 @@ export const POST = apiHandler(async (request: NextRequest) => { userId: user.id, measuredAt: { gte: start, lte: end }, type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + // v1.4.41 W-DELETED-2 — hide soft-deleted rows from the + // section-availability probe so the dialog does not offer a + // section that would render an empty PDF block. + deletedAt: null, }, }), prisma.measurement.count({ @@ -67,6 +71,7 @@ export const POST = apiHandler(async (request: NextRequest) => { userId: user.id, measuredAt: { gte: start, lte: end }, type: "WEIGHT", + deletedAt: null, }, }), prisma.measurement.count({ @@ -74,6 +79,7 @@ export const POST = apiHandler(async (request: NextRequest) => { userId: user.id, measuredAt: { gte: start, lte: end }, type: "PULSE", + deletedAt: null, }, }), prisma.moodEntry.count({ @@ -87,6 +93,7 @@ export const POST = apiHandler(async (request: NextRequest) => { userId: user.id, measuredAt: { gte: start, lte: end }, type: "SLEEP_DURATION", + deletedAt: null, }, }), prisma.user.findUnique({ diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts new file mode 100644 index 000000000..04e454684 --- /dev/null +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -0,0 +1,164 @@ +/** + * v1.4.41 W-DELETED-2 — pin soft-delete invisibility across the remaining + * reader tiers that W-DELETED-1 (v1.4.40) did not cover: + * - /api/export (legacy CSV/JSON endpoint), + * - /api/export/full-backup (single-file JSON bundle), + * - /api/export/measurements (per-type CSV), + * - /api/doctor-report/availability (section probe), + * - /api/gamification/achievements (achievement progress aggregator). + * + * Every assertion is shape-level: the route's measurement read MUST scope + * to `deletedAt: null` so an iOS undo, a pending-sync row, or a server- + * side admin delete never reaches the user's downloaded file / PDF / + * badge state. We do not exercise the full DB; mocks are enough because + * Prisma's `where` is the contract under test here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn(), count: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, + moodEntry: { findMany: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn() }, + passkey: { findMany: vi.fn() }, + apiToken: { count: vi.fn() }, + session: { count: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + auditEvent: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/auth", () => ({ + requireAuth: vi.fn().mockResolvedValue({ user: { id: "user-1" } }), +})); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ + emitStructuredLog: vi.fn(), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), +})); + +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + user: { + id: "user-1", + email: "test@example.com", + role: "USER", + }, +} as const; + +function mkReq(url: string): NextRequest { + return new NextRequest(url, { method: "GET" }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.count).mockResolvedValue(0); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.count).mockResolvedValue(0); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never); +}); + +describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { + it("/api/export/measurements scopes the read to deletedAt: null", async () => { + const { GET } = await import("../measurements/route"); + await GET(mkReq("http://localhost/api/export/measurements")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("/api/export/full-backup scopes the measurement read to deletedAt: null", async () => { + const { GET } = await import("../full-backup/route"); + await GET(mkReq("http://localhost/api/export/full-backup")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("/api/export (legacy) scopes the measurement read to deletedAt: null", async () => { + const { GET } = await import("../route"); + await GET(mkReq("http://localhost/api/export?type=measurements")); + expect(prisma.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ deletedAt: null }), + }), + ); + }); + + it("/api/gamification/achievements scopes the measurement read to deletedAt: null", async () => { + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + const { GET } = await import( + "../../gamification/achievements/route" + ); + await GET(mkReq("http://localhost/api/gamification/achievements")); + const calls = vi.mocked(prisma.measurement.findMany).mock.calls; + // The achievement aggregator reads recent measurements via + // findMany — a tombstoned row must never count toward a streak + // or PR badge. + expect(calls.length).toBeGreaterThan(0); + for (const [arg] of calls) { + expect(arg).toMatchObject({ + where: expect.objectContaining({ deletedAt: null }), + }); + } + }); + + it("/api/doctor-report/availability scopes every measurement count to deletedAt: null", async () => { + const { POST } = await import( + "../../doctor-report/availability/route" + ); + const req = new NextRequest( + "http://localhost/api/doctor-report/availability", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }, + ); + await POST(req); + const calls = vi.mocked(prisma.measurement.count).mock.calls; + // The probe runs four parallel measurement.count queries (BP / + // weight / pulse / sleep). All must filter tombstoned rows so a + // soft-deleted history does not light up a section the user has + // since wiped. + expect(calls.length).toBeGreaterThanOrEqual(4); + for (const [arg] of calls) { + expect(arg).toMatchObject({ + where: expect.objectContaining({ deletedAt: null }), + }); + } + }); +}); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 5cf342878..469192933 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -36,7 +36,10 @@ export const GET = apiHandler(async (request: NextRequest) => { const [measurements, medications, intakeEvents, moodEntries] = await Promise.all([ prisma.measurement.findMany({ - where: { userId: user.id }, + // v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the + // user-initiated backup bundle so the round-trip via admin + // restore does not resurrect deleted measurements. + where: { userId: user.id, deletedAt: null }, orderBy: { measuredAt: "desc" }, }), // Explicit `select` on the schedule columns — see the matching diff --git a/src/app/api/export/measurements/route.ts b/src/app/api/export/measurements/route.ts index 3eb5e4355..cd3fa45c9 100644 --- a/src/app/api/export/measurements/route.ts +++ b/src/app/api/export/measurements/route.ts @@ -89,9 +89,21 @@ function safeDate(value: string): Date | undefined { function buildWhere( userId: string, range: { since?: Date; until?: Date }, -): { userId: string; measuredAt?: { gte?: Date; lte?: Date } } { - const where: { userId: string; measuredAt?: { gte?: Date; lte?: Date } } = { +): { + userId: string; + deletedAt: null; + measuredAt?: { gte?: Date; lte?: Date }; +} { + // v1.4.41 W-DELETED-2 — soft-deleted measurements stay out of every + // per-type CSV download so an undo or pending-sync row never reaches + // the user's local file. + const where: { + userId: string; + deletedAt: null; + measuredAt?: { gte?: Date; lte?: Date }; + } = { userId, + deletedAt: null, }; if (range.since || range.until) { where.measuredAt = {}; diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index e1f678583..e688a44e3 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -55,7 +55,10 @@ export const GET = apiHandler(async (request: NextRequest) => { if (type === "measurements" || type === "all") { const measurements = await prisma.measurement.findMany({ - where: { userId }, + // v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from + // the legacy /api/export endpoint so CSV + JSON downloads stay + // consistent with the live measurement reads. + where: { userId, deletedAt: null }, orderBy: { measuredAt: "desc" }, }); data.measurements = diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index b81be4833..a6ab20dc1 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -536,6 +536,10 @@ async function buildAchievementsResult(user: AuthedUser) { type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], }, + // v1.4.41 W-DELETED-2 — soft-deleted measurements are excluded + // from achievement progress so deleting a row immediately rolls + // back any streak / count badge it earned. + deletedAt: null, }, select: { type: true, diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 439d3630d..bd1c7d9c3 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -91,7 +91,7 @@ vi.mock("@/lib/insights/features", async () => { }; }); -vi.mock("@/lib/insights/prompt", () => ({ +vi.mock("@/lib/ai/prompts/insight-system-prompt", () => ({ buildUserPrompt: vi.fn(() => "user"), })); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index eee951223..b20dc8d45 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -6,7 +6,7 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; -import { compactSections } from "@/lib/insights/prompt-compact"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { detectGlp1Plateau, buildGlp1PlateauPrompt, @@ -14,7 +14,7 @@ import { import { buildUserPrompt, type ComparisonSnapshot, -} from "@/lib/insights/prompt"; +} from "@/lib/ai/prompts/insight-system-prompt"; import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 5a8d475e3..957b499df 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -33,7 +33,7 @@ import { enqueueUserMedicationComplianceBackfill, type ComplianceBucket as RollupComplianceBucket, } from "@/lib/rollups/medication-compliance-rollups"; -import { expandTodayIntakes } from "@/lib/medication-schedule"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -93,103 +93,15 @@ export const GET = apiHandler(async (request: NextRequest) => { // for daily meds (`schedule.daysOfWeek = null` in the DB) until // the reminder worker entered the RED phase at the end of the // dose window — leaving the iOS Dashboard tile + the "Erfassen" - // sheet empty for the whole morning. The projection re-uses the - // canonical `parseScheduleRecurrence` (null/empty = every day) - // so the daysOfWeek semantics stay aligned across every reader. - const activeMedications = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, - select: { - id: true, - schedules: { - select: { - id: true, - medicationId: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - }, - }, - }, - }); - const projected = expandTodayIntakes( - activeMedications.flatMap((m) => m.schedules), - new Date(), + // sheet empty for the whole morning. Shared helper mirrors the + // dashboard-summary call site so the two routes converge on the + // same row set. + const { projected, backfilled } = await projectTodayIntakesAndRecompute({ + userId: user.id, userTz, - ); - - const existing = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { medicationId: true, scheduledFor: true }, + todayStart, + todayEnd, }); - const existingKey = new Set( - existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), - ); - const missing = projected.filter( - (p) => - !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), - ); - if (missing.length > 0) { - await prisma.medicationIntakeEvent.createMany({ - data: missing.map((m) => ({ - userId: user.id, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - takenAt: null, - skipped: false, - // `REMINDER` is the same source the reminder worker uses to - // mint RED-phase rows — semantically "the server projected - // this slot before the user logged anything". The IntakeSource - // enum has no separate `SCHEDULER` literal; reusing REMINDER - // keeps the doctor-report + analytics filters byte-stable. - source: "REMINDER", - })), - // v1.4.39 W-SERVER-FIX-2 — paired with the schema-level - // @@unique([userId, medicationId, scheduledFor, source]). A - // concurrent dashboard-summary hit (the second route that now - // runs the same projection) can race a duplicate row in - // between this route's existence probe and createMany; the - // flag tells Postgres to swallow the rejected rows so the - // request still returns 2xx. The unique constraint is the - // structural backstop, this is the defense-in-depth. - skipDuplicates: true, - }); - - // v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4. - // The bulk projection mints fresh `(medicationId, scheduledFor)` - // rows in PENDING state; without this hook the rollup for the - // affected `(user, medication, day)` tuples stays at its previous - // (pre-projection) `scheduled` count, which inflates the apparent - // compliance % until the user actually logs against the new - // row. Coalesce by `(medicationId, dayKey)` so we fire one - // recompute per distinct day-tuple instead of one per row. - // Best-effort: errors stay caught inside the helper so a - // populator hiccup doesn't surface as a 5xx on the intake - // request. Mirror call site in `dashboard/summary/route.ts`. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId: user.id, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - // Defence in depth (specialist H1): the helper swallows internally - // today, but any future refactor that lets a throw escape would - // turn the intake POST into a 5xx. `allSettled` keeps the - // best-effort contract explicit at the call site so the request - // stays 200-OK regardless of helper behaviour. - await Promise.allSettled(recomputeJobs); - } const events = await prisma.medicationIntakeEvent.findMany({ where: { @@ -204,8 +116,8 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "medications.intake.today" }, meta: { count: events.length, - projected: projected.length, - backfilled: missing.length, + projected, + backfilled, }, }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index b52e82d27..af8958252 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { describePasskeyError } from "@/lib/passkey-errors"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; export default function LoginPage() { const router = useRouter(); @@ -30,7 +31,7 @@ export default function LoginPage() { const errorId = useId(); const errorDescriptor = error ? errorId : undefined; const { data: registrationEnabled } = useQuery({ - queryKey: ["auth", "registration-status"], + queryKey: queryKeys.authRegistrationStatus(), queryFn: async () => { const res = await fetch("/api/auth/registration-status", { cache: "no-store", @@ -84,7 +85,7 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push(getRedirectTarget()); } catch (err) { const { key, params } = describePasskeyError(err); @@ -113,7 +114,7 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push(getRedirectTarget()); } catch { setError(t("auth.loginFailed")); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index d4dead215..73e56ac8c 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; import { detectBrowserTimezone } from "@/lib/tz/format"; +import { queryKeys } from "@/lib/query-keys"; export default function RegisterPage() { const router = useRouter(); @@ -54,7 +55,7 @@ export default function RegisterPage() { return; } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); router.push("/"); } catch { setError(t("auth.registerFailed")); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 3b2e3ec05..e6be98228 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -16,8 +16,10 @@ import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import type { CorrelationResult } from "@/lib/insights/correlations"; -import type { DataSummary } from "@/lib/analytics/trends"; +// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as +// `InsightsAnalyticsData`; aliased back to the local name to keep the +// rest of this file readable. +import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics"; /** * v1.4.33 IW2 — defer the three below-the-fold mother-page blocks @@ -96,49 +98,6 @@ interface ComprehensiveData { totalMeasurements: number; } -interface AnalyticsData { - summaries: Record; - correlations?: { - bpCompliance: CorrelationResult; - moodPulse: CorrelationResult; - weightWeekday: CorrelationResult; - } | null; - healthScore?: { - score: number; - band: "green" | "yellow" | "red"; - components: { - // v1.4.25 W8e — the optional `source`/`asOf` slots feed the - // provenance accordion. Older clients reading this payload - // happily ignore the extras (additive contract). - bp: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - weight: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - mood: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - compliance: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - }; - delta: number | null; - } | null; -} - export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 3f9e44d84..73e35020f 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -12,6 +12,7 @@ import { EVENT_DEFAULT_ENABLED, type EventType, } from "@/lib/notifications/types"; +import { queryKeys } from "@/lib/query-keys"; interface ChannelInfo { id: string; @@ -86,7 +87,7 @@ export default function NotificationsPage() { const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { const res = await fetch("/api/notifications/preferences"); if (!res.ok) throw new Error("Failed to load preferences"); @@ -112,12 +113,11 @@ export default function NotificationsPage() { }, onMutate: async ({ channelId, eventType, enabled }) => { await queryClient.cancelQueries({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), }); - const prev = queryClient.getQueryData([ - "notifications", - "preferences", - ]); + const prev = queryClient.getQueryData( + queryKeys.notificationsPreferences(), + ); if (prev) { const existing = prev.preferences.find( (p) => p.channelId === channelId && p.eventType === eventType, @@ -130,7 +130,7 @@ export default function NotificationsPage() { ) : [...prev.preferences, { channelId, eventType, enabled }]; queryClient.setQueryData( - ["notifications", "preferences"], + queryKeys.notificationsPreferences(), { ...prev, preferences: newPreferences }, ); } @@ -139,14 +139,14 @@ export default function NotificationsPage() { onError: (_err, _vars, context) => { if (context?.prev) { queryClient.setQueryData( - ["notifications", "preferences"], + queryKeys.notificationsPreferences(), context.prev, ); } }, onSettled: () => { queryClient.invalidateQueries({ - queryKey: ["notifications", "preferences"], + queryKey: queryKeys.notificationsPreferences(), }); }, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index e42ccc10e..2caa13078 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,7 +24,7 @@ import { resolveDashboardLayout, type DashboardLayout, } from "@/lib/dashboard-layout"; -import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends"; +import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -91,46 +91,6 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -interface AnalyticsData { - summaries: Record; - bpInTargetPct: number | null; - /** - * v1.4.18 A1 — share of paired BP readings inside target over the - * last 7 / 30 days. Drive the BD-Zielbereich tile's `7T:` / `30T:` - * sub-values; render "—" when the field is null (no paired readings - * in the window). - */ - bpInTargetPct7d?: number | null; - bpInTargetPct30d?: number | null; - /** - * v1.4.22 A1 — long-arc all-time aggregate. After the headline - * re-anchor to last-30-days the all-time number lives as a sub-value - * on the BD-Zielbereich tile (alongside `7d` and `30d`). - */ - bpInTargetPctAllTime?: number | null; - /** - * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window - * pcts. The BD-Zielbereich tile's comparison-overlay caption picks - * `priorMonth` for `comparisonBaseline === "lastMonth"` and - * `priorYear` for `lastYear` so the rendered "Δ X% vs. last month" - * stays honest. Null when the prior window has no paired readings. - */ - bpInTargetPctPriorMonth?: number | null; - bpInTargetPctPriorYear?: number | null; - glucoseByContext?: Record; - /** - * v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The - * tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards - * it to each `` so a metric the user hasn't - * logged in a while keeps its tile visible (with an explicit - * "Letzter Wert vor …" caption) instead of disappearing. - */ - lastSeenByType?: Record< - string, - { lastSeenAt: string; daysAgo: number } | null - >; -} - interface RangeDisplayConfig { range: TrafficRange | null; } @@ -279,7 +239,7 @@ export default function DashboardPage() { bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, glucoseByContext: merged.glucoseByContext as - | Record + | Record | undefined, }; }, [analyticsSlimQuery.data, analyticsThickQuery.data]); @@ -1452,7 +1412,25 @@ export default function DashboardPage() { * future RSC hoists of any tile slot won't need a * second pass to add the fallback infrastructure. */} - {entry.node} + {/* + * v1.4.41 W-FRONTEND-FACTORY (UX M1) — swap the + * `null` fallback for a layout-stable placeholder + * that mirrors the trend-card chrome. The body + * stays synchronous today so the fallback rarely + * paints, but a future RSC hoist of any tile slot + * would otherwise leave the grid track empty and + * trigger CLS as the cell paints in. + */} +
))} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index be8d6eadb..374638108 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -41,7 +41,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { BackupRow, BackupsList } from "@/app/api/admin/backups/route"; +import type { BackupRow, BackupsList } from "@/types/backups"; import { getApiErrorMessage } from "./_shared"; /** diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 7118d6bff..1679c828b 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -64,7 +64,7 @@ export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; */ export type TrendSentimentDirection = "positive" | "negative" | "neutral"; -export function getTrendSentiment( +function getTrendSentiment( change: number | null | undefined, sentiment: TrendDirectionSentiment, ): TrendSentimentDirection { diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx index 0e7b5842f..14e6ecd1b 100644 --- a/src/components/insights/personal-record-badge.tsx +++ b/src/components/insights/personal-record-badge.tsx @@ -52,14 +52,12 @@ interface PersonalRecordBadgeProps { const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; /** - * Pure freshness check — exported for unit testing so the component - * itself stays free of clock calls (the React purity linter rejects - * `Date.now()` inside a component body or hook initializer). Callers - * pass a `now` reference; consumers in production read it from the - * surrounding query refetch boundary where one stale-time tick = - * one new clock reading. + * Pure freshness check kept out of the component body so the React + * purity linter doesn't trip on the `Date.now()` reference; callers + * pass `now` from the surrounding query refetch boundary where one + * stale-time tick = one new clock reading. */ -export function isPRWithin30Days(achievedAtIso: string, now: number): boolean { +function isPRWithin30Days(achievedAtIso: string, now: number): boolean { const ts = new Date(achievedAtIso).getTime(); return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS; } diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index c034868ba..d911b1d0b 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -14,6 +14,9 @@ import { X, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as +// `ChecklistAnalyticsData`; aliased back to the local name. +import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; @@ -70,10 +73,6 @@ const ITEM_LABEL_KEYS: Record< }, }; -interface AnalyticsData { - summaries?: Record; -} - interface WithingsStatus { connected?: boolean; } diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 124ebeb88..5f4860240 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; interface VersionPayload { version: string; @@ -117,7 +118,7 @@ export function AboutSection() { const fmt = useFormatters(); const { data: version, isLoading } = useQuery({ - queryKey: ["api", "version"], + queryKey: queryKeys.apiVersion(), queryFn: async () => { const res = await fetch("/api/version"); if (!res.ok) throw new Error("version-fetch-failed"); diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 4d25e19b7..da18335c8 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -49,6 +49,25 @@ describe("queryKeys factory", () => { expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]); }); + // v1.4.41 W-FRONTEND-FACTORY — pin the shapes that auth/login + the + // notifications surface migrated to so a future rename can't drift + // the cache layout silently. + it("exposes auth + notifications keys for the factory-migrated surfaces", () => { + expect(queryKeys.authRegistrationStatus()).toEqual([ + "auth", + "registration-status", + ]); + expect(queryKeys.notificationsPreferences()).toEqual([ + "notifications", + "preferences", + ]); + expect(queryKeys.notificationsStatus()).toEqual([ + "notifications", + "status", + ]); + expect(queryKeys.apiVersion()).toEqual(["api", "version"]); + }); + it("packs chartData params into a stable tuple prefixed by chart-data", () => { const key = queryKeys.chartData( "WEIGHT", @@ -175,6 +194,12 @@ describe("queryKey factory enforcement", () => { join(repoRoot, "src", "components", "comparison"), join(repoRoot, "src", "app", "page.tsx"), join(repoRoot, "src", "hooks", "use-auth.ts"), + // v1.4.41 W-FRONTEND-FACTORY — auth, notifications, and the + // about-section migrated to the factory. Future waves extend + // this list as they migrate their own surface. + join(repoRoot, "src", "app", "auth"), + join(repoRoot, "src", "app", "notifications"), + join(repoRoot, "src", "components", "settings", "about-section.tsx"), ]; function collect(): string[] { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e681de881..605c45910 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -22,7 +22,7 @@ import { type CoachExcludeMetric, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { compactSections } from "@/lib/insights/prompt-compact"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import type { CoachProvenance, diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index fc6628c5f..33eb67473 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -53,20 +53,6 @@ export function generateState(): string { return base64url(randomBytes(32)); } -/** - * Backwards-compatible no-op type. Older callers that imported the error - * type continue to compile. Codex OAuth always considers itself - * configured because we ship the public client ID as the default. - */ -export class CodexOAuthNotConfiguredError extends Error { - constructor() { - super( - "Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID", - ); - this.name = "CodexOAuthNotConfiguredError"; - } -} - export function getCodexClientId(): string { return process.env.CODEX_OAUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID; } diff --git a/src/lib/ai/feedback-attribution.ts b/src/lib/ai/feedback-attribution.ts index 7e6792a5f..7802984d3 100644 --- a/src/lib/ai/feedback-attribution.ts +++ b/src/lib/ai/feedback-attribution.ts @@ -106,7 +106,7 @@ export interface CoachFeedbackAttribution extends FeedbackAttribution { metricSourceType: string; } -export function buildCoachMetricSourceType( +function buildCoachMetricSourceType( tone: string, verbosity: string, ): string { diff --git a/src/lib/insights/__tests__/prompt-compact.test.ts b/src/lib/ai/prompts/__tests__/compact-sections.test.ts similarity index 97% rename from src/lib/insights/__tests__/prompt-compact.test.ts rename to src/lib/ai/prompts/__tests__/compact-sections.test.ts index 4c4ca864b..4a8ca4ba6 100644 --- a/src/lib/insights/__tests__/prompt-compact.test.ts +++ b/src/lib/ai/prompts/__tests__/compact-sections.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { compactSections } from "../prompt-compact"; +import { compactSections } from "../compact-sections"; describe("compactSections", () => { it("drops empty arrays", () => { diff --git a/src/lib/insights/__tests__/prompt-comparison.test.ts b/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts similarity index 98% rename from src/lib/insights/__tests__/prompt-comparison.test.ts rename to src/lib/ai/prompts/__tests__/prompt-comparison.test.ts index 7f445a7e7..da7f4086c 100644 --- a/src/lib/insights/__tests__/prompt-comparison.test.ts +++ b/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts @@ -3,7 +3,7 @@ import { buildUserPrompt, buildComparisonBlock, type ComparisonSnapshot, -} from "@/lib/insights/prompt"; +} from "@/lib/ai/prompts/insight-system-prompt"; /** * v1.4.16 phase B8 — comparison context block in the user prompt. diff --git a/src/lib/insights/prompt-compact.ts b/src/lib/ai/prompts/compact-sections.ts similarity index 100% rename from src/lib/insights/prompt-compact.ts rename to src/lib/ai/prompts/compact-sections.ts diff --git a/src/lib/insights/prompt.ts b/src/lib/ai/prompts/insight-system-prompt.ts similarity index 100% rename from src/lib/insights/prompt.ts rename to src/lib/ai/prompts/insight-system-prompt.ts diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 2f7c79039..be58ff501 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -27,10 +27,7 @@ vi.mock("@/lib/logging/context", () => ({ })); import { prisma } from "@/lib/db"; -import { - computeLongWindowSummary, - computeSummariesSlice, -} from "../summaries-slice"; +import { computeSummariesSlice } from "../summaries-slice"; const RAW = prisma.$queryRaw as unknown as ReturnType; const MEASUREMENT_FIND_FIRST = @@ -453,136 +450,3 @@ describe("computeSummariesSlice", () => { }); }); -/** - * v1.4.39 W-WMY — long-window summary helper. Pins: - * - the helper routes a 365-day window through MONTH buckets, - * - the linearly-composable aggregate (count / min / max / mean / - * sum) lands at the same numbers `aggregateWmyBuckets` produces - * directly, so the rollup math survives round-tripping through - * the summaries-slice entry point, - * - a coverage miss surfaces as a `null` return instead of a - * partial-shape leak. - */ -describe("computeLongWindowSummary", () => { - it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => { - // The first findMany call is the per-tier probe inside - // `readBestGranularityRollups`. With windowDays=365 the router - // walks YEAR (skipped, floor=731) → MONTH (floor=181, matched). - // The MONTH call returns two buckets; the helper composes the - // window aggregate from them. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-01T00:00:00.000Z"), - count: 10, - mean: 82, - sd: 1, - slope: -0.01, - r2: 0.3, - sumValue: null, - minValue: 79, - maxValue: 84, - }, - { - bucketStart: new Date("2026-05-01T00:00:00.000Z"), - count: 20, - mean: 80, - sd: 1.5, - slope: 0.02, - r2: 0.4, - sumValue: null, - minValue: 77, - maxValue: 83, - }, - ]); - - const result = await computeLongWindowSummary("user-1", "WEIGHT", 365); - - expect(result).not.toBeNull(); - expect(result?.granularity).toBe("MONTH"); - expect(result?.bucketCount).toBe(2); - expect(result?.count).toBe(30); - expect(result?.min).toBe(77); - expect(result?.max).toBe(84); - // Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666… - expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5); - expect(result?.sum).toBeNull(); // no sumValue on these buckets - - // The router asked MONTH first (365 d clears the 181-day MONTH - // floor and falls short of the 731-day YEAR floor). - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1); - expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH"); - }); - - it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => { - // YEAR coverage miss → router falls back to MONTH. - ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss - .mockResolvedValueOnce([ - { - bucketStart: new Date("2024-06-01T00:00:00.000Z"), - count: 100, - mean: 81, - sd: 1.2, - slope: 0.005, - r2: 0.15, - sumValue: null, - minValue: 76, - maxValue: 86, - }, - ]); - - const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); - - expect(result?.granularity).toBe("MONTH"); - expect(result?.bucketCount).toBe(1); - expect(result?.count).toBe(100); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); - }); - - it("composes the cumulative sum when every bucket carries sumValue", async () => { - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-01T00:00:00.000Z"), - count: 28, - mean: 8_200, - sd: 1_000, - slope: 0, - r2: 0, - sumValue: 229_600, - minValue: 5_000, - maxValue: 12_000, - }, - { - bucketStart: new Date("2026-05-01T00:00:00.000Z"), - count: 30, - mean: 9_100, - sd: 1_100, - slope: 0, - r2: 0, - sumValue: 273_000, - minValue: 6_000, - maxValue: 12_500, - }, - ]); - - const result = await computeLongWindowSummary( - "user-1", - "ACTIVITY_STEPS", - 365, - ); - - expect(result?.sum).toBe(229_600 + 273_000); - }); - - it("returns null when every granularity misses", async () => { - ROLLUP_FIND_MANY.mockResolvedValue([]); - const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); - expect(result).toBeNull(); - }); - - it("returns null on a zero / negative / NaN window without hitting the DB", async () => { - expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull(); - expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull(); - expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull(); - expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 8a548c23b..83b8c9f56 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -53,7 +53,7 @@ * 100`. Slope `direction` is derived from `slope`'s sign with the * same 0.01-units-per-day "stable" threshold the JS helper uses. */ -import type { MeasurementType, RollupGranularity } from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -64,11 +64,7 @@ import { isFullyCovered, probeRollupCoverage, } from "@/lib/rollups/measurement-coverage"; -import { - aggregateWmyBuckets, - readBestGranularityRollups, - type RollupBucketRow, -} from "@/lib/rollups/measurement-read-wmy"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -656,54 +652,6 @@ async function computeFromLiveAggregate( * "rollup tier empty" cold-mount case, and v1.5's multi-year card * accepts a `null` "no data yet" state. */ -export async function computeLongWindowSummary( - userId: string, - type: MeasurementType, - windowDays: number, -): Promise<{ - granularity: RollupGranularity; - bucketStart: Date | null; - bucketEnd: Date | null; - bucketCount: number; - count: number; - min: number | null; - max: number | null; - mean: number | null; - sum: number | null; -} | null> { - const resolved = await readBestGranularityRollups(userId, type, windowDays); - if (!resolved) return null; - const rows: RollupBucketRow[] = resolved.rows; - const aggregate = aggregateWmyBuckets(rows); - // Annotate which granularity served the window so the operator can - // verify the routing in production wide-events without redeploying - // instrumentation. Mirrors the `slim_summaries.path` annotate - // shape inside `computeFromRollups` / `computeFromLiveAggregate`. - annotate({ - meta: { - analytics: { - long_window_summary: { - type, - window_days: windowDays, - granularity: resolved.granularity, - bucket_count: rows.length, - row_count: aggregate.count, - }, - }, - }, - }); - return { - granularity: resolved.granularity, - bucketStart: rows[0]?.bucketStart ?? null, - bucketEnd: rows[rows.length - 1]?.bucketStart ?? null, - bucketCount: rows.length, - count: aggregate.count, - min: aggregate.min, - max: aggregate.max, - mean: aggregate.mean, - sum: aggregate.sum, - }; -} /** * v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index efa564769..d53df38f8 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -91,7 +91,7 @@ export interface DashboardWidgetConfig { export const COMPARISON_BASELINES = ["none", "lastMonth", "lastYear"] as const; export type ComparisonBaseline = (typeof COMPARISON_BASELINES)[number]; -export function isComparisonBaseline( +function isComparisonBaseline( value: unknown, ): value is ComparisonBaseline { return ( @@ -196,7 +196,7 @@ export interface DashboardLayout { chartOverlayPrefs?: ChartOverlayPrefsMap; } -export const DASHBOARD_LAYOUT_VERSION = 1; +const DASHBOARD_LAYOUT_VERSION = 1; /** * Default layout mirrors v1.1 behavior: core vitals always on, mood + diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index db0fd6846c0b40795e6aac0aa63ed309509a304a..8b074d1f2f704be7042d52020cc4f4da4bca2ac2 100644 GIT binary patch delta 240 zcmaF4fbqy8#tjK7_4@h>WrlhtdM1Vn;kquaKCU6IF1khvj~b>Zq*f&7l%}LA6z8Xv z=%%FRq?V+nDCDLl7MB*K=BDPA6f2|^<>zuKKmpLCl8jV^l>Fq9{36|=)Pnq?5{1O{ z^rF=CM4$-J7zLOiMfv5$3VEqzsYT8TIjM=+3YmE&`RE3D1^fFc6eL#WM@^~8MJmdIIxq_yORN;~N^^21-&GgiyhdfOHURUJ BP^ { diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 6b52d86a1..6de1643df 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -29,7 +29,7 @@ export const DISPLAY_TIMEZONE = "Europe/Berlin"; * `Intl.*`. When we add locales, extend this map — do not rely on the short * tag directly since browsers differ in how they handle "en" vs "en-US". */ -export const INTL_LOCALE_MAP: Record = { +const INTL_LOCALE_MAP: Record = { de: "de-DE", en: "en-US", fr: "fr-FR", diff --git a/src/lib/format.ts b/src/lib/format.ts index 85e72959f..b32a85f3b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -15,11 +15,9 @@ * stay consistent with the server-rendered locale. */ -import { makeFormatters, DISPLAY_TIMEZONE } from "./format-locale"; +import { makeFormatters } from "./format-locale"; import type { Locale } from "./i18n/config"; -export { DISPLAY_TIMEZONE }; - function activeLocale(): Locale { if (typeof document === "undefined") return "en"; // Prefer the cookie (what SSR used) over localStorage to stay in sync diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 7b089aaec..fb76ceb66 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -109,6 +109,106 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () => }); }); +describe("generateBloodPressureStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, + { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + expect(result.text).toBeTruthy(); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeTruthy(); + + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBe(1); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + dateKey?: string; + text?: string; + timeout?: boolean; + model?: string; + }; + expect(parsed.text).toBeTruthy(); + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Blood pressure assessment fallback…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generateBloodPressureStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Blood pressure assessment fallback…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index c7e758b38..1f7cbc6dc 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -100,6 +100,102 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateGeneralStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateGeneralStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + expect(result.text).toBeTruthy(); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeTruthy(); + + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBe(1); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + dateKey?: string; + text?: string; + timeout?: boolean; + model?: string; + }; + expect(parsed.text).toBeTruthy(); + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "General status fallback…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generateGeneralStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("General status fallback…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index cedf9e67c..234873ed5 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -108,6 +108,114 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload" }); }); +describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + const now = new Date(); + const medication = { + id: "med-1", + name: "Ramipril", + dose: "5mg", + active: true, + createdAt: new Date(now.getTime() - 60 * dayMs), + schedules: [{ id: "s1", time: "08:00" }], + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + // Route returns the richer `{summary, medications}` shape — the + // helper writes the persist row and the route maps `summary` ← + // `text` on the way out. + expect(result.summary).toBeTruthy(); + expect(result.medications).toEqual([]); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeTruthy(); + + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBe(1); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + dateKey?: string; + text?: string; + timeout?: boolean; + model?: string; + }; + expect(parsed.text).toBeTruthy(); + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe("generateMedicationComplianceStatusForUser — v1.4.41 timeout-stub short-circuit", () => { + it("subsequent mounts short-circuit when a stub row with text+timeout:true exists", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Medication compliance fallback…", + timeout: true, + }), + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generateMedicationComplianceStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.summary).toBe("Medication compliance fallback…"); + expect(result.medications).toEqual([]); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached summary + per-medication text", async () => { const now = new Date(); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index b9d1e6cb3..bac7611d0 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -88,6 +88,97 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateMoodStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + const t = new Date(); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + { + date: t.toISOString().slice(0, 10), + score: 4, + tags: [], + moodLoggedAt: t, + }, + ] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateMoodStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + expect(result.text).toBeTruthy(); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeTruthy(); + + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBe(1); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + dateKey?: string; + text?: string; + timeout?: boolean; + model?: string; + }; + expect(parsed.text).toBeTruthy(); + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Mood fallback text…", + timeout: true, + }), + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generateMoodStatusForUser("user-1", { locale: "en" }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Mood fallback text…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { const t = new Date(); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 8936b8c6d..088ea2d8b 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -7,6 +7,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; * cached-style envelope so the InsightStatusCard renders the * fallback text instead of spinning behind React-Query's default * retries. + * + * v1.4.41 — the timeout branch now persists a sentinel keyed to + * today (see `persistTimeoutStubAndReturn`) so subsequent mounts + * short-circuit at the cache lookup. The persisted row carries + * `timeout: true` + `model: "timeout-stub"` so the daily pre-warm + * job can recognise and overwrite the stub instead of treating it + * as a real assessment. */ vi.mock("@/lib/db", () => ({ @@ -51,6 +58,9 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { { value: 72, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); // Provider stalls indefinitely. vi.mocked(resolveProvider).mockResolvedValue({ @@ -69,9 +79,19 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { expect(result.cached).toBe(true); expect(typeof result.text).toBe("string"); expect(result.text?.length ?? 0).toBeGreaterThan(0); - // The route must NOT persist a fallback into the audit cache — - // a transient provider stall should not poison tomorrow's cache - // hit. - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.4.41 — the timeout branch now persists a sentinel row so + // subsequent mounts short-circuit at the cache lookup. The + // daily pre-warm worker overwrites the stub by detecting + // `timeout: true` + `model: "timeout-stub"`. + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const createArg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + }; + const parsed = JSON.parse(createArg.data.details) as { + timeout?: boolean; + model?: string; + }; + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index d6562101c..7288196b2 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -82,6 +82,53 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generatePulseStatusForUser — v1.4.41 timeout-stub short-circuit", () => { + // The persist-on-timeout path is covered by + // `pulse-status-timeout.test.ts`. This block pins the second-mount + // behaviour: when a sentinel row already exists for today the cache + // lookup short-circuits and the provider is never invoked. + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Pulse fallback text…", + timeout: true, + }), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generatePulseStatusForUser("user-1", { locale: "en" }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Pulse fallback text…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 82409970f..909dd392f 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -82,6 +82,94 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => { }); }); +describe("generateWeightStatusForUser — v1.4.41 timeout-stub persistence", () => { + it("persists a sentinel row keyed to today when the provider times out", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", value: 82, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + // Provider stalls indefinitely so the timeout race wins. + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: vi.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ), + } as never); + + vi.useFakeTimers(); + const promise = generateWeightStatusForUser("user-1", { locale: "en" }); + await vi.advanceTimersByTimeAsync(25_000); + const result = await promise; + vi.useRealTimers(); + + expect(result.text).toBeTruthy(); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBeTruthy(); + + const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; + expect(createCalls.length).toBe(1); + const details = (createCalls[0][0] as { data: { details: string } }).data + .details; + const parsed = JSON.parse(details) as { + dateKey?: string; + text?: string; + timeout?: boolean; + model?: string; + }; + expect(parsed.text).toBeTruthy(); + expect(parsed.timeout).toBe(true); + expect(parsed.model).toBe("timeout-stub"); + expect(parsed.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("subsequent mounts short-circuit at the cache lookup and skip the race", async () => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const y = parts.find((p) => p.type === "year")!.value; + const m = parts.find((p) => p.type === "month")!.value; + const d = parts.find((p) => p.type === "day")!.value; + const todayKey = `${y}-${m}-${d}`; + const stubRow = { + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Weight is a directional metric…", + timeout: true, + }), + }; + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(stubRow as never); + const providerCall = vi.fn(); + vi.mocked(resolveProvider).mockResolvedValue({ + type: "anthropic", + generateCompletion: providerCall, + } as never); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + }); + + expect(providerCall).not.toHaveBeenCalled(); + expect(result.text).toBe("Weight is a directional metric…"); + expect(result.cached).toBe(true); + expect(result.updatedAt).toBe(stubRow.createdAt.toISOString()); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + }); +}); + describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index c7f03e84c..a8dca717e 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -28,6 +28,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -542,12 +543,18 @@ export async function generateBloodPressureStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyBloodPressureStatusText(locale), - cached: true, - updatedAt: null, - }; + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyBloodPressureStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 0acebf85d..697e91798 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -13,6 +13,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -294,49 +295,15 @@ export async function generateBmiStatusForUser( // v1.4.37 — persist a sentinel row keyed to today so the next // mount short-circuits at the cache lookup above instead of // re-racing the same 20 s provider call on every cold visit. - // Without this, a single stall (provider hiccup, network blip, - // model warm-up) leaves the user staring at the loading state - // on every reload until the daily 02:20 pre-warm job runs. - // The body is the same no-key fallback the caller returned - // before, so the user-facing UI is identical; only the - // re-fire frequency drops to zero for the rest of the day. - // `meta.timeout` is set so the daily pg-boss pre-warm worker - // (the same job that originally seeded the cache) can - // recognise and overwrite the stub rather than respect it as - // a real assessment. - const stubText = getNoKeyBmiStatusText(locale); - let stubUpdatedAt: string | null = null; - try { - const stub = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - text: stubText, - providerType: provider.type, - model: "timeout-stub", - tokensUsed: null, - timeout: true, - }), - }, - select: { createdAt: true }, - }); - stubUpdatedAt = stub.createdAt.toISOString(); - } catch { - // The persist is best-effort — if the row write fails the - // caller still sees the deterministic fallback text and the - // next mount falls back to the (still expensive) race. Do - // not surface the error: the user does not care that the - // cache write missed, only that the page rendered. - } - return { - hasProvider: true, - text: stubText, - cached: true, - updatedAt: stubUpdatedAt, - }; + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyBmiStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index e48101dc7..0d4145520 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -18,6 +18,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -390,12 +391,18 @@ export async function generateGeneralStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyGeneralStatusText(locale), - cached: true, - updatedAt: null, - }; + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyGeneralStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index cc91e4fa6..bbf6e5096 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -18,6 +18,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; // 360 daily days + 24 monthly windows ≈ 1080 days of intake history. @@ -104,6 +105,8 @@ export async function generateMedicationComplianceStatusForUser( const parsed = JSON.parse(latestCache.details) as { dateKey?: string; summary?: string; + text?: string; + timeout?: boolean; medications?: MedicationSummaryItem[]; }; @@ -126,6 +129,25 @@ export async function generateMedicationComplianceStatusForUser( updatedAt: latestCache.createdAt.toISOString(), }; } + + // v1.4.41 — timeout-stub rows persist `text` + `timeout: true` + // instead of the standard `summary` + `medications` envelope. + // Recognise the stub so the second mount short-circuits at the + // cache lookup instead of re-racing the provider call. + if ( + parsed.dateKey === todayKey && + parsed.timeout === true && + typeof parsed.text === "string" && + parsed.text.trim().length > 0 + ) { + return { + hasProvider: true, + summary: parsed.text, + medications: [], + cached: true, + updatedAt: latestCache.createdAt.toISOString(), + }; + } } catch { // ignore invalid cache payload } @@ -332,12 +354,28 @@ export async function generateMedicationComplianceStatusForUser( ); if (raced.timedOut || raced.value === null) { + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + // This route returns a richer shape (`summary`, `medications`) + // than the standard `text` envelope, so the helper is called + // for its persist side-effect and the route maps `summary` ← + // `text` on the way out. + const stubReturn = await persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyMedicationComplianceStatusText(locale), + }); return { hasProvider: true, - summary: getNoKeyMedicationComplianceStatusText(locale), + summary: stubReturn.text, medications: [], cached: true, - updatedAt: null, + updatedAt: stubReturn.updatedAt, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index f8acfb72f..5c2cecdc4 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -20,6 +20,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -447,12 +448,18 @@ export async function generateMoodStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyMoodStatusText(locale), - cached: true, - updatedAt: null, - }; + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyMoodStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/persist-timeout-stub.ts b/src/lib/insights/persist-timeout-stub.ts new file mode 100644 index 000000000..cfbb4a3e9 --- /dev/null +++ b/src/lib/insights/persist-timeout-stub.ts @@ -0,0 +1,73 @@ +import { prisma } from "@/lib/db"; + +/** + * v1.4.37 / v1.4.41 — shared timeout-stub persist for InsightStatus routes. + * + * When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS` + * the caller persists a sentinel `auditLog` row keyed to today's Berlin + * day so the next mount short-circuits at the cache lookup instead of + * re-racing the same 20 s provider call on every cold visit. Without + * this, a single stall (provider hiccup, network blip, model warm-up) + * leaves the user staring at the loading state on every reload until + * the daily 02:20 pre-warm job runs. + * + * The persisted body is the deterministic no-key fallback the caller + * would have returned in the bare-fallback shape, so the user-facing + * UI is identical; only the re-fire frequency drops to zero for the + * rest of the day. + * + * `meta.timeout` + `meta.model = "timeout-stub"` are set so the daily + * pg-boss pre-warm worker (the same job that originally seeded the + * cache) can recognise and overwrite the stub rather than respect it + * as a real assessment. + * + * The write is best-effort — if it fails the caller still sees the + * deterministic fallback text and the next mount falls back to the + * (still expensive) race. We do not surface the error: the user does + * not care that the cache write missed, only that the page rendered. + */ +export async function persistTimeoutStubAndReturn(input: { + userId: string; + cacheAction: string; + todayKey: string; + locale: string; + providerType: string; + stubText: string; +}): Promise<{ + hasProvider: true; + text: string; + cached: true; + updatedAt: string | null; +}> { + const { userId, cacheAction, todayKey, locale, providerType, stubText } = + input; + let stubUpdatedAt: string | null = null; + try { + const stub = await prisma.auditLog.create({ + data: { + userId, + action: cacheAction, + details: JSON.stringify({ + dateKey: todayKey, + locale, + text: stubText, + providerType, + model: "timeout-stub", + tokensUsed: null, + timeout: true, + }), + }, + select: { createdAt: true }, + }); + stubUpdatedAt = stub.createdAt.toISOString(); + } catch { + // The persist is best-effort — silently swallow so the user-facing + // payload still renders even when the row write missed. + } + return { + hasProvider: true, + text: stubText, + cached: true, + updatedAt: stubUpdatedAt, + }; +} diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 5dcb595a9..a84d190cc 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -19,6 +19,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -315,8 +316,10 @@ export async function generatePulseStatusForUser( // (or upstream failure) return the no-key fallback text in a // cached-style envelope so the status card renders deterministically // instead of spinning behind React-Query's default retry ladder. - // The fallback is NOT persisted to the audit cache — a transient - // upstream stall would otherwise poison tomorrow's hit. + // v1.4.41 — the timeout branch now persists a sentinel keyed to + // today (see `persistTimeoutStubAndReturn`) so subsequent mounts + // short-circuit at the cache lookup instead of re-racing the + // upstream call. The daily pre-warm job overwrites the stub. const raced = await withTimeout( () => provider.generateCompletion({ @@ -335,12 +338,18 @@ export async function generatePulseStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyPulseStatusText(locale), - cached: true, - updatedAt: null, - }; + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyPulseStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 4f0a72adf..d1e474c63 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -23,6 +23,7 @@ import { withTimeout, STATUS_PROVIDER_TIMEOUT_MS, } from "@/lib/insights/with-timeout"; +import { persistTimeoutStubAndReturn } from "@/lib/insights/persist-timeout-stub"; import { annotate } from "@/lib/logging/context"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -440,12 +441,18 @@ export async function generateWeightStatusForUser( ); if (raced.timedOut || raced.value === null) { - return { - hasProvider: true, - text: getNoKeyWeightStatusText(locale), - cached: true, - updatedAt: null, - }; + // v1.4.37 — persist a sentinel row keyed to today so the next + // mount short-circuits at the cache lookup above instead of + // re-racing the same 20 s provider call on every cold visit. + // See `persistTimeoutStubAndReturn` for the full rationale. + return persistTimeoutStubAndReturn({ + userId, + cacheAction, + todayKey, + locale, + providerType: provider.type, + stubText: getNoKeyWeightStatusText(locale), + }); } const result = raced.value; diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index 5c1b8cc07..c36476bf8 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,7 +1,7 @@ import { prisma } from "@/lib/db"; import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; -export const MEDICATION_CATEGORIES = MEDICATION_CATEGORY_VALUES; +const MEDICATION_CATEGORIES = MEDICATION_CATEGORY_VALUES; export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number]; diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts new file mode 100644 index 000000000..ee0dc045b --- /dev/null +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -0,0 +1,145 @@ +/** + * Shared today-window intake projector for the two routes that both + * need to backfill `MedicationIntakeEvent` rows for daily schedules + * before reading them: + * + * - `/api/medications/intake?scope=today` (web + iOS Erfassen sheet) + * - `/api/dashboard/summary` (iOS Dashboard tile) + * + * Pre-v1.4.41 both routes carried their own copy of the projection + + * idempotent `createMany` + per-`(med, day)` compliance recompute. The + * two implementations grew apart twice; this helper folds them into + * one tested location so future changes (new IntakeSource literal, + * new schedule cadence, audit metadata) only have to land once. + * + * The helper is intentionally side-effect-only: + * 1. Projects the active schedules through `expandTodayIntakes`. + * 2. Reads existing rows in the today-window. + * 3. Inserts the missing rows (`skipDuplicates: true` for the + * `(userId, medicationId, scheduledFor, source)` unique index). + * 4. Fires one compliance-rollup recompute per distinct + * `(medicationId, dayKey)` so the rollup matches the new + * `scheduled` count before the next read. + * + * The caller owns its own typed re-read of `MedicationIntakeEvent` + * afterwards — the two routes return different projections of those + * rows, so the helper stays read-shape-neutral. + * + * Returns the counts so the caller can surface them on `annotate(...)`. + */ +import { prisma } from "@/lib/db"; +import { expandTodayIntakes } from "@/lib/medication-schedule"; +import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; + +export interface ProjectTodayIntakesResult { + projected: number; + backfilled: number; +} + +export async function projectTodayIntakesAndRecompute(input: { + userId: string; + userTz: string; + todayStart: Date; + todayEnd: Date; +}): Promise { + const { userId, userTz, todayStart, todayEnd } = input; + + const activeMedications = await prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + schedules: { + select: { + id: true, + medicationId: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + }, + }, + }, + }); + + const projected = expandTodayIntakes( + activeMedications.flatMap((m) => m.schedules), + new Date(), + userTz, + ); + + if (projected.length === 0) { + return { projected: 0, backfilled: 0 }; + } + + const existing = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { medicationId: true, scheduledFor: true }, + }); + const existingKey = new Set( + existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), + ); + const missing = projected.filter( + (p) => !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), + ); + + if (missing.length === 0) { + return { projected: projected.length, backfilled: 0 }; + } + + await prisma.medicationIntakeEvent.createMany({ + data: missing.map((m) => ({ + userId, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + takenAt: null, + skipped: false, + // `REMINDER` is the same source the reminder worker uses to mint + // RED-phase rows — semantically "the server projected this slot + // before the user logged anything". The `IntakeSource` enum has + // no separate `SCHEDULER` literal; reusing REMINDER keeps the + // doctor-report + analytics filters byte-stable. + source: "REMINDER", + })), + // Paired with the schema-level @@unique([userId, medicationId, + // scheduledFor, source]). A concurrent dashboard-summary + intake + // route hit can race a duplicate row in between the existence + // probe and createMany; the flag tells Postgres to swallow the + // rejected rows so the request still returns 2xx. The unique + // constraint is the structural backstop; this is the + // defense-in-depth. + skipDuplicates: true, + }); + + // Close the v1.4.39.4 compliance-rollup hook gap: a bulk projection + // mints fresh `(medicationId, scheduledFor)` rows in PENDING state; + // without this hook the rollup row stays at its previous + // `scheduled` count, which inflates compliance % until the user + // actually logs against the new row. Coalesce by `(medicationId, + // dayKey)` so we fire one recompute per distinct day-tuple instead + // of one per row. + // + // `Promise.allSettled` keeps the best-effort contract explicit at + // the call site: the helper swallows internally today, but a + // future refactor that lets a throw escape would otherwise turn + // the parent POST into a 5xx. + const seenDayKeys = new Set(); + const recomputeJobs: Array> = []; + for (const m of missing) { + const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; + if (seenDayKeys.has(key)) continue; + seenDayKeys.add(key); + recomputeJobs.push( + recomputeMedicationComplianceForEvent({ + userId, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + tz: userTz, + }), + ); + } + await Promise.allSettled(recomputeJobs); + + return { projected: projected.length, backfilled: missing.length }; +} diff --git a/src/lib/medications/side-effects/validators.ts b/src/lib/medications/side-effects/validators.ts index b7188440f..279ce0393 100644 --- a/src/lib/medications/side-effects/validators.ts +++ b/src/lib/medications/side-effects/validators.ts @@ -40,7 +40,7 @@ export const SIDE_EFFECT_ENTRY_VALUES = Object.values( MedicationSideEffectEntry, ) as readonly MedicationSideEffectEntry[]; -export const SIDE_EFFECT_NOTES_MAX = 280; +const SIDE_EFFECT_NOTES_MAX = 280; /** * v1.4.25 W21 Fix-N (code-M6) — `category` was dropped from the wire diff --git a/src/lib/medications/window-status.ts b/src/lib/medications/window-status.ts index d36753b98..c7c1d1139 100644 --- a/src/lib/medications/window-status.ts +++ b/src/lib/medications/window-status.ts @@ -124,7 +124,7 @@ function isLastIntakeInCurrentWindow( * to suppress "overdue" pills once the user has already covered every * passed schedule with intake events for the day. */ -export function countPassedSchedules( +function countPassedSchedules( schedules: Schedule[], nowBerlin: Date, ): number { diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index cd24470f7..6343ce618 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -26,7 +26,7 @@ import { createDocument, type ZodOpenApiObject } from "zod-openapi"; import { openApiPaths, openApiComponents } from "./routes"; -export const openApiBase: Pick< +const openApiBase: Pick< ZodOpenApiObject, "openapi" | "info" | "servers" | "tags" > = { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index df8155542..74f62508a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -93,9 +93,56 @@ export const queryKeys = { passkeys: () => ["passkeys"] as const, notificationsPreferences: () => ["notifications", "preferences"] as const, + notificationsStatus: () => ["notifications", "status"] as const, settingsGlobalServices: () => ["settings", "global-services"] as const, settingsNtfy: () => ["settings", "ntfy"] as const, + settingsReminderThresholds: () => + ["settings", "reminder-thresholds"] as const, + + /** + * v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain, + * insights settings, user provider preference) and the targets editor + * all read these endpoints; centralising the keys keeps invalidation + * symmetrical with the user-thresholds + auth surfaces. + */ + insightsSettings: () => ["insights", "settings"] as const, + insightsProviderChain: () => ["insights", "provider-chain"] as const, + insightsGlp1Timeline: (limit: number | string) => + ["insights", "glp1-timeline", limit] as const, + userAiProvider: () => ["user", "ai-provider"] as const, + userProfile: () => ["user", "profile"] as const, + + apiVersion: () => ["api", "version"] as const, + publicVersion: () => ["public", "version"] as const, + researchMode: () => ["research-mode"] as const, + moodlogStatus: () => ["moodlog-status"] as const, + integrationsStatus: () => ["integrations", "status"] as const, + featureFlags: () => ["feature-flags"] as const, + coachPrefs: () => ["coach-prefs"] as const, + + /** + * v1.4.41 — admin surfaces. Pre-fix every admin section declared its + * own bare-literal `["admin", ""]`. Routing through the factory + * lets a single rename change every consumer in lockstep. + */ + adminAiQuality: () => ["admin", "ai-quality"] as const, + adminAppLogs: ( + traceId: string | undefined, + action: string | undefined, + level: string | undefined, + range: string | undefined, + ) => + ["admin", "app-logs", traceId, action, level, range] as const, + adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, + adminBackups: () => ["admin", "backups"] as const, + adminCoachFeedback: () => ["admin", "coach-feedback"] as const, + adminFeedback: (status: string) => ["admin", "feedback", status] as const, + adminFeedbackRoot: () => ["admin", "feedback"] as const, + adminHostMetrics: (window: string) => + ["admin", "host-metrics", window] as const, + adminAuditActions: () => ["admin", "audit-log", "actions"] as const, + adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const, tokens: () => ["tokens"] as const, telegram: () => ["telegram"] as const, diff --git a/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts b/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts deleted file mode 100644 index 9beb554ff..000000000 --- a/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers. - * - * `prisma.measurementRollup.findMany` is mocked so the tests pin the - * exact `where` shape the consumer sends (cumulative type set, - * granularity, since-bound) and the legacy-NULL fallback semantics of - * `resolveBucketSum`. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - findMany: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - measurementRollup: { - findMany: mocks.findMany, - }, - }, -})); - -import { prisma } from "@/lib/db"; -import { - isCumulativeType, - readCumulativeDaySums, - resolveBucketSum, -} from "../measurement-read-cumulative"; - -beforeEach(() => { - mocks.findMany.mockReset(); -}); - -describe("isCumulativeType", () => { - it("recognises the five cumulative HK types", () => { - expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true); - expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true); - expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true); - expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true); - expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true); - }); - - it("rejects spot metrics", () => { - expect(isCumulativeType("WEIGHT")).toBe(false); - expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false); - expect(isCumulativeType("PULSE")).toBe(false); - }); -}); - -describe("readCumulativeDaySums", () => { - it("queries the rollup table with the cumulative type + DAY granularity", async () => { - const since = new Date("2026-04-15T00:00:00.000Z"); - const rows = [ - { - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: 8120, - count: 4, - mean: 2030, - }, - { - bucketStart: new Date("2026-04-16T00:00:00.000Z"), - sumValue: 12480, - count: 5, - mean: 2496, - }, - ]; - mocks.findMany.mockResolvedValueOnce(rows); - - const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since); - - expect(out).toHaveLength(2); - expect(out[0]).toMatchObject({ - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: 8120, - count: 4, - mean: 2030, - }); - // Single round-trip — eliminates the legacy per-type chunked - // findMany loop in analytics A2. - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); - const args = mocks.findMany.mock.calls[0][0]; - expect(args.where).toMatchObject({ - userId: "user-1", - type: "ACTIVITY_STEPS", - granularity: "DAY", - bucketStart: { gte: since }, - }); - expect(args.orderBy).toEqual({ bucketStart: "asc" }); - }); - - it("propagates legacy NULL sum_value rows so the caller can fall back", async () => { - mocks.findMany.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: null, - count: 3, - mean: 1500, - }, - ]); - - const [row] = await readCumulativeDaySums( - "user-1", - "ACTIVITY_STEPS", - new Date("2026-04-15T00:00:00.000Z"), - ); - expect(row.sumValue).toBeNull(); - // Algebraic fallback parity: pre-v1.4.39 buckets still surface - // a usable daily total during the boot-backfill convergence - // window. - expect(resolveBucketSum(row)).toBe(4500); - }); -}); - -describe("resolveBucketSum", () => { - it("returns sumValue when populated", () => { - expect( - resolveBucketSum({ - bucketStart: new Date(), - sumValue: 7500, - count: 3, - mean: 2500, - }), - ).toBe(7500); - }); - - it("falls back to mean * count when sumValue is null (legacy row)", () => { - expect( - resolveBucketSum({ - bucketStart: new Date(), - sumValue: null, - count: 3, - mean: 2500, - }), - ).toBe(7500); - }); -}); diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index ade95242d..04531c6fb 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -37,9 +37,6 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, readBestGranularityRollups, - readMonthRollups, - readWeekRollups, - readYearRollups, type RollupBucketRow, } from "../measurement-read-wmy"; @@ -71,86 +68,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("readWeekRollups", () => { - it("returns the WEEK buckets for (userId, type, since)", async () => { - const rows = [ - bucket("2026-04-27T00:00:00.000Z"), - bucket("2026-05-04T00:00:00.000Z", { mean: 81 }), - ]; - findMany.mockResolvedValueOnce(rows); - const since = new Date("2026-04-20T00:00:00.000Z"); - - const result = await readWeekRollups("user-1", "WEIGHT", since); - - expect(result).not.toBeNull(); - expect(result).toHaveLength(2); - expect(result?.[0].mean).toBe(82); - expect(result?.[1].mean).toBe(81); - const args = findMany.mock.calls[0][0]; - expect(args.where.granularity).toBe("WEEK"); - expect(args.where.userId).toBe("user-1"); - expect(args.where.type).toBe("WEIGHT"); - expect(args.where.bucketStart.gte).toBe(since); - expect(args.orderBy).toEqual({ bucketStart: "asc" }); - }); - - it("returns null when the WEEK window has no coverage", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readWeekRollups( - "user-1", - "WEIGHT", - new Date("2026-04-20T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - -describe("readMonthRollups", () => { - it("returns the MONTH buckets and pins the granularity filter", async () => { - findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]); - const result = await readMonthRollups( - "user-2", - "PULSE", - new Date("2026-01-01T00:00:00.000Z"), - ); - expect(result).toHaveLength(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - }); - - it("returns null on coverage miss", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readMonthRollups( - "user-2", - "PULSE", - new Date("2026-01-01T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - -describe("readYearRollups", () => { - it("returns the YEAR buckets and pins the granularity filter", async () => { - findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]); - const result = await readYearRollups( - "user-3", - "WEIGHT", - new Date("2023-01-01T00:00:00.000Z"), - ); - expect(result).toHaveLength(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); - }); - - it("returns null on coverage miss", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readYearRollups( - "user-3", - "WEIGHT", - new Date("2023-01-01T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - describe("readBestGranularityRollups", () => { it("returns null on a non-positive window", async () => { expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull(); diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index f5d056e85..7a5891898 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -543,30 +543,6 @@ describe("enqueueBootTimeRollupBackfill", () => { expect(bossSend).not.toHaveBeenCalled(); }); - // v1.4.39 W-SUM — the discovery query now unions in users whose - // existing DAY rollup rows carry `sum_value IS NULL`. Re-folding - // converges those rows because `persistRollupRows` always writes the - // new column on upsert; the union keeps the per-day missing-coverage - // gap (v1.4.39.1) and the legacy-NULL backfill on the same indexed - // pass. - it("includes the sum_value IS NULL branch in the discovery query", async () => { - getGlobalBossMock.mockReturnValue({ send: bossSend }); - queryRaw.mockResolvedValueOnce([{ id: "user-with-legacy-rollups" }]); - bossSend.mockResolvedValueOnce("job-id"); - - const result = await enqueueBootTimeRollupBackfill(); - - expect(result).toEqual({ enqueued: 1, skipped: 0, error: null }); - // The discovery SQL surfaces both per-day missing coverage AND - // legacy NULL sum_value rows under one UNION. The text-anchor is - // the only durable assertion at the unit level — the integration - // suite covers the planner shape on real Postgres. - const sqlParts = queryRaw.mock.calls[0][0] as TemplateStringsArray; - const sqlText = Array.isArray(sqlParts) ? sqlParts.join("?") : ""; - expect(sqlText).toContain("sum_value"); - expect(sqlText).toContain("UNION"); - }); - // v1.4.39.1 — the discovery anchor moved from per-type to per-day so // accounts whose Withings sync / `/api/import` / admin restore wrote // measurements without firing the rollup write hook get re-enqueued diff --git a/src/lib/rollups/measurement-read-cumulative.ts b/src/lib/rollups/measurement-read-cumulative.ts deleted file mode 100644 index 6ad3d53e0..000000000 --- a/src/lib/rollups/measurement-read-cumulative.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * v1.4.39 W-SUM — read helpers for cumulative-metric daily sums. - * - * The v1.4.36 read path consumes the rollup table for spot-metric means - * via `rollup-read.ts`. Cumulative HK types (steps, flights, distance, - * daylight, active-energy) need the per-day SUM rather than the MEAN — - * every row is a partial-day slice from HealthKit, so MEAN understates - * the daily total by the per-bucket sample count. - * - * The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to - * `measurement_rollups`. The writer in `rollups.ts` populates it on - * every fold (including for non-cumulative types — the cost is one - * extra `SUM` in the existing aggregator). The legacy-NULL backfill - * runs through `enqueueBootTimeRollupBackfill` so existing rows - * converge on the next worker boot. - * - * Callers fall back to `mean * count` when `sumValue` is null. The - * shortcut is mathematically exact for a single source per day: - * - * sum = mean × count = (Σvalue / count) × count = Σvalue - * - * Multiple sources for the same `(user, type, day)` mix into the same - * bucket today — the source-priority resolution that - * `pickCanonicalSourceRows` runs in JS happens AFTER the rollup - * aggregate is built. The W8c per-source rollup deferred to v1.5 fixes - * that; until then, the cumulative read paths trust the bucket sum as - * the canonical daily total and the iOS drain (which collapses per - * source via `dailyStatsExternalId`) keeps Marc's tenant on the - * single-source-per-day path. - */ -import { prisma } from "@/lib/db"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; - -/** - * Cumulative measurement types the rollup `sum_value` column serves. - * Re-exported from the canonical Apple Health mapping so the - * consumer-side gate stays in one place. - * - * Audit anchor: `.planning/round-v1438-perf-analysis.md` §3 - * "Cumulative daily sums" + §5 P3. - */ -export type CumulativeType = - | "ACTIVITY_STEPS" - | "ACTIVE_ENERGY_BURNED" - | "FLIGHTS_CLIMBED" - | "WALKING_RUNNING_DISTANCE" - | "TIME_IN_DAYLIGHT"; - -/** - * `true` when `type` is one of the cumulative HK types whose daily - * total the rollup `sum_value` column carries. - */ -export function isCumulativeType(type: MeasurementType): type is CumulativeType { - return CUMULATIVE_HK_TYPES.has(type); -} - -/** One DAY-bucket row read from `measurement_rollups`. */ -export interface CumulativeDaySumRow { - bucketStart: Date; - /** - * Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39 - * writer change AND the boot-time backfill has not yet converged on - * this user. Callers fall back to `mean * count` for the null case. - */ - sumValue: number | null; - count: number; - mean: number; -} - -/** - * Read every DAY bucket for `(userId, type)` since `since`. Returns - * rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with - * `sumValue = null`; the caller decides whether to fall back to - * `mean * count` or skip them. - * - * Bounded to the cumulative-type set so a misuse (passing a spot - * metric) is a type error rather than a silent SUM on the wrong - * column. The query is a plain `findMany` against the - * `(userId, type, granularity, bucketStart)` composite primary key — - * single round-trip, fully indexed. - */ -export async function readCumulativeDaySums( - userId: string, - type: CumulativeType, - since: Date, -): Promise { - const rows = await prisma.measurementRollup.findMany({ - where: { - userId, - type: type as MeasurementType, - granularity: "DAY", - bucketStart: { gte: since }, - }, - orderBy: { bucketStart: "asc" }, - select: { - bucketStart: true, - sumValue: true, - count: true, - mean: true, - }, - }); - return rows.map((r) => ({ - bucketStart: r.bucketStart, - sumValue: r.sumValue, - count: r.count, - mean: r.mean, - })); -} - -/** - * Resolve a per-bucket cumulative total. Reads `sumValue` when the - * v1.4.39 writer has populated it; falls back to `mean * count` for - * legacy NULL rows so the chart never paints a hole during the boot- - * backfill convergence window. - */ -export function resolveBucketSum(row: CumulativeDaySumRow): number { - if (row.sumValue !== null) return row.sumValue; - return row.mean * row.count; -} diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 28b8f34f0..dc6a3fc72 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -117,43 +117,6 @@ const GRANULARITY_FLOORS: Array<{ { granularity: "DAY", minWindowDays: 0 }, ]; -/** - * Read the trailing WEEK buckets for `(userId, type)` since `since`. - * Returns `null` when no buckets exist in the window — the caller - * decides whether to fall back to a finer granularity or to live SQL. - */ -export async function readWeekRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "WEEK", since); -} - -/** - * Read the trailing MONTH buckets for `(userId, type)` since `since`. - * Returns `null` on coverage miss. - */ -export async function readMonthRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "MONTH", since); -} - -/** - * Read the trailing YEAR buckets for `(userId, type)` since `since`. - * Returns `null` on coverage miss. - */ -export async function readYearRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "YEAR", since); -} - /** * Pick the largest granularity that can still resolve the requested * `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for @@ -163,10 +126,9 @@ export async function readYearRollups( * series. * * Returns the granularity the helper resolved against plus the row - * shape `readWeekRollups` / `readMonthRollups` / `readYearRollups` - * return. `null` when no granularity yields any buckets in the - * window — the caller is expected to short-circuit to "no data" or - * to live SQL. + * shape the internal `readGranularity` reader produces. `null` when + * no granularity yields any buckets in the window — the caller is + * expected to short-circuit to "no data" or to live SQL. */ export async function readBestGranularityRollups( userId: string, diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 897789a30..a6de1b45f 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -752,35 +752,26 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ // index path. The LEFT JOIN walks `measurement_rollups` against // its composite primary key. // - // v1.4.39 W-SUM — the discovery also surfaces users whose existing - // DAY rollup rows carry `sum_value IS NULL`. Those rows pre-date - // the v1.4.39 writer change; re-folding the user converges them - // because `persistRollupRows` always writes the new column on - // upsert. The union runs in one query so the per-day coverage gap - // and the legacy-NULL backfill share a single planner pass. + // v1.4.41 — the v1.4.39 W-SUM legacy-NULL `sum_value` UNION arm + // has been retired. Marc's tenant converged at v1.4.40, so the + // arm was a permanent no-op seq scan over `measurement_rollups`. + // Per-day missing coverage remains the sole discovery anchor. const users = await prisma.$queryRaw>` - SELECT DISTINCT id FROM ( - SELECT DISTINCT mt."user_id" AS id - FROM ( - SELECT DISTINCT - m."user_id", - m."type", - date_trunc('day', m."measured_at") AS bucket_start - FROM measurements m - WHERE m."deleted_at" IS NULL - ) mt - LEFT JOIN measurement_rollups r - ON r."user_id" = mt."user_id" - AND r."type" = mt."type" - AND r."granularity" = 'DAY' - AND r."bucket_start" = mt."bucket_start" - WHERE r."bucket_start" IS NULL - UNION - SELECT DISTINCT r2."user_id" AS id - FROM measurement_rollups r2 - WHERE r2."granularity" = 'DAY' - AND r2."sum_value" IS NULL - ) discovery + SELECT DISTINCT mt."user_id" AS id + FROM ( + SELECT DISTINCT + m."user_id", + m."type", + date_trunc('day', m."measured_at") AS bucket_start + FROM measurements m + WHERE m."deleted_at" IS NULL + ) mt + LEFT JOIN measurement_rollups r + ON r."user_id" = mt."user_id" + AND r."type" = mt."type" + AND r."granularity" = 'DAY' + AND r."bucket_start" = mt."bucket_start" + WHERE r."bucket_start" IS NULL `; if (users.length === 0) { diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 6684c709d..d67d9c033 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -16,8 +16,6 @@ * * Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4. */ -import type { Prisma } from "@/generated/prisma/client"; - import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { annotate } from "@/lib/logging/context"; @@ -58,14 +56,6 @@ export interface ComplianceBucket { taken: number; } -/** - * Prisma client surface the helpers accept. Either the regular - * `prisma` singleton or a `$transaction(tx)` argument — both expose - * the `medicationIntakeEvent.findMany` + `medicationComplianceRollup.*` - * methods we touch. - */ -type PrismaLike = typeof prisma | Prisma.TransactionClient; - /** * UTC offset (in minutes) of `tz` at the given instant. Positive east * of UTC. Honours DST via `Intl.DateTimeFormat`. @@ -147,18 +137,14 @@ export function dayKeyForScheduledFor( * so the read path returns the trailing-window zero-default rather * than a stale stub. * - * `tx` accepts the `prisma.$transaction(tx)` client argument when the - * parent write is transactional — passing the transaction client keeps - * the rollup write inside the same transactional boundary. */ export async function recomputeMedicationComplianceForDay( userId: string, medicationId: string, dayKey: string, tz: string | null | undefined, - tx?: PrismaLike, ): Promise { - const client = (tx ?? prisma) as PrismaLike; + const client = prisma; const safeTz = safeTimezone(tz); const start = startOfDayUtcInTz(dayKey, safeTz); const end = new Date(start.getTime() + 86_400_000); @@ -247,7 +233,6 @@ export async function recomputeMedicationComplianceForEvent(input: { medicationId: string; scheduledFor: Date; tz: string | null | undefined; - tx?: PrismaLike; }): Promise { const dayKey = dayKeyForScheduledFor(input.scheduledFor, input.tz); try { @@ -256,7 +241,6 @@ export async function recomputeMedicationComplianceForEvent(input: { input.medicationId, dayKey, input.tz, - input.tx, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -459,40 +443,6 @@ export async function recomputeUserMedicationCompliance( return { rowsUpserted, durationMs: Date.now() - startedAt }; } -/** - * Fire-and-forget warm-up — kept around for symmetry with - * `ensureUserRollupsFresh` even though the on-write hook fully owns - * coverage in steady state. The route currently doesn't call this - * directly; the coverage-probe + boot-backfill cascade handles the - * cold-mount case. - */ -export async function ensureUserMedicationComplianceFresh( - userId: string, - days: number = MEDICATION_COMPLIANCE_BACKFILL_DAYS, - tz: string | null | undefined = null, -): Promise<{ recomputed: boolean }> { - try { - if (await hasMedicationComplianceCoverage(userId, days, tz)) { - return { recomputed: false }; - } - await recomputeUserMedicationCompliance(userId, days, tz); - return { recomputed: true }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - medication_compliance_rollup_refresh_failed: true, - medication_compliance_rollup_refresh_error: message, - }, - }); - console.error( - "[medication-compliance-rollups] ensure-fresh failed:", - message, - ); - return { recomputed: false }; - } -} - /** * v1.4.39 hotfix (QA F-SEC-M-01) — user-scoped enqueue helper for the * request-path coverage-miss fallback. diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index bb7df0069..e525e7f10 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -51,7 +51,7 @@ import type { const ASYNC_GRANULARITIES: RollupGranularity[] = ["WEEK", "MONTH", "YEAR"]; /** All granularities the rollup table tracks. */ -export const ALL_MOOD_GRANULARITIES: RollupGranularity[] = [ +const ALL_MOOD_GRANULARITIES: RollupGranularity[] = [ "DAY", "WEEK", "MONTH", @@ -166,17 +166,12 @@ export async function recomputeUserMoodRollups( * * Scoped to the affected `(userId, day)` tuple — cheap, and avoids * invalidating buckets that haven't changed. - * - * Pass `tx` when called from inside a `prisma.$transaction` block so - * the rollup write commits atomically with the parent mutation. - * Otherwise the global `prisma` client is used. */ export async function recomputeMoodBucketsForEntry( userId: string, moodLoggedAt: Date, - tx?: PrismaTxOrClient, ): Promise { - const client = tx ?? prisma; + const client = prisma; // DAY pass — synchronous. v1.4.39 hotfix (QA F-H-02): one atomic SQL // statement re-aggregates inside the upsert subquery, closing the // race window where two concurrent writes for the same (user, day) diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts index 3e9401777..156661410 100644 --- a/src/lib/telegram.ts +++ b/src/lib/telegram.ts @@ -115,25 +115,6 @@ export async function answerTelegramCallbackQuery( return json.ok; } -export async function editMessageReplyMarkup( - botToken: string, - chatId: string, - messageId: number, - replyMarkup?: TelegramReplyMarkup, -): Promise { - const json = await telegramApiRequest(botToken, "editMessageReplyMarkup", { - chat_id: chatId, - message_id: messageId, - reply_markup: replyMarkup ?? { inline_keyboard: [] }, - }); - if (!json.ok) { - getEvent()?.addWarning( - `[telegram] editMessageReplyMarkup failed: ${json.description}`, - ); - } - return json.ok; -} - export async function setTelegramWebhook( botToken: string, webhookUrl: string, diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 12b5f971f..61ef34f54 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -1,23 +1,149 @@ /** - * v1.4.28 R3d (BK-F-M1) — shared shape for the trimmed `/api/analytics` - * payload that every insights sub-page consumes. + * Shared analytics DTOs. * - * Pre-fix, each of the seven `/insights//page.tsx` modules - * declared its own `interface AnalyticsData { summaries: Record<...> }` - * inline. They were structurally identical so the TanStack-Query cache - * unified anyway, but maintaining seven copies meant every iOS-contract - * addition (e.g. a new `MeasurementType`) showed up only after the - * matching sub-page was touched. Hoisting the interface here is a - * single-place change. + * Pre-v1.4.41, three structurally-distinct `interface AnalyticsData {…}` + * declarations lived inline in `src/app/page.tsx` (dashboard), in + * `src/app/insights/page.tsx` (insights-mother), and in + * `src/components/onboarding/getting-started-checklist.tsx` (checklist). + * The names collided across files even though the shapes did not, which + * made "where does the analytics payload live?" un-discoverable. * - * NOTE: the dashboard's `AnalyticsData` (in `src/app/page.tsx`) is a - * wider type — it carries `bpInTargetPct*` aggregates the sub-pages - * don't use. The dashboard keeps its own declaration; we'd lose more - * than we gain by trying to merge them. + * v1.4.41 W-ORG (org-audit rec #2) hoists every shape into this module + * under a name that says which surface it describes. The header below + * documents the relationships so a future contract change has one + * place to land. + * + * - `SubPageAnalyticsData` — slim shape consumed by every + * `/insights//page.tsx`; just `summaries`. + * - `DashboardAnalyticsData` — dashboard root (`src/app/page.tsx`); + * carries the `bpInTargetPct*` aggregates the sub-pages don't use + * plus `glucoseByContext` and `lastSeenByType`. + * - `InsightsAnalyticsData` — insights mother page + * (`src/app/insights/page.tsx`); carries `correlations` + + * `healthScore` blocks that the sub-pages also don't use. + * - `ChecklistAnalyticsData` — onboarding checklist component; + * ultra-loose shape that only checks per-type `count` for + * "do you have any data of this kind yet?". + * + * NOTE: we keep four shapes (rather than collapsing into one with + * everything optional) because the call-sites use TypeScript control + * flow to *demand* the fields they need — the slim sub-pages should + * not be able to type-pass an access to `bpInTargetPct`, the checklist + * should not be able to read `correlations`, and the dashboard should + * not be reading `healthScore` without going through the insights + * surface. Three named shapes capture the contract; one swiss-army + * shape loses it. */ import type { DataSummary } from "@/lib/analytics/trends"; +import type { CorrelationResult } from "@/lib/insights/correlations"; +/** Shape consumed by every `/insights//page.tsx`. */ export interface SubPageAnalyticsData { /** `Record` — sentinel value for "not loaded yet" is `undefined`. */ summaries: Record; } + +/** + * Shape consumed by the dashboard root (`src/app/page.tsx`). + * + * `bpInTargetPct*` drive the BD-Zielbereich tile; `lastSeenByType` + * keeps stale-but-still-relevant tiles visible with an explicit + * "Letzter Wert vor …" caption. + */ +export interface DashboardAnalyticsData { + summaries: Record; + bpInTargetPct: number | null; + /** + * v1.4.18 A1 — share of paired BP readings inside target over the + * last 7 / 30 days. Drive the BD-Zielbereich tile's `7T:` / `30T:` + * sub-values; render "—" when the field is null (no paired readings + * in the window). + */ + bpInTargetPct7d?: number | null; + bpInTargetPct30d?: number | null; + /** + * v1.4.22 A1 — long-arc all-time aggregate. After the headline + * re-anchor to last-30-days the all-time number lives as a sub-value + * on the BD-Zielbereich tile (alongside `7d` and `30d`). + */ + bpInTargetPctAllTime?: number | null; + /** + * v1.4.22 W5 reconcile (Code-H2) — period-aligned prior-window + * pcts. The BD-Zielbereich tile's comparison-overlay caption picks + * `priorMonth` for `comparisonBaseline === "lastMonth"` and + * `priorYear` for `lastYear` so the rendered "Δ X% vs. last month" + * stays honest. Null when the prior window has no paired readings. + */ + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: number | null; + glucoseByContext?: Record; + /** + * v1.4.34 IW-B — per-type freshness map from `/api/analytics`. The + * tile-strip helper reads `lastSeenByType[type]?.daysAgo` and forwards + * it to each `` so a metric the user hasn't + * logged in a while keeps its tile visible (with an explicit + * "Letzter Wert vor …" caption) instead of disappearing. + */ + lastSeenByType?: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + >; +} + +/** + * Shape consumed by the insights mother page + * (`src/app/insights/page.tsx`). Carries the correlation + health-score + * blocks the sub-pages don't use. + */ +export interface InsightsAnalyticsData { + summaries: Record; + correlations?: { + bpCompliance: CorrelationResult; + moodPulse: CorrelationResult; + weightWeekday: CorrelationResult; + } | null; + healthScore?: { + score: number; + band: "green" | "yellow" | "red"; + components: { + // v1.4.25 W8e — the optional `source`/`asOf` slots feed the + // provenance accordion. Older clients reading this payload + // happily ignore the extras (additive contract). + bp: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + weight: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + mood: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + compliance: { + value: number | null; + weight: number; + source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; + asOf?: string; + }; + }; + delta: number | null; + } | null; +} + +/** + * Shape consumed by the onboarding checklist + * (`src/components/onboarding/getting-started-checklist.tsx`). + * Intentionally loose — the checklist only asks + * "does any per-type bucket have a non-zero count?". + */ +export interface ChecklistAnalyticsData { + summaries?: Record; +} diff --git a/src/types/backups.ts b/src/types/backups.ts new file mode 100644 index 000000000..75b0cc66c --- /dev/null +++ b/src/types/backups.ts @@ -0,0 +1,36 @@ +/** + * v1.4.41 W-ORG (org-audit rec #2) — DTOs for `/api/admin/backups`. + * + * Pre-v1.4.41 these interfaces lived inside the route handler at + * `src/app/api/admin/backups/route.ts` and were imported directly from + * the route module by `src/components/admin/backups-section.tsx`. That + * was a textbook layer violation (component → route handler) and the + * only one of its kind in the codebase. Hoisting the shapes here gives + * both sides a route-handler-independent shared type home; the route + * keeps owning the HTTP contract, the component keeps owning the UI. + */ + +export interface BackupRow { + id: string; + userId: string; + username: string; + type: string; + /** + * Size of the encrypted blob in bytes — useful for capacity planning. + * The blob itself is never returned. + */ + sizeBytes: number; + createdAt: string; +} + +export interface BackupsList { + rows: BackupRow[]; + /** + * Soft retention hint — the worker is configured for weekly backups + * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and + * the model upserts in-place per (userId, type), so each user has + * exactly one current snapshot. The frontend uses this to label the + * grid; no server-side enforcement. + */ + retentionDays: number; +}