refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 17:14:01 +02:00
1 parent d0bdc4b8d1
commit 8a56f48259
16 files changed
+473 -613

No files matched your search

+11 -8
View File
@@ -29,12 +29,15 @@ jobs:
- run: pnpm db:generate
# v1.4.40 W-INFRA — fail the build when knip surfaces a new dead
# module, an unused production dependency, an unused devDependency,
# or an unlisted binary. Unused exports + types are intentionally
# left informational while the existing surface is cleaned wave by
# wave (487 exports / 52 types on the v1.4.40 baseline). The
# baseline carve-outs live in `knip.json` under `ignore` +
# `ignoreDependencies` + `ignoreBinaries` with the rationale
# captured in `.planning/phase-W-INFRA-v1440-report.md`.
# v1.4.41 W-PROCESS-DOCS — exports + types tier staged for
# promotion to enforcing. The W-SIMPLIFIER and W-INSIGHTS-HOT
# cleanup waves have not yet landed on develop at the time this
# comment was written (48 unused exports + 52 unused exported
# types still in the tree), so the gate remains scoped to
# files,dependencies,binaries,unlisted. Once those cleanup waves
# merge and `pnpm knip --reporter compact` reports zero unused
# exports + zero unused types, drop the `--include` flag below
# to flip exports + types to enforcing. See
# `.planning/phase-W-PROCESS-DOCS-v1441-report.md` for the
# promotion checklist and the rollback path.
- run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted
@@ -0,0 +1,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
@@ -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.<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,
},
};
+12
View File
@@ -1,6 +1,7 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import healthlogPlugin from "./eslint-plugins/healthlog/queryKey-factory.js";
const eslintConfig = defineConfig([
...nextVitals,
@@ -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;
@@ -91,7 +91,7 @@ vi.mock("@/lib/insights/features", async () => {
};
});
vi.mock("@/lib/insights/prompt", () => ({
vi.mock("@/lib/ai/prompts/insight-system-prompt", () => ({
buildUserPrompt: vi.fn(() => "user"),
}));
+2 -2
View File
@@ -6,7 +6,7 @@ import {
FeaturesPayloadTooLargeError,
} from "@/lib/insights/features";
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
import { compactSections } from "@/lib/insights/prompt-compact";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
@@ -14,7 +14,7 @@ import {
import {
buildUserPrompt,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
} from "@/lib/ai/prompts/insight-system-prompt";
import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
+1 -1
View File
@@ -22,7 +22,7 @@ import {
type CoachExcludeMetric,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { compactSections } from "@/lib/insights/prompt-compact";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import type {
CoachProvenance,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { compactSections } from "../prompt-compact";
import { compactSections } from "../compact-sections";
describe("compactSections", () => {
it("drops empty arrays", () => {
@@ -3,7 +3,7 @@ import {
buildUserPrompt,
buildComparisonBlock,
type ComparisonSnapshot,
} from "@/lib/insights/prompt";
} from "@/lib/ai/prompts/insight-system-prompt";
/**
* v1.4.16 phase B8 — comparison context block in the user prompt.
@@ -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();
});
});
-48
View File
@@ -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
@@ -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();
@@ -1,120 +0,0 @@
/**
* v1.4.39 W-SUM — read helpers for cumulative-metric daily sums.
*
* The v1.4.36 read path consumes the rollup table for spot-metric means
* via `rollup-read.ts`. Cumulative HK types (steps, flights, distance,
* daylight, active-energy) need the per-day SUM rather than the MEAN —
* every row is a partial-day slice from HealthKit, so MEAN understates
* the daily total by the per-bucket sample count.
*
* The v1.4.39 schema scaffolding (migration 0072) added `sum_value` to
* `measurement_rollups`. The writer in `rollups.ts` populates it on
* every fold (including for non-cumulative types — the cost is one
* extra `SUM` in the existing aggregator). The legacy-NULL backfill
* runs through `enqueueBootTimeRollupBackfill` so existing rows
* converge on the next worker boot.
*
* Callers fall back to `mean * count` when `sumValue` is null. The
* shortcut is mathematically exact for a single source per day:
*
* sum = mean × count = (Σvalue / count) × count = Σvalue
*
* Multiple sources for the same `(user, type, day)` mix into the same
* bucket today — the source-priority resolution that
* `pickCanonicalSourceRows` runs in JS happens AFTER the rollup
* aggregate is built. The W8c per-source rollup deferred to v1.5 fixes
* that; until then, the cumulative read paths trust the bucket sum as
* the canonical daily total and the iOS drain (which collapses per
* source via `dailyStatsExternalId`) keeps Marc's tenant on the
* single-source-per-day path.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
/**
* Cumulative measurement types the rollup `sum_value` column serves.
* Re-exported from the canonical Apple Health mapping so the
* consumer-side gate stays in one place.
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §3
* "Cumulative daily sums" + §5 P3.
*/
export type CumulativeType =
| "ACTIVITY_STEPS"
| "ACTIVE_ENERGY_BURNED"
| "FLIGHTS_CLIMBED"
| "WALKING_RUNNING_DISTANCE"
| "TIME_IN_DAYLIGHT";
/**
* `true` when `type` is one of the cumulative HK types whose daily
* total the rollup `sum_value` column carries.
*/
export function isCumulativeType(type: MeasurementType): type is CumulativeType {
return CUMULATIVE_HK_TYPES.has(type);
}
/** One DAY-bucket row read from `measurement_rollups`. */
export interface CumulativeDaySumRow {
bucketStart: Date;
/**
* Per-bucket SUM. `null` only when the bucket pre-dates the v1.4.39
* writer change AND the boot-time backfill has not yet converged on
* this user. Callers fall back to `mean * count` for the null case.
*/
sumValue: number | null;
count: number;
mean: number;
}
/**
* Read every DAY bucket for `(userId, type)` since `since`. Returns
* rows ascending by `bucketStart`. Pre-v1.4.39 rows surface here with
* `sumValue = null`; the caller decides whether to fall back to
* `mean * count` or skip them.
*
* Bounded to the cumulative-type set so a misuse (passing a spot
* metric) is a type error rather than a silent SUM on the wrong
* column. The query is a plain `findMany` against the
* `(userId, type, granularity, bucketStart)` composite primary key —
* single round-trip, fully indexed.
*/
export async function readCumulativeDaySums(
userId: string,
type: CumulativeType,
since: Date,
): Promise<CumulativeDaySumRow[]> {
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type: type as MeasurementType,
granularity: "DAY",
bucketStart: { gte: since },
},
orderBy: { bucketStart: "asc" },
select: {
bucketStart: true,
sumValue: true,
count: true,
mean: true,
},
});
return rows.map((r) => ({
bucketStart: r.bucketStart,
sumValue: r.sumValue,
count: r.count,
mean: r.mean,
}));
}
/**
* Resolve a per-bucket cumulative total. Reads `sumValue` when the
* v1.4.39 writer has populated it; falls back to `mean * count` for
* legacy NULL rows so the chart never paints a hole during the boot-
* backfill convergence window.
*/
export function resolveBucketSum(row: CumulativeDaySumRow): number {
if (row.sumValue !== null) return row.sumValue;
return row.mean * row.count;
}
+3 -41
View File
@@ -117,43 +117,6 @@ const GRANULARITY_FLOORS: Array<{
{ granularity: "DAY", minWindowDays: 0 },
];
/**
* Read the trailing WEEK buckets for `(userId, type)` since `since`.
* Returns `null` when no buckets exist in the window — the caller
* decides whether to fall back to a finer granularity or to live SQL.
*/
export async function readWeekRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "WEEK", since);
}
/**
* Read the trailing MONTH buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readMonthRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "MONTH", since);
}
/**
* Read the trailing YEAR buckets for `(userId, type)` since `since`.
* Returns `null` on coverage miss.
*/
export async function readYearRollups(
userId: string,
type: MeasurementType,
since: Date,
): Promise<RollupBucketRow[] | null> {
return readGranularity(userId, type, "YEAR", since);
}
/**
* Pick the largest granularity that can still resolve the requested
* `windowDays` window — WEEK for >14 d, MONTH for >62 d, YEAR for
@@ -163,10 +126,9 @@ export async function readYearRollups(
* series.
*
* Returns the granularity the helper resolved against plus the row
* shape `readWeekRollups` / `readMonthRollups` / `readYearRollups`
* return. `null` when no granularity yields any buckets in the
* window — the caller is expected to short-circuit to "no data" or
* to live SQL.
* shape the internal `readGranularity` reader produces. `null` when
* no granularity yields any buckets in the window — the caller is
* expected to short-circuit to "no data" or to live SQL.
*/
export async function readBestGranularityRollups(
userId: string,
@@ -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.