mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
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:
1 parent
1114cf7e63
commit
67207d725e
96 files changed
+4582
-1187
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface CoachFeedbackAttribution extends FeedbackAttribution {
|
||||
metricSourceType: string;
|
||||
}
|
||||
|
||||
export function buildCoachMetricSourceType(
|
||||
function buildCoachMetricSourceType(
|
||||
tone: string,
|
||||
verbosity: string,
|
||||
): string {
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user