From 8a56f482597b2fef8ce1ee1b84ecadd30b2342f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 17:14:01 +0200 Subject: [PATCH] refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per- metric system prompts + safety contracts) and `src/lib/insights/prompt *.ts` (user-prompt builder + comparison snapshot + the `compactSections` helper). A new reader could not tell from filenames which file to extend; both directories looked equally authoritative. Audit rec #3 flagged the split as the only undiscoverable seam in the AI layer. Move both files into `src/lib/ai/prompts/`: - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts` - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts` - co-located tests follow Three importers updated: `app/api/insights/generate/route.ts`, `lib/ai/coach/snapshot.ts`, the generate route test (the `vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have ONE home and ONE mental model. --- .github/workflows/knip.yml | 19 +- .planning/research/rsc-migration-plan-v1.5.md | 313 ++++++++++++++++++ eslint-plugins/healthlog/queryKey-factory.js | 127 +++++++ eslint.config.mjs | 12 + .../insights/generate/__tests__/route.test.ts | 2 +- src/app/api/insights/generate/route.ts | 4 +- src/lib/ai/coach/snapshot.ts | 2 +- .../__tests__/compact-sections.test.ts | 2 +- .../__tests__/prompt-comparison.test.ts | 2 +- .../__tests__/summaries-slice.test.ts | 138 +------- src/lib/analytics/summaries-slice.ts | 48 --- .../measurement-read-cumulative.test.ts | 136 -------- .../__tests__/measurement-read-wmy.test.ts | 83 ----- .../rollups/measurement-read-cumulative.ts | 120 ------- src/lib/rollups/measurement-read-wmy.ts | 44 +-- .../rollups/medication-compliance-rollups.ts | 34 -- 16 files changed, 473 insertions(+), 613 deletions(-) create mode 100644 .planning/research/rsc-migration-plan-v1.5.md create mode 100644 eslint-plugins/healthlog/queryKey-factory.js delete mode 100644 src/lib/rollups/__tests__/measurement-read-cumulative.test.ts delete mode 100644 src/lib/rollups/measurement-read-cumulative.ts diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index a36dfdec5..e04d8a904 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -29,12 +29,15 @@ jobs: - run: pnpm db:generate - # v1.4.40 W-INFRA — fail the build when knip surfaces a new dead - # module, an unused production dependency, an unused devDependency, - # or an unlisted binary. Unused exports + types are intentionally - # left informational while the existing surface is cleaned wave by - # wave (487 exports / 52 types on the v1.4.40 baseline). The - # baseline carve-outs live in `knip.json` under `ignore` + - # `ignoreDependencies` + `ignoreBinaries` with the rationale - # captured in `.planning/phase-W-INFRA-v1440-report.md`. + # v1.4.41 W-PROCESS-DOCS — exports + types tier staged for + # promotion to enforcing. The W-SIMPLIFIER and W-INSIGHTS-HOT + # cleanup waves have not yet landed on develop at the time this + # comment was written (48 unused exports + 52 unused exported + # types still in the tree), so the gate remains scoped to + # files,dependencies,binaries,unlisted. Once those cleanup waves + # merge and `pnpm knip --reporter compact` reports zero unused + # exports + zero unused types, drop the `--include` flag below + # to flip exports + types to enforcing. See + # `.planning/phase-W-PROCESS-DOCS-v1441-report.md` for the + # promotion checklist and the rollback path. - run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted diff --git a/.planning/research/rsc-migration-plan-v1.5.md b/.planning/research/rsc-migration-plan-v1.5.md new file mode 100644 index 000000000..e7a9ec1ed --- /dev/null +++ b/.planning/research/rsc-migration-plan-v1.5.md @@ -0,0 +1,313 @@ +# RSC Migration Plan — `app/page.tsx` (HealthLog v1.5) + +**Author:** W-RSC-RESEARCH (HealthLog v1.4.41 marathon) +**Status:** Research only — no source code changes +**Target release:** v1.5 minor (dedicated 2–3 day marathon, no parallel waves) +**Inputs:** +- `.planning/round-v1439-arch-qa-frontend.md` (frontend audit, Critical/High findings C1/C2/H2/H3/H4) +- `.planning/phase-W-RSC-v1440-report.md` (Suspense layer + queryKey factory shipped) +- `src/app/page.tsx` (1 495 lines, `"use client"` line 1) +- `src/app/layout.tsx` (already async RSC — locale + nonce resolve server-side) +- 30 sibling `"use client"` files under `src/app/` (samples: `insights/page.tsx`, `measurements/page.tsx`) + +--- + +## 1. Current state inventory + +### 1.1 Why `app/page.tsx` is `"use client"` today + +Hook surface that pins client-side rendering: + +| Hook / API | Site | Purpose | +| --- | --- | --- | +| `useAuth()` | line 208 | session + `user` profile (timezone, dateOfBirth, gender, heightCm, glucoseUnit) | +| `useTranslations()` / `useFormatters()` | 209–210 | i18n context (locale-aware number/date formatting) | +| `useState<…>` × 4 | 211–224 | quick-entry sheet state + 3 footer-portal DOM refs | +| `useAnalyticsQuery({ slice: "summaries" })` | 255 | **slim** analytics fetch (per-type DataSummary headlines) | +| `useAnalyticsQuery()` | 256 | **thick** analytics fetch (`bpInTargetPct*`, `glucoseByContext`) | +| `useMemo` (merge slim+thick) | 257–285 | `mergeSlimAndThickAnalytics(slim, thick)` | +| `useQuery(queryKeys.dashboardWidgets())` | 287 | layout (visibility + order of every widget) | +| `useQuery(queryKeys.moodAnalytics())` | 299 | mood summary + sparkline series | +| `useMemo` (hour-of-day) | 577–580 | greeting copy keyed on `user.timezone` | + +Plus interactive primitives: ``, ``, ``, ``, ``, ``. + +Plus per-tile `` boundaries (shipped v1.4.40 W-RSC) — already streaming-ready. + +### 1.2 Data fetched from the page + +- **Slim analytics** (`/api/analytics?slice=summaries`) — fast path; rollup-served, < 1 s warm. +- **Thick analytics** (`/api/analytics`) — full fan-out; carries `bpInTargetPct*` + `glucoseByContext`. +- **Dashboard widgets** (`/api/dashboard/widgets`) — layout config (per-user). +- **Mood analytics** (`/api/mood/analytics`) — mood summary + sparkline entries. + +Both `useAnalyticsQuery` calls share `caches.analytics` server-side; warm hits are free. + +### 1.3 Client-only patterns blocking RSC migration + +1. **TanStack Query everywhere** — `useQuery` × 4 on the page, plus every chart child re-fetches via `["chart-data", …]` / `["mood-chart-data"]`. RSC cannot host `useQuery`. +2. **`useState` for sheet open/close + footer portal refs** — interactive UI state. +3. **`useAuth()` is a client hook** (reads from a TanStack-Query-backed `["auth","me"]` slot). The session itself can be resolved server-side via the existing session helper that `app/layout.tsx` already uses for headers/cookies. +4. **`useTranslations()` + `useFormatters()` are client-side** — bound to a Providers tree-internal context. RSC i18n requires a parallel server-side `t()` reader. +5. **Inline IIFE in the JSX (`(() => { … })()` lines 737–1492)** — ~755 lines of tile/chart assembly; not a hook problem but every conditional reads from client-only `data` / `user` / `layout`. +6. **30 sibling `"use client"` pages** — same patterns at smaller scale. RSC migration of `page.tsx` is the lighthouse; siblings stay client unless promoted in later releases. + +### 1.4 Already in place (do not re-do) + +- `}>` per chart row + `` per tile-strip cell (v1.4.40 W-RSC). +- `queryKeys` factory routing for dashboard + chart hooks; `measurementDependentKeys` bundles `["chart-data"]` + `["dashboard-medication-compliance"]` prefixes (v1.4.40 W-RSC). +- `DASHBOARD_QUERY_OPTS` module-scope hoist; `getHourForTimeZone` memoised (v1.4.40 W-RSC audit-M2 + audit-H4). +- `RootLayout` is already async RSC — reads `headers()`/`cookies()` for locale + CSP nonce. + +--- + +## 2. Target architecture + +``` +app/page.tsx (Server Component, async) +├── resolveSession(cookies/headers) → user (RSC-safe helper) +├── cache() wrapper: +│ ├── getSlimAnalyticsForUser(userId) → /api/analytics slim equivalent +│ └── getDashboardWidgetsForUser(userId) → /api/dashboard/widgets equivalent +└── (server) + ├── (server — static) + ├── (client island) + │ ├── + │ └── × 3 + ├── (already self-gated client) + ├── (client; ready computed from server hint) + ├── }> + │ └── (server — renders TrendCard per tile) + │ └── (server — pure presentational, no hooks) + ├── }> + │ └── (streams thick via async server child) + ├── }> + │ └── (streams thick) + └── (server — composes the chart row) + ├── }> + │ └── (client — Recharts; reads chart-data via TanStack) + ├── }> + │ └── (client) + └── … (one Suspense + client island per chart) +``` + +Key contracts: + +- **Server Components** carry the static shell, the per-request slim+thick fetch, the TrendCard tiles (pure presentational), and the chart-row scaffolding. +- **``** boundaries (already shipped) wrap each data-dependent island. Each one streams independently. +- **Client islands**: `` (dropdown + sheets + forms), every chart (Recharts is client-only — no SSR), the tour launcher, the getting-started checklist. +- **`cache()`** wraps slim + widgets fetchers so a single React tree render only hits the DB once per request even if multiple server children read them. Use `unstable_noStore()` (or `export const dynamic = "force-dynamic"`) so per-request freshness wins over the route-segment cache. +- **TanStack Query stays** for client islands (chart-data, mood-chart-data, optimistic mutations, focus refetch). The dashboard page itself no longer hosts a `QueryClient` read of slim/thick analytics — those are server data passed in as props. + +--- + +## 3. Chunked migration steps + +Each step is shippable independently. Each step ends with a CI run + a manual deploy probe. + +### Step 1 — Carve out the static shell as a Server Component wrapper + +- **Goal:** `app/page.tsx` becomes a server component that renders the existing dashboard as a child client component. No data moves yet. +- **Touched files:** + - `src/app/page.tsx` (new — server) — `export const dynamic = "force-dynamic"`, resolves `user` via the same session helper `layout.tsx` uses, renders ``. + - `src/app/dashboard-client.tsx` (new — `"use client"`) — receives `user` as a prop, contains today's entire `DashboardPage()` body. +- **Risk:** low. Behaviour-identical refactor; `useAuth()` is replaced inside the client with `user` prop + a thin client-side cache hydrate (so client islands deeper in the tree still see `useAuth().user`). +- **Tests:** dashboard render snapshot, auth-prop hydration test, e2e dashboard.spec.ts. +- **Acceptance:** page renders byte-identical; `Application` tab shows no "use client" boundary at the route root. + +### Step 2 — Move slim analytics + widgets fetch to the Server Component + +- **Goal:** server fetches slim analytics + widgets, passes them as props; the client only fetches the thick slice. +- **Touched files:** + - `src/lib/analytics/server-fetchers.ts` (new) — `getSlimAnalytics(userId)` + `getDashboardWidgets(userId)`, both wrapped in React `cache()`. + - `src/app/page.tsx` — calls both fetchers, passes `slim` + `layout` as props. + - `src/app/dashboard-client.tsx` — replaces `useAnalyticsQuery({ slice: "summaries" })` + the `dashboardWidgets()` `useQuery` with prop reads. Keeps the thick `useAnalyticsQuery()` client-side. + - Optional: hydrate the TanStack cache with the server-fetched slim payload so client-side mutations + invalidations still reach the right slot (`hydrate({ queries: [...] })`). +- **Risk:** medium. The slim fetcher must mint the same `lastSeenByType` + `summaries` shape the route currently returns. Use the existing internal function (don't re-implement) — `/api/analytics?slice=summaries` calls into a service layer; lift that service layer into `server-fetchers.ts`. +- **Tests:** parity test (server fetch result == HTTP route result), updated dashboard render snapshot, mutation-invalidation test (after `POST /measurements` the dashboard refetches via TanStack invalidation OR the page revalidates via `revalidatePath("/")`). +- **Acceptance:** Network tab on cold mount shows one fewer client-side request; tile strip paints with the initial HTML. + +### Step 3 — Thick analytics streaming via `` + nested Server Component + +- **Goal:** the BD-Zielbereich tile + glucose-tiles + the row's deeper-thick consumers move into an async server child wrapped in ``. Initial HTML carries slim data + tile-strip skeleton for the thick-dependent tiles; the thick slice streams in as the second flush of the streaming response. +- **Touched files:** + - `src/components/dashboard/bp-in-target-tile-server.tsx` (new) — async RSC, awaits a `thickPromise` passed from the parent. + - `src/components/dashboard/glucose-tiles-server.tsx` (new) — same shape. + - `src/app/page.tsx` — kicks off `getThickAnalytics(userId)` (also `cache()`-wrapped) WITHOUT awaiting it; passes the promise down to the suspending children. +- **Risk:** high. Streaming requires the response to NOT be buffered by any reverse proxy. Coolify's Caddy-reverse-proxy default config buffers `text/html` responses by default — a prerequisite check is mandatory (§4.1). +- **Tests:** streaming probe (assert HTTP response body arrives in ≥ 2 flushes); per-tile suspense boundary unit test extended. +- **Acceptance:** BD-Zielbereich tile renders ~500 ms after the tile-strip in the network waterfall. + +### Step 4 — Each tile becomes its own Server Component island + +- **Goal:** the inline IIFE (lines 737–1492) is dissolved. Every tile is a self-contained server (or client, if interactive) component. The page assembles them via the layout-order array. +- **Touched files:** + - `src/components/dashboard/tiles/weight-tile.tsx` + - `src/components/dashboard/tiles/bp-sys-tile.tsx` + - `src/components/dashboard/tiles/bp-dia-tile.tsx` + - `src/components/dashboard/tiles/pulse-tile.tsx` + - `src/components/dashboard/tiles/body-fat-tile.tsx` + - `src/components/dashboard/tiles/mood-tile.tsx` + - `src/components/dashboard/tiles/sleep-tile.tsx` + - `src/components/dashboard/tiles/steps-tile.tsx` + - `src/components/dashboard/tiles/vo2-tile.tsx` + - `src/components/dashboard/tiles/glucose-tiles.tsx` + - `src/components/dashboard/tile-strip.tsx` (new — server, assembles tiles from layout order) +- **Risk:** medium. `getRangeColorClass` / `getRangeHint` / `tileCompareDelta` / `tileStaleDays` helpers move into per-tile files OR a shared server-safe helper module. Range / band derivation helpers (already pure) move to `src/lib/analytics/value-bands.ts` consumers cleanly. +- **Tests:** one render test per tile + the existing dashboard-suspense-boundaries.test.ts updated. +- **Acceptance:** React DevTools shows named components per tile (cf. audit L2). Initial HTML carries one tile's static frame per cell. + +### Step 5 — Trim client islands to interactive pieces only + +- **Goal:** every component that does NOT use a hook drops `"use client"`. Identify candidates: `TrendCard`, `TrendHint`, `EmptyState`, the page-header block, the welcome banner, the `` static frame (the dismiss button stays a client child). +- **Touched files:** ~15–20 component files, mostly removing the directive at line 1. +- **Risk:** low. Each removal is a one-line edit gated by typecheck + test. If a component transitively imports a client-only API (e.g. `next/dynamic`), it stays client. +- **Tests:** all existing tests stay green; bundle-analyzer probe confirms the dashboard route's client JS payload shrinks. +- **Acceptance:** bundle-analyzer report: route `/` client JS ≤ 60 % of pre-migration size (rough target — verify against actual numbers in step). + +--- + +## 4. Prerequisites + +### 4.1 Next.js streaming verification — Coolify proxy buffering + +- **Risk:** Caddy's `reverse_proxy` directive (Coolify's default) does NOT buffer by default for HTTP/1.1, BUT `flush_interval` defaults can collapse short flushes. Verify by deploying a one-route probe to staging and `curl --no-buffer -N https://staging/probe-stream` — assert chunks arrive ≥ 100 ms apart. +- **Mitigation:** if buffering is observed, set `flush_interval: -1` (immediate flush) on the dashboard route in the Caddy snippet. Document in `docs/infra/coolify-config.md`. +- **Owner:** infra wave; must complete BEFORE Step 3. + +### 4.2 Test fixture updates + +- **Vitest dashboard tests** today mount `DashboardPage` directly inside a `QueryClientProvider`. Step-1 split requires either: + - rendering `DashboardClient` directly (cleanest — tests already supply `user` via mocked `useAuth`), OR + - introducing a `` that simulates the RSC prop-injection. +- **Playwright e2e** (`tests/e2e/dashboard.spec.ts`) — no changes needed for Step 1; for Step 3 (streaming) extend with an `await page.waitForResponse` that asserts ≥ 2 chunks for the dashboard route. +- **Snapshot tests** — every dashboard snapshot regenerates once per step. Treat as expected churn. + +### 4.3 React Compiler compatibility + +- React Compiler is enabled in `next.config.js` (verify before Step 1). The compiler runs on client components; promoting components to server components removes them from the compiler's surface area (server components are not memoised by the compiler). No regression — but the bundle-analyzer comparison in Step 5 must compare apples-to-apples (post-compiler client bundle vs post-compiler client bundle). +- **Owner:** prerequisite verification in Step 1; if the compiler is NOT enabled the plan is unchanged. + +### 4.4 Session helper RSC-safety + +- `useAuth()` is client. The server-side equivalent is a `getCurrentUser()` helper that reads the JWT/session cookie via `cookies()`. Audit `src/lib/auth/` for an existing helper; if absent, factor one out of the API-route session middleware as the first sub-step of Step 1. + +### 4.5 i18n server-side reader + +- `useTranslations()` is a client hook. RSC pages need a server-side `t(locale, key)` reader that loads the same message bundle. The locale already resolves server-side in `layout.tsx` (`resolveInitialLocale`); reuse the same bundle loader. +- **Owner:** prerequisite for Step 1's static header / welcome banner. If the bundle loader is client-only today, factor an `import { getServerTranslations } from "@/lib/i18n/server"` out of the existing `Providers` tree. + +### 4.6 `cache()` + bounded TTL + +- React `cache()` dedupes within a single render. For request-level dedupe across multiple `await`s in the same render, that is exactly what's needed. +- For cross-request reuse (avoiding hitting the DB twice in two consecutive requests within ~1 minute), wrap the inner fetchers in `unstable_cache(fn, key, { revalidate: 60 })`. Verify the staleness window matches today's `DASHBOARD_QUERY_OPTS.staleTime: 60_000`. +- After a measurement / mood / medication-intake mutation, the relevant API route must call `revalidateTag("user::analytics")` (set the tag on the `unstable_cache` wrap). Otherwise the dashboard reads stale slim data for up to 60 s after a save. + +--- + +## 5. Risk register + +| ID | Risk | Severity | Mitigation | +| --- | --- | --- | --- | +| R1 | **Hydration mismatch on initial mount** — server-rendered tile values vs client-side TanStack-cache-derived values diverge on prop hydrate | **High** | Hydrate the TanStack QueryClient with the server payload at the boundary (``). One mount-time assertion per tile in tests. | +| R2 | **Performance regression** — server compute path heavier than the client cache-warm hit it replaces | High | Step-2 bench gate: compare TTFB + LCP against pre-migration baseline; reject if LCP degrades > 100 ms. Server slim fetch must hit the rollup tier (already `< 100 ms` warm). | +| R3 | **SSR data freshness** — `cache()` + `unstable_cache` TTL drifts from TanStack staleTime | Medium | Pin TTL = 60 s in both layers; mutations call `revalidateTag(...)` AND `queryClient.invalidateQueries()`. Single integration test asserts post-mutation freshness end-to-end. | +| R4 | **Test fragmentation** — every step rewrites a chunk of the dashboard tests | Medium | Treat test churn as expected per-step deliverable; CI green-gate is non-negotiable per step. Plan budgets 20 % per-step time for test updates. | +| R5 | **Coolify proxy buffers the streamed response** | High (blocks Step 3) | §4.1 prerequisite probe before Step 3. If buffering can't be defeated, ship Steps 1+2+4+5 only; Step 3 becomes a v1.6 sub-release. | +| R6 | **`useAuth()` removal breaks deeper client islands** that read `user.timezone` etc. | Medium | Keep `useAuth()` as a thin client-side hook that hydrates from a server-injected `` at the page boundary. Forms / charts continue to call `useAuth()` unchanged. | +| R7 | **30 sibling client pages** — touching `app/page.tsx` ripples through shared layouts | Low | Migration scope is `/` only. Sibling pages stay client; the `AuthShell` + `Providers` chain stays unchanged. | +| R8 | **React Compiler bundle skew** post-migration vs pre-migration | Low | Bundle-analyzer comparisons in Step 5 must compare same compiler-on config. | + +--- + +## 6. Recommended scope + +- **Release:** v1.5 minor (NOT a patch — the architectural shift is observable in production logs + Network tab). +- **Duration:** 2–3 day dedicated marathon. +- **In-flight work:** none. No parallel waves on the dashboard tree; cross-agent commit drift (cf. v1.4.40 W-RSC report and memory note `feedback_cross_agent_commit_drift`) would corrupt step boundaries. +- **PR cadence:** one PR per step (5 PRs total). Each merges to `develop`, deploys to staging, gets a 24-hour soak before the next step ships. +- **Rollback:** every step is reversible by reverting the merge commit. Steps 1+2 are pure refactors with prop-equivalent semantics. + +--- + +## 7. Out-of-scope (explicitly) + +1. **Replacing TanStack Query entirely.** It stays for client-island data (`["chart-data", …]`, `["mood-chart-data"]`, mutations, focus refetch). The migration ONLY hoists the page's own slim+thick reads to the server. +2. **Removing the slim/thick split.** Both slices stay — they're the streaming envelope. The server fetches slim eagerly and kicks off thick concurrently for the `` flush. +3. **Migrating sibling `"use client"` pages** (`/insights`, `/measurements`, `/medications`, `/mood`, `/targets`, `/achievements`, etc.). 30 client pages remain client; this plan covers `/` only. +4. **Replacing Recharts with a server-renderable alternative.** Charts stay client (`next/dynamic({ ssr: false })`). Server tiles + client charts coexist behind a single `` per row. +5. **Custom ESLint `no-restricted-syntax` rule for queryKey factory** — deferred from v1.4.40 W-RSC; not blocking this migration. +6. **Long-tail factory migration** (154 bare sites) — deferred from v1.4.40; not blocking. +7. **Service Worker / VersionPoller changes** — VersionPoller stays as-is. + +--- + +## 8. Per-step deliverables checklist (for the future executing agent) + +### Step 1 (Day 0.5) +- [ ] `getCurrentUser()` server helper exists and is unit-tested +- [ ] `getServerTranslations(locale)` exists and matches client `t()` output +- [ ] `src/app/page.tsx` is a server component (no `"use client"`) +- [ ] `src/app/dashboard-client.tsx` holds today's body, receives `user` + `locale` props +- [ ] All existing tests pass +- [ ] Network tab unchanged (still ~10 requests on cold mount) +- [ ] Commit: `refactor(dashboard): carve server-component shell around dashboard-client` + +### Step 2 (Day 1) +- [ ] `src/lib/analytics/server-fetchers.ts` exports `getSlimAnalytics(userId)` + `getDashboardWidgets(userId)`, both wrapped in `cache()` + `unstable_cache({ revalidate: 60, tags: ["user::analytics", "user::widgets"] })` +- [ ] `app/page.tsx` awaits both, passes as props +- [ ] `dashboard-client.tsx` drops the slim `useAnalyticsQuery({ slice: "summaries" })` + widgets `useQuery` +- [ ] `` hydrates the TanStack cache with the server payload +- [ ] Mutation API routes call `revalidateTag(...)` on the relevant tags +- [ ] Network tab on cold mount: one fewer client request +- [ ] Commit: `feat(dashboard): server-fetch slim analytics + widgets, hydrate client cache` + +### Step 3 (Day 1.5) +- [ ] Coolify streaming probe green (§4.1) +- [ ] `bp-in-target-tile-server.tsx` + `glucose-tiles-server.tsx` are async RSCs +- [ ] `app/page.tsx` passes a `thickPromise` (NOT awaited) to those children +- [ ] HTTP response arrives in ≥ 2 flushes (curl `--no-buffer` probe) +- [ ] Commit: `feat(dashboard): stream thick analytics through Suspense` + +### Step 4 (Day 2) +- [ ] 10 tile files under `src/components/dashboard/tiles/` +- [ ] `tile-strip.tsx` assembles them from the layout order +- [ ] Inline IIFE in `dashboard-client.tsx` reduced to JSX-only +- [ ] React DevTools shows named components per tile +- [ ] Commit: `refactor(dashboard): one server component per tile` + +### Step 5 (Day 2.5) +- [ ] Bundle-analyzer report: route `/` client JS ≤ 60 % of pre-migration size +- [ ] `"use client"` removed from all components that don't use hooks (target: ≥ 15 files) +- [ ] All existing tests pass +- [ ] Commit: `refactor: trim client islands to interactive components only` + +### Final +- [ ] CHANGELOG entry under v1.5 — "Dashboard rebuilt on React Server Components for instant initial paint" +- [ ] Marc-voice release-notes blurb (no AI mention, no PII) +- [ ] 24-hour production soak before flipping the v1.5 release tag + +--- + +## 9. Estimated v1.5 effort + +| Phase | Days | +| --- | --- | +| Step 0 (prereqs: server helpers, i18n reader, Coolify probe) | 0.5 | +| Steps 1 + 2 (server shell + slim fetch) | 1.0 | +| Step 3 (streaming thick) | 0.5 | +| Step 4 (per-tile servers) | 0.5 | +| Step 5 (client-island trim + bundle verify) | 0.5 | +| **Total** | **3.0 dedicated days** | + +Buffer: +0.5 day for test churn / Coolify proxy debug. Real envelope: **2.5–3.5 days**, single autonomous marathon. + +--- + +## 10. References + +- Frontend audit C1, C2, H2, H3, H4: `.planning/round-v1439-arch-qa-frontend.md` +- v1.4.40 W-RSC report (Suspense + factory shipped, RSC deferred): `.planning/phase-W-RSC-v1440-report.md` +- Current `app/page.tsx`: 1 495 lines, `"use client"` line 1 +- Current `app/layout.tsx`: async RSC, locale + nonce resolved server-side +- 30 sibling client pages under `src/app/` — out of scope diff --git a/eslint-plugins/healthlog/queryKey-factory.js b/eslint-plugins/healthlog/queryKey-factory.js new file mode 100644 index 000000000..d54e6f033 --- /dev/null +++ b/eslint-plugins/healthlog/queryKey-factory.js @@ -0,0 +1,127 @@ +/** + * @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", +]; + +// Individual guarded files outside the directories above. +const GUARDED_FILES = [ + "src/app/page.tsx", + "src/hooks/use-auth.ts", +]; + +function isGuarded(filename) { + // Factory home is never an offender even if it shows up under a + // guarded directory by accident. + for (const home of FACTORY_HOME_FILES) { + if (filename.endsWith(home)) return false; + } + for (const dir of GUARDED_DIRECTORIES) { + if (filename.includes(`/${dir}/`)) return true; + } + for (const file of GUARDED_FILES) { + if (filename.endsWith(file)) return true; + } + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +const queryKeyFactoryRule = { + meta: { + type: "problem", + docs: { + description: + "Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.()` from `src/lib/query-keys.ts` instead.", + recommended: false, + }, + messages: { + bareArray: + "Bare-array `{{prop}}: [ … ]` bypasses the queryKeys factory. Import from `@/lib/query-keys` and call `queryKeys.()` so cache-invalidation bundles stay in lockstep.", + }, + schema: [], + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isGuarded(filename)) { + return {}; + } + + function check(node) { + // `node` is a Property like `queryKey: [ … ]`. Only flag the + // bare ArrayExpression form — identifiers, call expressions, + // conditional expressions, etc. are all acceptable indirection + // through the factory. + if (!node.value || node.value.type !== "ArrayExpression") return; + + const keyName = + node.key.type === "Identifier" + ? node.key.name + : node.key.type === "Literal" + ? node.key.value + : null; + if (keyName !== "queryKey" && keyName !== "mutationKey") return; + + context.report({ + node, + messageId: "bareArray", + data: { prop: keyName }, + }); + } + + return { + Property: check, + }; + }, +}; + +module.exports = { + rules: { + "queryKey-factory": queryKeyFactoryRule, + }, +}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d1b..b08e653fd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; +import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js"; const eslintConfig = defineConfig([ ...nextVitals, @@ -13,6 +14,17 @@ const eslintConfig = defineConfig([ "build/**", "next-env.d.ts", ]), + // v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array + // `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the + // files the queryKeys factory currently guards. Promotes the + // test-guard substitute from v1.4.40 W-RSC to a real ESLint rule + // so IDE + CI both fail fast on a factory bypass. See + // `eslint-plugins/healthlog/queryKey-factory.js` for the + // whitelist + rationale. + { + plugins: { healthlog: healthlogPlugin }, + rules: { "healthlog/queryKey-factory": "error" }, + }, ]); export default eslintConfig; diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 439d3630d..bd1c7d9c3 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -91,7 +91,7 @@ vi.mock("@/lib/insights/features", async () => { }; }); -vi.mock("@/lib/insights/prompt", () => ({ +vi.mock("@/lib/ai/prompts/insight-system-prompt", () => ({ buildUserPrompt: vi.fn(() => "user"), })); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index eee951223..b20dc8d45 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -6,7 +6,7 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; -import { compactSections } from "@/lib/insights/prompt-compact"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { detectGlp1Plateau, buildGlp1PlateauPrompt, @@ -14,7 +14,7 @@ import { import { buildUserPrompt, type ComparisonSnapshot, -} from "@/lib/insights/prompt"; +} from "@/lib/ai/prompts/insight-system-prompt"; import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e681de881..605c45910 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -22,7 +22,7 @@ import { type CoachExcludeMetric, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { compactSections } from "@/lib/insights/prompt-compact"; +import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import type { CoachProvenance, diff --git a/src/lib/ai/prompts/__tests__/compact-sections.test.ts b/src/lib/ai/prompts/__tests__/compact-sections.test.ts index 4c4ca864b..4a8ca4ba6 100644 --- a/src/lib/ai/prompts/__tests__/compact-sections.test.ts +++ b/src/lib/ai/prompts/__tests__/compact-sections.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { compactSections } from "../prompt-compact"; +import { compactSections } from "../compact-sections"; describe("compactSections", () => { it("drops empty arrays", () => { diff --git a/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts b/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts index 7f445a7e7..da7f4086c 100644 --- a/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts +++ b/src/lib/ai/prompts/__tests__/prompt-comparison.test.ts @@ -3,7 +3,7 @@ import { buildUserPrompt, buildComparisonBlock, type ComparisonSnapshot, -} from "@/lib/insights/prompt"; +} from "@/lib/ai/prompts/insight-system-prompt"; /** * v1.4.16 phase B8 — comparison context block in the user prompt. diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 2f7c79039..be58ff501 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -27,10 +27,7 @@ vi.mock("@/lib/logging/context", () => ({ })); import { prisma } from "@/lib/db"; -import { - computeLongWindowSummary, - computeSummariesSlice, -} from "../summaries-slice"; +import { computeSummariesSlice } from "../summaries-slice"; const RAW = prisma.$queryRaw as unknown as ReturnType; const MEASUREMENT_FIND_FIRST = @@ -453,136 +450,3 @@ describe("computeSummariesSlice", () => { }); }); -/** - * v1.4.39 W-WMY — long-window summary helper. Pins: - * - the helper routes a 365-day window through MONTH buckets, - * - the linearly-composable aggregate (count / min / max / mean / - * sum) lands at the same numbers `aggregateWmyBuckets` produces - * directly, so the rollup math survives round-tripping through - * the summaries-slice entry point, - * - a coverage miss surfaces as a `null` return instead of a - * partial-shape leak. - */ -describe("computeLongWindowSummary", () => { - it("routes a 365-day window through MONTH buckets and composes the aggregate", async () => { - // The first findMany call is the per-tier probe inside - // `readBestGranularityRollups`. With windowDays=365 the router - // walks YEAR (skipped, floor=731) → MONTH (floor=181, matched). - // The MONTH call returns two buckets; the helper composes the - // window aggregate from them. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-01T00:00:00.000Z"), - count: 10, - mean: 82, - sd: 1, - slope: -0.01, - r2: 0.3, - sumValue: null, - minValue: 79, - maxValue: 84, - }, - { - bucketStart: new Date("2026-05-01T00:00:00.000Z"), - count: 20, - mean: 80, - sd: 1.5, - slope: 0.02, - r2: 0.4, - sumValue: null, - minValue: 77, - maxValue: 83, - }, - ]); - - const result = await computeLongWindowSummary("user-1", "WEIGHT", 365); - - expect(result).not.toBeNull(); - expect(result?.granularity).toBe("MONTH"); - expect(result?.bucketCount).toBe(2); - expect(result?.count).toBe(30); - expect(result?.min).toBe(77); - expect(result?.max).toBe(84); - // Σ(count × mean) / Σcount = (10×82 + 20×80) / 30 = 80.6666… - expect(result?.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5); - expect(result?.sum).toBeNull(); // no sumValue on these buckets - - // The router asked MONTH first (365 d clears the 181-day MONTH - // floor and falls short of the 731-day YEAR floor). - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(1); - expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH"); - }); - - it("routes a 1095-day window through YEAR with a fall-through to MONTH on miss", async () => { - // YEAR coverage miss → router falls back to MONTH. - ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // YEAR miss - .mockResolvedValueOnce([ - { - bucketStart: new Date("2024-06-01T00:00:00.000Z"), - count: 100, - mean: 81, - sd: 1.2, - slope: 0.005, - r2: 0.15, - sumValue: null, - minValue: 76, - maxValue: 86, - }, - ]); - - const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); - - expect(result?.granularity).toBe("MONTH"); - expect(result?.bucketCount).toBe(1); - expect(result?.count).toBe(100); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); - }); - - it("composes the cumulative sum when every bucket carries sumValue", async () => { - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-01T00:00:00.000Z"), - count: 28, - mean: 8_200, - sd: 1_000, - slope: 0, - r2: 0, - sumValue: 229_600, - minValue: 5_000, - maxValue: 12_000, - }, - { - bucketStart: new Date("2026-05-01T00:00:00.000Z"), - count: 30, - mean: 9_100, - sd: 1_100, - slope: 0, - r2: 0, - sumValue: 273_000, - minValue: 6_000, - maxValue: 12_500, - }, - ]); - - const result = await computeLongWindowSummary( - "user-1", - "ACTIVITY_STEPS", - 365, - ); - - expect(result?.sum).toBe(229_600 + 273_000); - }); - - it("returns null when every granularity misses", async () => { - ROLLUP_FIND_MANY.mockResolvedValue([]); - const result = await computeLongWindowSummary("user-1", "WEIGHT", 1095); - expect(result).toBeNull(); - }); - - it("returns null on a zero / negative / NaN window without hitting the DB", async () => { - expect(await computeLongWindowSummary("user-1", "WEIGHT", 0)).toBeNull(); - expect(await computeLongWindowSummary("user-1", "WEIGHT", -10)).toBeNull(); - expect(await computeLongWindowSummary("user-1", "WEIGHT", NaN)).toBeNull(); - expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 8a548c23b..961a4a194 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -656,54 +656,6 @@ async function computeFromLiveAggregate( * "rollup tier empty" cold-mount case, and v1.5's multi-year card * accepts a `null` "no data yet" state. */ -export async function computeLongWindowSummary( - userId: string, - type: MeasurementType, - windowDays: number, -): Promise<{ - granularity: RollupGranularity; - bucketStart: Date | null; - bucketEnd: Date | null; - bucketCount: number; - count: number; - min: number | null; - max: number | null; - mean: number | null; - sum: number | null; -} | null> { - const resolved = await readBestGranularityRollups(userId, type, windowDays); - if (!resolved) return null; - const rows: RollupBucketRow[] = resolved.rows; - const aggregate = aggregateWmyBuckets(rows); - // Annotate which granularity served the window so the operator can - // verify the routing in production wide-events without redeploying - // instrumentation. Mirrors the `slim_summaries.path` annotate - // shape inside `computeFromRollups` / `computeFromLiveAggregate`. - annotate({ - meta: { - analytics: { - long_window_summary: { - type, - window_days: windowDays, - granularity: resolved.granularity, - bucket_count: rows.length, - row_count: aggregate.count, - }, - }, - }, - }); - return { - granularity: resolved.granularity, - bucketStart: rows[0]?.bucketStart ?? null, - bucketEnd: rows[rows.length - 1]?.bucketStart ?? null, - bucketCount: rows.length, - count: aggregate.count, - min: aggregate.min, - max: aggregate.max, - mean: aggregate.mean, - sum: aggregate.sum, - }; -} /** * v1.4.40 W-WMY-WIRE — year-ago 30-day baseline per type, served from diff --git a/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts b/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts deleted file mode 100644 index 9beb554ff..000000000 --- a/src/lib/rollups/__tests__/measurement-read-cumulative.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * v1.4.39 W-SUM — unit tests for the cumulative-day-sum reader helpers. - * - * `prisma.measurementRollup.findMany` is mocked so the tests pin the - * exact `where` shape the consumer sends (cumulative type set, - * granularity, since-bound) and the legacy-NULL fallback semantics of - * `resolveBucketSum`. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - findMany: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - measurementRollup: { - findMany: mocks.findMany, - }, - }, -})); - -import { prisma } from "@/lib/db"; -import { - isCumulativeType, - readCumulativeDaySums, - resolveBucketSum, -} from "../measurement-read-cumulative"; - -beforeEach(() => { - mocks.findMany.mockReset(); -}); - -describe("isCumulativeType", () => { - it("recognises the five cumulative HK types", () => { - expect(isCumulativeType("ACTIVITY_STEPS")).toBe(true); - expect(isCumulativeType("ACTIVE_ENERGY_BURNED")).toBe(true); - expect(isCumulativeType("FLIGHTS_CLIMBED")).toBe(true); - expect(isCumulativeType("WALKING_RUNNING_DISTANCE")).toBe(true); - expect(isCumulativeType("TIME_IN_DAYLIGHT")).toBe(true); - }); - - it("rejects spot metrics", () => { - expect(isCumulativeType("WEIGHT")).toBe(false); - expect(isCumulativeType("BLOOD_PRESSURE_SYS")).toBe(false); - expect(isCumulativeType("PULSE")).toBe(false); - }); -}); - -describe("readCumulativeDaySums", () => { - it("queries the rollup table with the cumulative type + DAY granularity", async () => { - const since = new Date("2026-04-15T00:00:00.000Z"); - const rows = [ - { - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: 8120, - count: 4, - mean: 2030, - }, - { - bucketStart: new Date("2026-04-16T00:00:00.000Z"), - sumValue: 12480, - count: 5, - mean: 2496, - }, - ]; - mocks.findMany.mockResolvedValueOnce(rows); - - const out = await readCumulativeDaySums("user-1", "ACTIVITY_STEPS", since); - - expect(out).toHaveLength(2); - expect(out[0]).toMatchObject({ - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: 8120, - count: 4, - mean: 2030, - }); - // Single round-trip — eliminates the legacy per-type chunked - // findMany loop in analytics A2. - expect(prisma.measurementRollup.findMany).toHaveBeenCalledTimes(1); - const args = mocks.findMany.mock.calls[0][0]; - expect(args.where).toMatchObject({ - userId: "user-1", - type: "ACTIVITY_STEPS", - granularity: "DAY", - bucketStart: { gte: since }, - }); - expect(args.orderBy).toEqual({ bucketStart: "asc" }); - }); - - it("propagates legacy NULL sum_value rows so the caller can fall back", async () => { - mocks.findMany.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-04-15T00:00:00.000Z"), - sumValue: null, - count: 3, - mean: 1500, - }, - ]); - - const [row] = await readCumulativeDaySums( - "user-1", - "ACTIVITY_STEPS", - new Date("2026-04-15T00:00:00.000Z"), - ); - expect(row.sumValue).toBeNull(); - // Algebraic fallback parity: pre-v1.4.39 buckets still surface - // a usable daily total during the boot-backfill convergence - // window. - expect(resolveBucketSum(row)).toBe(4500); - }); -}); - -describe("resolveBucketSum", () => { - it("returns sumValue when populated", () => { - expect( - resolveBucketSum({ - bucketStart: new Date(), - sumValue: 7500, - count: 3, - mean: 2500, - }), - ).toBe(7500); - }); - - it("falls back to mean * count when sumValue is null (legacy row)", () => { - expect( - resolveBucketSum({ - bucketStart: new Date(), - sumValue: null, - count: 3, - mean: 2500, - }), - ).toBe(7500); - }); -}); diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index ade95242d..04531c6fb 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -37,9 +37,6 @@ vi.mock("@/lib/db", () => ({ import { aggregateWmyBuckets, readBestGranularityRollups, - readMonthRollups, - readWeekRollups, - readYearRollups, type RollupBucketRow, } from "../measurement-read-wmy"; @@ -71,86 +68,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("readWeekRollups", () => { - it("returns the WEEK buckets for (userId, type, since)", async () => { - const rows = [ - bucket("2026-04-27T00:00:00.000Z"), - bucket("2026-05-04T00:00:00.000Z", { mean: 81 }), - ]; - findMany.mockResolvedValueOnce(rows); - const since = new Date("2026-04-20T00:00:00.000Z"); - - const result = await readWeekRollups("user-1", "WEIGHT", since); - - expect(result).not.toBeNull(); - expect(result).toHaveLength(2); - expect(result?.[0].mean).toBe(82); - expect(result?.[1].mean).toBe(81); - const args = findMany.mock.calls[0][0]; - expect(args.where.granularity).toBe("WEEK"); - expect(args.where.userId).toBe("user-1"); - expect(args.where.type).toBe("WEIGHT"); - expect(args.where.bucketStart.gte).toBe(since); - expect(args.orderBy).toEqual({ bucketStart: "asc" }); - }); - - it("returns null when the WEEK window has no coverage", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readWeekRollups( - "user-1", - "WEIGHT", - new Date("2026-04-20T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - -describe("readMonthRollups", () => { - it("returns the MONTH buckets and pins the granularity filter", async () => { - findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]); - const result = await readMonthRollups( - "user-2", - "PULSE", - new Date("2026-01-01T00:00:00.000Z"), - ); - expect(result).toHaveLength(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); - }); - - it("returns null on coverage miss", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readMonthRollups( - "user-2", - "PULSE", - new Date("2026-01-01T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - -describe("readYearRollups", () => { - it("returns the YEAR buckets and pins the granularity filter", async () => { - findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]); - const result = await readYearRollups( - "user-3", - "WEIGHT", - new Date("2023-01-01T00:00:00.000Z"), - ); - expect(result).toHaveLength(1); - expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); - }); - - it("returns null on coverage miss", async () => { - findMany.mockResolvedValueOnce([]); - const result = await readYearRollups( - "user-3", - "WEIGHT", - new Date("2023-01-01T00:00:00.000Z"), - ); - expect(result).toBeNull(); - }); -}); - describe("readBestGranularityRollups", () => { it("returns null on a non-positive window", async () => { expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull(); diff --git a/src/lib/rollups/measurement-read-cumulative.ts b/src/lib/rollups/measurement-read-cumulative.ts deleted file mode 100644 index 6ad3d53e0..000000000 --- a/src/lib/rollups/measurement-read-cumulative.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * v1.4.39 W-SUM — read helpers for cumulative-metric daily sums. - * - * The v1.4.36 read path consumes the rollup table for spot-metric means - * via `rollup-read.ts`. Cumulative HK types (steps, flights, distance, - * daylight, active-energy) need the per-day SUM rather than the MEAN — - * every row is a partial-day slice from HealthKit, so MEAN understates - * the daily total by the per-bucket sample count. - * - * The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to - * `measurement_rollups`. The writer in `rollups.ts` populates it on - * every fold (including for non-cumulative types — the cost is one - * extra `SUM` in the existing aggregator). The legacy-NULL backfill - * runs through `enqueueBootTimeRollupBackfill` so existing rows - * converge on the next worker boot. - * - * Callers fall back to `mean * count` when `sumValue` is null. The - * shortcut is mathematically exact for a single source per day: - * - * sum = mean × count = (Σvalue / count) × count = Σvalue - * - * Multiple sources for the same `(user, type, day)` mix into the same - * bucket today — the source-priority resolution that - * `pickCanonicalSourceRows` runs in JS happens AFTER the rollup - * aggregate is built. The W8c per-source rollup deferred to v1.5 fixes - * that; until then, the cumulative read paths trust the bucket sum as - * the canonical daily total and the iOS drain (which collapses per - * source via `dailyStatsExternalId`) keeps Marc's tenant on the - * single-source-per-day path. - */ -import { prisma } from "@/lib/db"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; - -/** - * Cumulative measurement types the rollup `sum_value` column serves. - * Re-exported from the canonical Apple Health mapping so the - * consumer-side gate stays in one place. - * - * Audit anchor: `.planning/round-v1438-perf-analysis.md` §3 - * "Cumulative daily sums" + §5 P3. - */ -export type CumulativeType = - | "ACTIVITY_STEPS" - | "ACTIVE_ENERGY_BURNED" - | "FLIGHTS_CLIMBED" - | "WALKING_RUNNING_DISTANCE" - | "TIME_IN_DAYLIGHT"; - -/** - * `true` when `type` is one of the cumulative HK types whose daily - * total the rollup `sum_value` column carries. - */ -export function isCumulativeType(type: MeasurementType): type is CumulativeType { - return CUMULATIVE_HK_TYPES.has(type); -} - -/** One DAY-bucket row read from `measurement_rollups`. */ -export interface CumulativeDaySumRow { - bucketStart: Date; - /** - * Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39 - * writer change AND the boot-time backfill has not yet converged on - * this user. Callers fall back to `mean * count` for the null case. - */ - sumValue: number | null; - count: number; - mean: number; -} - -/** - * Read every DAY bucket for `(userId, type)` since `since`. Returns - * rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with - * `sumValue = null`; the caller decides whether to fall back to - * `mean * count` or skip them. - * - * Bounded to the cumulative-type set so a misuse (passing a spot - * metric) is a type error rather than a silent SUM on the wrong - * column. The query is a plain `findMany` against the - * `(userId, type, granularity, bucketStart)` composite primary key — - * single round-trip, fully indexed. - */ -export async function readCumulativeDaySums( - userId: string, - type: CumulativeType, - since: Date, -): Promise { - const rows = await prisma.measurementRollup.findMany({ - where: { - userId, - type: type as MeasurementType, - granularity: "DAY", - bucketStart: { gte: since }, - }, - orderBy: { bucketStart: "asc" }, - select: { - bucketStart: true, - sumValue: true, - count: true, - mean: true, - }, - }); - return rows.map((r) => ({ - bucketStart: r.bucketStart, - sumValue: r.sumValue, - count: r.count, - mean: r.mean, - })); -} - -/** - * Resolve a per-bucket cumulative total. Reads `sumValue` when the - * v1.4.39 writer has populated it; falls back to `mean * count` for - * legacy NULL rows so the chart never paints a hole during the boot- - * backfill convergence window. - */ -export function resolveBucketSum(row: CumulativeDaySumRow): number { - if (row.sumValue !== null) return row.sumValue; - return row.mean * row.count; -} diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 28b8f34f0..dc6a3fc72 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -117,43 +117,6 @@ const GRANULARITY_FLOORS: Array<{ { granularity: "DAY", minWindowDays: 0 }, ]; -/** - * Read the trailing WEEK buckets for `(userId, type)` since `since`. - * Returns `null` when no buckets exist in the window — the caller - * decides whether to fall back to a finer granularity or to live SQL. - */ -export async function readWeekRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "WEEK", since); -} - -/** - * Read the trailing MONTH buckets for `(userId, type)` since `since`. - * Returns `null` on coverage miss. - */ -export async function readMonthRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "MONTH", since); -} - -/** - * Read the trailing YEAR buckets for `(userId, type)` since `since`. - * Returns `null` on coverage miss. - */ -export async function readYearRollups( - userId: string, - type: MeasurementType, - since: Date, -): Promise { - return readGranularity(userId, type, "YEAR", since); -} - /** * Pick the largest granularity that can still resolve the requested * `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for @@ -163,10 +126,9 @@ export async function readYearRollups( * series. * * Returns the granularity the helper resolved against plus the row - * shape `readWeekRollups` / `readMonthRollups` / `readYearRollups` - * return. `null` when no granularity yields any buckets in the - * window — the caller is expected to short-circuit to "no data" or - * to live SQL. + * shape the internal `readGranularity` reader produces. `null` when + * no granularity yields any buckets in the window — the caller is + * expected to short-circuit to "no data" or to live SQL. */ export async function readBestGranularityRollups( userId: string, diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 6684c709d..6184361c3 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -459,40 +459,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.