mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
d0bdc4b8d1
commit
8a56f48259
16 files changed
+473
-613
No files matched your search
@@ -29,12 +29,15 @@ jobs:
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
# v1.4.40 W-INFRA — fail the build when knip surfaces a new dead
|
||||
# module, an unused production dependency, an unused devDependency,
|
||||
# or an unlisted binary. Unused exports + types are intentionally
|
||||
# left informational while the existing surface is cleaned wave by
|
||||
# wave (487 exports / 52 types on the v1.4.40 baseline). The
|
||||
# baseline carve-outs live in `knip.json` under `ignore` +
|
||||
# `ignoreDependencies` + `ignoreBinaries` with the rationale
|
||||
# captured in `.planning/phase-W-INFRA-v1440-report.md`.
|
||||
# v1.4.41 W-PROCESS-DOCS — exports + types tier staged for
|
||||
# promotion to enforcing. The W-SIMPLIFIER and W-INSIGHTS-HOT
|
||||
# cleanup waves have not yet landed on develop at the time this
|
||||
# comment was written (48 unused exports + 52 unused exported
|
||||
# types still in the tree), so the gate remains scoped to
|
||||
# files,dependencies,binaries,unlisted. Once those cleanup waves
|
||||
# merge and `pnpm knip --reporter compact` reports zero unused
|
||||
# exports + zero unused types, drop the `--include` flag below
|
||||
# to flip exports + types to enforcing. See
|
||||
# `.planning/phase-W-PROCESS-DOCS-v1441-report.md` for the
|
||||
# promotion checklist and the rollback path.
|
||||
- run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
};
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user