chore(release): v1.4.41 — iOS perf hotfix, soft-delete completeness, tree hygiene

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.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 18:36:17 +02:00
1 parent 1114cf7e63
commit 67207d725e
96 files changed
+4582 -1187

No files matched your search

+11 -8
View File
@@ -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
@@ -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
@@ -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: <float>` 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: <Zod issue message> }`) 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.
+73
View File
@@ -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.
@@ -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.
@@ -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).
+166
View File
@@ -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<typeof X>` 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 <Suspense> 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={<ChartSkeleton />}`. 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`
@@ -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: `<DropdownMenu>`, `<ResponsiveSheet>`, `<MeasurementForm>`, `<MoodForm>`, `<MedicationIntakeQuickAdd>`, `<TourLauncher>`.
Plus per-tile `<Suspense fallback>` 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)
- `<Suspense fallback={<ChartSkeleton/>}>` per chart row + `<Suspense fallback={null}>` 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
└── <DashboardShell user, locale, layout, slim> (server)
├── <PageHeader t, user, greeting> (server — static)
├── <QuickAddTrigger> (client island)
│ ├── <DropdownMenu …>
│ └── <ResponsiveSheet …> × 3
├── <GettingStartedChecklist> (already self-gated client)
├── <TourLauncher ready> (client; ready computed from server hint)
├── <Suspense fallback={<TileStripSkeleton/>}>
│ └── <TileStripServer user, slim> (server — renders TrendCard per tile)
│ └── <TrendCard …/> (server — pure presentational, no hooks)
├── <Suspense fallback={<TileSkeleton/>}>
│ └── <BdZielbereichTile slim, thickPromise/> (streams thick via async server child)
├── <Suspense fallback={<TileSkeleton/>}>
│ └── <GlucoseTilesServer thickPromise/> (streams thick)
└── <ChartRowServer layout, user> (server — composes the chart row)
├── <Suspense fallback={<ChartSkeleton/>}>
│ └── <WeightChartClient> (client — Recharts; reads chart-data via TanStack)
├── <Suspense fallback={<ChartSkeleton/>}>
│ └── <BpChartClient> (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.
- **`<Suspense>`** boundaries (already shipped) wrap each data-dependent island. Each one streams independently.
- **Client islands**: `<QuickAddTrigger>` (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 `<DashboardClient user={user} />`.
- `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 `<Suspense>` + nested Server Component
- **Goal:** the BD-Zielbereich tile + glucose-tiles + the row's deeper-thick consumers move into an async server child wrapped in `<Suspense>`. 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 `<GettingStartedChecklist>` 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 `<TestServerWrapper>` 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:<id>: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 (`<HydrationBoundary state={dehydrate(qc)}>`). 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 `<AuthProvider initial={user}>` 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 `<Suspense>` 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 `<Suspense>` 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:<id>:analytics", "user:<id>:widgets"] })`
- [ ] `app/page.tsx` awaits both, passes as props
- [ ] `dashboard-client.tsx` drops the slim `useAnalyticsQuery({ slice: "summaries" })` + widgets `useQuery`
- [ ] `<HydrationBoundary>` 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
@@ -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 `<Suspense>` 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) |
@@ -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 `<Suspense>` 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.
@@ -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.
+299
View File
@@ -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<void>`
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<dayKey>`
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.
@@ -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 `<entity>-write` (so the convention is `<entity>-<role>`) or accept the asymmetry and add a one-line module comment to each writer explaining the read side lives in `app/api/<entity>/...`. 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.
@@ -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).
+321
View File
@@ -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
`<Suspense fallback={null}>` and `:1486` wraps every chart cell in
`<Suspense fallback={<ChartSkeleton />}>`. Each cell sits inside its
own `<div key={entry.id}>` 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 `<ChartSkeleton />`, 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
`<Suspense>`-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 `<main className="mx-auto max-w-3xl space-y-10 px-4 py-8
md:px-6 md:py-12">` 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
`<details>` 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 `<details>` 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 `<details>`"); 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/<host>`. 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<EventType, {lastDeliveredAt}>` 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.
+94
View File
@@ -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 `<Suspense>` 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 = <contents of M9WAFLNC2U.p8>
$ # 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).
@@ -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.
+30
View File
@@ -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.
+53
View File
@@ -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
+42
View File
@@ -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.
+71
View File
@@ -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.
@@ -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.<entry>()` 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.<entry>()` 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,
},
};
+16
View File
@@ -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;
+1 -1
View File
@@ -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",
@@ -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<typeof POST>[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();
});
});
@@ -47,15 +47,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
);
});
it("wraps each tile-strip cell in a `<Suspense>` boundary", () => {
it("wraps each tile-strip cell in a `<Suspense>` boundary with a layout-stable placeholder", () => {
const src = load(PAGE_PATH);
// `<Suspense fallback={null}>{entry.node}</Suspense>` — 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(
/<Suspense\s+fallback=\{null\}>\s*\{entry\.node\}\s*<\/Suspense>/,
/<Suspense\s+fallback=\{[\s\S]*?aria-hidden="true"[\s\S]*?\}\s*>\s*\{entry\.node\}\s*<\/Suspense>/,
);
});
+4 -25
View File
@@ -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" } });
+3 -1
View File
@@ -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 } }),
+114
View File
@@ -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 });
});
+13 -102
View File
@@ -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<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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: {
@@ -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({
@@ -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 }),
});
}
});
});
+4 -1
View File
@@ -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
+14 -2
View File
@@ -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 = {};
+4 -1
View File
@@ -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 =
@@ -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,
@@ -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"),
}));
+2 -2
View File
@@ -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 {
+10 -98
View File
@@ -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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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,
},
});
+4 -3
View File
@@ -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"));
+2 -1
View File
@@ -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"));
+4 -45
View File
@@ -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<string, DataSummary>;
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();
+9 -9
View File
@@ -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<PreferencesData>([
"notifications",
"preferences",
]);
const prev = queryClient.getQueryData<PreferencesData>(
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<PreferencesData>(
["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(),
});
},
});
+21 -43
View File
@@ -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<string, DataSummary>;
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<string, DataSummaryType>;
/**
* 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 `<TrendCard staleDays>` 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<string, DataSummaryType>
| Record<string, DataSummary>
| 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.
*/}
<Suspense fallback={null}>{entry.node}</Suspense>
{/*
* 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.
*/}
<Suspense
fallback={
<div
aria-hidden="true"
className="bg-card border-border h-full w-full rounded-xl border p-4 md:p-6"
/>
}
>
{entry.node}
</Suspense>
</div>
))}
</div>
+1 -1
View File
@@ -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";
/**
+1 -1
View File
@@ -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 {
@@ -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;
}
@@ -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<string, { count?: number } | undefined>;
}
interface WithingsStatus {
connected?: boolean;
}
+2 -1
View File
@@ -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");
+25
View File
@@ -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[] {
+1 -1
View File
@@ -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,
-14
View File
@@ -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;
}
+1 -1
View File
@@ -106,7 +106,7 @@ export interface CoachFeedbackAttribution extends FeedbackAttribution {
metricSourceType: string;
}
export function buildCoachMetricSourceType(
function buildCoachMetricSourceType(
tone: string,
verbosity: string,
): string {
@@ -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", () => {
@@ -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.
File renamed without changes.
File renamed without changes.
@@ -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<typeof vi.fn>;
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();
});
});
+2 -54
View File
@@ -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
+2 -2
View File
@@ -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 +
Binary file not shown.
+1 -1
View File
@@ -37,7 +37,7 @@ export class GithubPublishError extends Error {
* Resolves the configured GitHub token + repo from AppSettings (or env).
* Returns null if no configuration is present (caller should treat as 503).
*/
export async function getGithubConfig(): Promise<{
async function getGithubConfig(): Promise<{
token: string;
repo: string;
} | null> {
+1 -1
View File
@@ -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<Locale, string> = {
const INTL_LOCALE_MAP: Record<Locale, string> = {
de: "de-DE",
en: "en-US",
fr: "fr-FR",
+1 -3
View File
@@ -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
@@ -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({
@@ -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({
@@ -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();
@@ -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();
@@ -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");
});
});
@@ -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({
@@ -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);
+13 -6
View File
@@ -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;
+10 -43
View File
@@ -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;
+13 -6
View File
@@ -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;
@@ -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,
};
}
+13 -6
View File
@@ -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;
+73
View File
@@ -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,
};
}
+17 -8
View File
@@ -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;
+13 -6
View File
@@ -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;
+1 -1
View File
@@ -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];
@@ -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<ProjectTodayIntakesResult> {
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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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 };
}
@@ -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
+1 -1
View File
@@ -124,7 +124,7 @@ function isLastIntakeInCurrentWindow<Schedule extends ScheduleWindowInput>(
* to suppress "overdue" pills once the user has already covered every
* passed schedule with intake events for the day.
*/
export function countPassedSchedules<Schedule extends ScheduleWindowInput>(
function countPassedSchedules<Schedule extends ScheduleWindowInput>(
schedules: Schedule[],
nowBerlin: Date,
): number {
+1 -1
View File
@@ -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"
> = {
+47
View File
@@ -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", "<name>"]`. 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,
@@ -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);
});
});
@@ -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();
@@ -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
@@ -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<CumulativeDaySumRow[]> {
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;
}
+3 -41
View File
@@ -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<RollupBucketRow[] | null> {
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<RollupBucketRow[] | null> {
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<RollupBucketRow[] | null> {
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,
+19 -28
View File
@@ -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<Array<{ id: string }>>`
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) {
@@ -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<void> {
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<void> {
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.
+2 -7
View File
@@ -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<void> {
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)
-19
View File
@@ -115,25 +115,6 @@ export async function answerTelegramCallbackQuery(
return json.ok;
}
export async function editMessageReplyMarkup(
botToken: string,
chatId: string,
messageId: number,
replyMarkup?: TelegramReplyMarkup,
): Promise<boolean> {
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,
+139 -13
View File
@@ -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/<metric>/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/<metric>/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/<metric>/page.tsx`. */
export interface SubPageAnalyticsData {
/** `Record<MeasurementType, DataSummary>` — sentinel value for "not loaded yet" is `undefined`. */
summaries: Record<string, DataSummary>;
}
/**
* 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<string, DataSummary>;
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<string, DataSummary>;
/**
* 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 `<TrendCard staleDays>` 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<string, DataSummary>;
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<string, { count?: number } | undefined>;
}
+36
View File
@@ -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;
}