diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bfbce1ca..6d41e1c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.37.16] — 2026-08-13 + +### Changed + +- The correlations page no longer recomputes its full 180-day discovery on every visit. The first request computes and caches the result server-side; repeat visits within the hour answer from the cache, and any new measurement, mood entry, illness episode or custom metric refreshes it. Accepting or dismissing a pattern still takes effect immediately. +- Switching a chart's time range keeps the previous chart visible, slightly dimmed, while the new range loads instead of dropping to a placeholder. The dimming is deliberate: what you see during the switch is marked as the old range, not passed off as the new one. +- The checkups and mood pages send their first page of data along with the page itself, so they render with content instead of a loading skeleton. Self-hosters can switch this off with the existing `DASHBOARD_SSR_PREFETCH` flag. +- Two background checks are calmer now. The achievement check runs every five minutes instead of every two, since anything you do in the app updates instantly anyway and the poll only exists for data arriving from a device. The Coach plan-proposal check stops on its own instead of polling an open conversation forever. + +### Fixed + +- A checkup accepted from a Coach suggestion appears on the checkups page and the Today rail immediately. Before, it stayed invisible for up to five minutes. +- Changing a target, a threshold or the health score configuration is reflected on the dashboard right away, including the hero bands and score context. Before, the dashboard kept showing the old configuration until its next background refresh. +- Starting or ending an illness episode updates the dashboard's rest mode immediately, booking an appointment puts it on the Today rail immediately, and logging a vaccination dose updates the booster reminder on the rail immediately. +- Completing a mental-health screening refreshes the checkup list right away, and changing a source priority refreshes the affected charts right away. +- Background syncs from Fitbit, Oura, Polar, WHOOP, Strava and Nightscout now tell the server-side caches that new data arrived, so analytics and workout views pick the data up on the next read instead of waiting out the cache lifetime. The same applies to illness, cycle and appointment changes. +- A structural test now enforces that every place that writes data a daily view reads also triggers that view's refresh, so this class of stale display cannot quietly return. + ## [1.37.15] — 2026-08-13 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 229ac3174..43481fd39 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.15 + version: 1.37.16 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/dashboard-ssr-prefetch-hydration.spec.ts b/e2e/dashboard-ssr-prefetch-hydration.spec.ts index efeec4d01..bea2d3acf 100644 --- a/e2e/dashboard-ssr-prefetch-hydration.spec.ts +++ b/e2e/dashboard-ssr-prefetch-hydration.spec.ts @@ -97,4 +97,45 @@ test.describe("dashboard hydration with the SSR prefetch on", () => { expect(witness?.seen).toBe(true); expect(witness?.replaced).toBe(0); }); + + // The two list-page prefetch wrappers (`/checkups`, `/mood`) hand TanStack a + // server-dehydrated state through the same HydrationBoundary mechanism the + // dashboard uses. Their page-clients render behind a `useMounted` gate, so + // the streamed HTML is the auth-gate shell by design — the node-identity + // witness has nothing to latch. What CAN break, and what these assert, is + // the hydration itself: a dehydrated state that disagrees with the client + // render surfaces as a React #418 bailout on exactly this server (the + // mocked suite runs with the prefetch OFF and can never see it). + for (const target of [ + { + path: "/checkups", + name: "checkups", + readySelector: '[data-testid="checkups-tab-vorsorge"]', + }, + { + path: "/mood", + name: "mood", + readySelector: '[data-tour-id="mood-hero"]', + }, + ]) { + test(`hydrates ${target.name} with its prefetched list state`, async ({ + page, + }) => { + const clientErrors: string[] = []; + page.on("pageerror", (error) => clientErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") clientErrors.push(message.text()); + }); + + await page.goto(target.path); + await expect(page.locator(target.readySelector)).toBeVisible(); + // Let every boundary hydrate and every post-mount refetch land before + // the verdict; a bailout that happens late is still a bailout. + await page.waitForLoadState("networkidle"); + + expect( + clientErrors.filter((message) => /418|hydrat/i.test(message)), + ).toEqual([]); + }); + } }); diff --git a/package.json b/package.json index 8cbb9ff70..b04ff5c14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.15", + "version": "1.37.16", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f5efaea5..9e8cd59f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: dompurify@<=3.4.10: ^3.4.11 postcss@<8.5.10: ^8.5.10 - nanoid@<3.3.17: ^3.3.17 + nanoid@<3.3.18: ^3.3.18 '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -5557,8 +5557,8 @@ packages: nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -12632,7 +12632,7 @@ snapshots: nan@2.27.0: optional: true - nanoid@3.3.17: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} @@ -13039,19 +13039,19 @@ snapshots: postcss@8.5.14: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.20: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.25: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3fa8ec2d3..c501b83ad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,7 @@ overrides: "dompurify@<=3.4.10": "^3.4.11" "postcss@<8.5.10": "^8.5.10" - "nanoid@<3.3.17": "^3.3.17" + "nanoid@<3.3.18": "^3.3.18" "@hono/node-server@<1.19.13": "^1.19.13" "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" diff --git a/src/__tests__/customize-shortcut-guard.test.ts b/src/__tests__/customize-shortcut-guard.test.ts index 6faafe83f..9945974b4 100644 --- a/src/__tests__/customize-shortcut-guard.test.ts +++ b/src/__tests__/customize-shortcut-guard.test.ts @@ -140,7 +140,7 @@ describe("the customise shortcut is withheld inside somebody else's record", () // clean tree are the same sentence otherwise, and this is a leg whose // verdict is an empty list. expect(carrying.length).toBeGreaterThan(4); - expect(carrying).toContain("app/mood/page.tsx"); + expect(carrying).toContain("app/mood/page-client.tsx"); expect(carrying).toContain("app/medications/page-client.tsx"); for (const rel of Object.keys(NOT_A_SHARED_DESTINATION)) { expect(carrying, rel).toContain(rel); diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 8631c1c2d..3dda6b7c3 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -22,6 +22,14 @@ * an allowlisted exception is correct — only that no NEW site invalidates a * daily bundle without the paired refetch, and no PAIRED site loses its refetch, * without someone editing this file. + * + * Both matcher families were proven live by breaking a real fix and watching + * the guard go red: removing the `refetchInactiveDailyReads` pairing from the + * illness invalidator (`components/illness/use-illness.ts`) failed the bundle + * assertion, and reverting the score-config save + * (`components/settings/score-section.tsx`) to its former bare + * snapshot/digest `invalidateQueries` pair failed the direct-invalidation + * assertion. Both fixes were then restored and the guard re-ran green. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -41,13 +49,38 @@ const FACTORY = "lib/query-keys/index.ts"; * per-surface key). Sites routed through `invalidateMedicationReads` do NOT * match — the wrapper carries the refetch internally, so they are compliant by * construction and correctly invisible here. + * + * The cycle / encounter / vaccination / illness families joined the matcher + * alongside the original three: the encounter and illness bundles now carry a + * daily-read key directly, the vaccination bundle's reminder root feeds the + * Today rail, and cycle rides along so a future daily-read key added to its + * bundle cannot ship an unpaired site silently (its current sites are + * allowlisted below with the reason). */ const INVALIDATION = - /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g; + /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g; // Match the CALL form only (open paren), never the bare import specifier — // otherwise the `import { refetchInactiveDailyReads }` line would inflate the // count and let a file drop one paired call while still "passing". -const REFETCH = /refetchInactiveDailyReads\(/g; +// `invalidateReminderReads` counts as a paired refetch: it carries +// `refetchInactiveDailyReads` internally (see `hooks/use-measurement-reminders`), +// and the encounter / vaccination mutation sites pair through it. +const REFETCH = /(?:refetchInactiveDailyReads|invalidateReminderReads)\(/g; + +/** + * Second matcher family — a DIRECT `invalidateQueries` on a daily-read key. + * Captures `.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })` + * / `dailyDigest()` in every observed shape (single line, prettier-broken, + * with or without a `refetchType`). Group 1 carries the `refetchType` value + * when present: `"inactive"` and `"all"` both force the unmounted daily reads + * to refetch and are compliant; a bare call (or `"active"`) marks the + * typically-unmounted daily reads stale without refetching them — exactly the + * v1.16.11/v1.29.1/v1.32.19 class, one call site at a time instead of via a + * bundle. Known limit (mirrors the bundle matcher): a call spelling the key + * through a local alias rather than `queryKeys.()` would slip past. + */ +const DIRECT_DAILY_INVALIDATION = + /\.invalidateQueries\(\s*\{\s*queryKey:\s*queryKeys\.(?:dashboardSnapshot|dailyDigest)\(\)\s*,?\s*(?:refetchType:\s*"(\w+)"\s*,?\s*)?\}\s*,?\s*\)/g; function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) @@ -99,6 +132,29 @@ const NO_REFETCH_ALLOWLIST = [ "components/settings/integrations/oauth-provider-card.tsx", "components/settings/integrations/whoop-card.tsx", "components/settings/integrations/withings-card.tsx", + // Cycle writes: `cycleDependentKeys` carries no daily-read key and neither + // the dashboard snapshot nor the Today digest reads cycle rows today, so + // the forced refetch would be a no-op round-trip. The bundle is in the + // matcher so the day a daily-read key joins it, these sites surface here + // and must take the pairing. + "components/cycle/use-cycle.ts", +].sort(); + +/** + * Files allowed a DIRECT daily-read `invalidateQueries` without an inactive + * refetch, deliberately. Frozen like the bundle allowlist above. + */ +const DIRECT_NO_REFETCH_ALLOWLIST = [ + // Background sync completion on the Settings surface — same reason as the + // integration cards in the bundle allowlist: the user is configuring, not + // acting on the hero; poll/focus convergence is intended. + "components/settings/integrations/google-health-card.tsx", + // The check-in card and the priority-item dismissal both render INSIDE the + // Today digest, so the digest query is mounted and the default + // `refetchType: "active"` refetches it immediately — the inactive arm has + // nothing to cover. + "hooks/use-coach-checkin.ts", + "hooks/use-priority-item-dismiss.ts", ].sort(); describe("daily-reads refetch guard", () => { @@ -146,6 +202,33 @@ describe("daily-reads refetch guard", () => { expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST); }); + it("every direct daily-read invalidation forces a refetch, except the frozen allowlist", () => { + // A direct `invalidateQueries(dashboardSnapshot|dailyDigest)` is the + // single-key form of the same class the bundle matcher pins. Compliant + // forms: `refetchType: "inactive"` / `"all"` on the call itself, or a + // paired `refetchInactiveDailyReads` elsewhere in the file (then the + // direct call is the mounted-arm half of an explicit pair). + const nonCompliant: string[] = []; + let totalMatches = 0; + for (const rel of sourceFiles()) { + if (rel === FACTORY) continue; + const s = read(rel); + let bare = 0; + for (const m of s.matchAll(DIRECT_DAILY_INVALIDATION)) { + totalMatches++; + const refetchType = m[1]; + if (refetchType !== "inactive" && refetchType !== "all") bare++; + } + if (bare > 0 && count(s, REFETCH) === 0) nonCompliant.push(rel); + } + // The matcher must be alive: the tree has known compliant call sites + // (the dashboard-layout section's `refetchType: "all"` pair among them). + // Zero matches would mean the regex rotted, not that the class is gone — + // the lesson of the 2026-08-01 bearer-guard blind spot. + expect(totalMatches).toBeGreaterThan(3); + expect(nonCompliant.sort()).toEqual(DIRECT_NO_REFETCH_ALLOWLIST); + }); + it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => { // Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to // a bare `invalidateKeys(medicationDependentKeys)` is caught here as well diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index eecb9a461..a136120ec 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -52,8 +52,11 @@ function read(relativePath: string): string { */ const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [ { - file: "src/app/api/measurement-reminders/route.ts", - surface: "the reminder list the checkups page renders", + // The list query moved out of the route into the shared read so the + // `/checkups` SSR prefetch and `GET /api/measurement-reminders` cannot + // drift; the exclusion travels with the query. + file: "src/lib/measurement-reminders/list-read.ts", + surface: "the reminder list the checkups page and its SSR prefetch render", }, { file: "src/lib/daily/load-digest.ts", diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts index 54c19d195..883bb2c9d 100644 --- a/src/__tests__/prefetch-page-caching-guard.test.ts +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -172,10 +172,12 @@ const prefetchingPages = walk(APP_DIR) */ const PREFETCHING_ROUTES = [ "/", + "/checkups", "/coach", "/insights", "/insights/workouts", "/medications", + "/mood", ] as const; describe("server-prefetching pages cannot leak a cacheable record", () => { diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index a1e42ed56..dd822043e 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -105,7 +105,7 @@ describe("S1 — the direct session-resolution set is frozen", () => { // with the session user's own data. Absence of a session skips the // prefetch rather than failing the render. // - // These five resolve through `getUnswitchedSession()`, not `getSession()`, + // These seven resolve through `getUnswitchedSession()`, not `getSession()`, // and they stay listed here BECAUSE of it. The helper is still a session // derivation outside `requireAuth` — moving the call one file deeper into // `lib/` would have taken them off this list while changing nothing about @@ -115,6 +115,8 @@ describe("S1 — the direct session-resolution set is frozen", () => { // can only ever hold the caller's own. See // `prefetch-page-caching-guard.test.ts` for the rule that keeps a sixth // prefetching page from reaching for the bare helper. + "app/checkups/page.tsx": + "RSC prefetch of the session user's reminder list, skipped under a switch", "app/coach/page.tsx": "RSC prefetch of the session user's coach state, skipped under a switch", "app/insights/page.tsx": @@ -123,6 +125,8 @@ describe("S1 — the direct session-resolution set is frozen", () => { "RSC prefetch of the session user's workouts, skipped under a switch", "app/medications/page.tsx": "RSC prefetch of the session user's medications, skipped under a switch", + "app/mood/page.tsx": + "RSC prefetch of the session user's mood list, skipped under a switch", "app/page.tsx": "RSC prefetch of the session user's dashboard snapshot, skipped under a switch", // Onboarding routing. Reads the actor's own onboarding step to redirect; diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts index 3d54a91ef..2e0a91dd4 100644 --- a/src/app/api/cycle/all/route.ts +++ b/src/app/api/cycle/all/route.ts @@ -18,6 +18,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -105,6 +106,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { details: cleared, }); + // The purge removes every row the cached cycle context was built on — + // evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.purge" }, meta: cleared, diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 4b8900d00..4e8483cc2 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -12,6 +12,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -78,6 +79,10 @@ export const DELETE = apiHandler( }, }); + // A removed cycle changes the cycle context the cached analytics / + // snapshot / digest cells carry — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.cycle.delete", diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index af4db03b8..211e07eea 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -11,6 +11,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -204,6 +205,10 @@ export const PATCH = apiHandler( }, }); + // A day-log edit changes the cycle context the cached analytics / + // snapshot / digest cells carry — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.day-log.update", @@ -282,6 +287,10 @@ export const DELETE = apiHandler( }, }); + // A removed day (possibly with its cycle) changes the cycle context the + // cached analytics / snapshot / digest cells carry — evict the bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.day-log.delete", diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 7a83d30a8..890bbf240 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -25,6 +25,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -228,6 +229,12 @@ async function postBulk(request: NextRequest): Promise { }, }); + // Landed rows change the cycle context the cached analytics / snapshot / + // digest cells carry — evict the analytics bucket. + if (inserted > 0 || updated > 0) { + invalidateUserHealthContext(user.id); + } + annotate({ action: { name: "cycle.bulk.ingest" }, meta: { diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 4b1f8cbda..28e91b0c4 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -173,6 +174,10 @@ async function postDayLog(request: NextRequest): Promise { }, }); + // A day-log write changes the cycle context the cached analytics / + // snapshot / digest cells carry — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.day-log.upsert", diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 2223447d0..26eca5a6d 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -12,6 +12,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -196,6 +197,10 @@ async function postPeriod(request: NextRequest): Promise { }, }); + // A period boundary changes the cycle context the cached analytics / + // snapshot / digest cells carry — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "cycle.period.boundary", diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index 5da6b3242..c37cd9500 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; @@ -92,6 +93,10 @@ export const POST = apiHandler( details: { encounterId: id, occurredAt: row.occurredAt.toISOString() }, }); + // A restored visit must reach the cached daily-digest / snapshot cells + // (Today rail) on the next read — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "encounter.visit.restore", diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index 24840de07..b33bc64d9 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -18,6 +18,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; @@ -342,6 +343,10 @@ export const PATCH = apiHandler( }, }); + // A rescheduled / re-statused visit must reach the cached daily-digest / + // snapshot cells (Today rail) on the next read — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "encounter.visit.update", @@ -392,6 +397,10 @@ export const DELETE = apiHandler( }, }); + // A removed visit must leave the cached daily-digest / snapshot cells + // (Today rail) on the next read — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "encounter.visit.delete", diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 85797154e..0fc4a5fa7 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -15,6 +15,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -299,6 +300,10 @@ async function postEncounter(request: NextRequest): Promise { details: { encounterId: created.id, status: created.status }, }); + // A booked visit must reach the cached daily-digest / snapshot cells + // (Today rail) on the next read — evict the analytics bucket. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "encounter.visit.create", diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index f1a77e14c..dab4ac6c6 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -11,6 +11,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -94,6 +95,10 @@ export const PATCH = apiHandler( details: { episodeId: id }, }); + // Resolving flips Rest Mode off — evict the cached analytics / snapshot / + // digest cells so the context is true on the next read. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "illness.episode.resolve", diff --git a/src/app/api/illness/episodes/[id]/restore/route.ts b/src/app/api/illness/episodes/[id]/restore/route.ts index 082aafb19..893ed22f2 100644 --- a/src/app/api/illness/episodes/[id]/restore/route.ts +++ b/src/app/api/illness/episodes/[id]/restore/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; @@ -55,6 +56,9 @@ export const POST = apiHandler( ipAddress: getClientIp(request), details: { episodeId: id }, }); + // Restoring an unresolved episode can flip Rest Mode back on — evict + // the cached analytics / snapshot / digest cells. + invalidateUserHealthContext(user.id); } const restored = await prisma.illnessEpisode.findUniqueOrThrow({ diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index 102c23314..220859e8d 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -11,6 +11,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; @@ -184,6 +185,11 @@ export const PATCH = apiHandler( }, }); + // An onset / lifecycle / resolvedAt edit can flip Rest Mode — evict the + // cached analytics / snapshot / digest cells so the context is true on + // the next read. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "illness.episode.update", @@ -224,6 +230,9 @@ export const DELETE = apiHandler( ipAddress: getClientIp(request), details: { episodeId: id }, }); + // Deleting an active episode can flip Rest Mode off — evict the cached + // analytics / snapshot / digest cells. + invalidateUserHealthContext(user.id); } annotate({ diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index bb2d90b9a..2095d5246 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -135,6 +136,10 @@ async function postEpisode(request: NextRequest): Promise { details: { episodeId: created.id, type: created.type }, }); + // A new episode can flip Rest Mode on — evict the cached analytics / + // snapshot / digest cells so the context is true on the next read. + invalidateUserHealthContext(user.id); + annotate({ action: { name: "illness.episode.create", diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 943931089..2e19d0ea1 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -80,6 +80,8 @@ import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -88,6 +90,9 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + // The route reads through the SWR analytics cache now; a warm cell from a + // prior test would serve a hit and skip the builder every assertion spies on. + __resetAllCachesForTests(); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ @@ -157,6 +162,25 @@ describe("GET /api/insights/correlations", () => { expect(body.data.fdrQ).toBeGreaterThan(0); }); + it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => { + // The SWR cell caches the built body for 60 s; the second GET inside that + // window must neither re-read the measurement window nor re-run the two + // `syncAcceptedPatterns` DB writes — writes on the read path were the + // pre-cache defect, and a hit that still wrote would reintroduce it. + // Watched red: with the assertion counts set to 2 (i.e. asserting the + // builder re-ran) this test fails, so the cache path is proven live. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const first = await callGet(makeReq()); + expect(first.status).toBe(200); + const second = await callGet(makeReq()); + expect(second.status).toBe(200); + expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1); + expect(syncAcceptedPatterns).toHaveBeenCalledTimes(2); + const firstBody = (await first.json()) as { data: unknown }; + const secondBody = (await second.json()) as { data: unknown }; + expect(secondBody.data).toEqual(firstBody.data); + }); + it("returns 403 + errorCode when the correlations flag is off", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); ( diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 8c03fdcde..1dd15e271 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -17,6 +17,7 @@ */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -86,16 +87,43 @@ export const GET = apiHandler(async () => { // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { timezone: true }, - }); - const tz = profile?.timezone ?? "Europe/Berlin"; // Reader's locale for the narrated `interpretation` — the correlation cards // render this string verbatim, so it MUST be localised (cookie / User.locale / // Accept-Language). Without it the never-causal sentence leaked English into a - // non-English UI. + // non-English UI. It is part of the cache key for the same reason. const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); + + // The discovery scan is the most expensive uncached read the app had: + // ~10 parallel window reads over 180 days + Pearson/FDR in-process, paid on + // EVERY mount. Cache the built body in the analytics bucket (60 s fresh / + // 1 h stale — the bucket defaults are exactly the contract this read wants). + // Eviction truth comes free: every measurement / mood / medication / + // illness / custom-metric write sweeps the `${userId}|` prefix in this + // bucket, and a pattern dismiss goes through + // `invalidateUserCorrelationPatterns`, which sweeps it too. The + // `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder, + // so a cache hit performs no write on the read path. + const body = await cachedSwr( + caches.analytics as ServerCache< + Awaited> + >, + `${user.id}|correlations|${locale}`, + () => buildCorrelationsResponse(user.id, locale), + annotate, + ); + + return apiSuccess(body); +}); + +async function buildCorrelationsResponse( + userId: string, + locale: Awaited>, +) { + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const tz = profile?.timezone ?? "Europe/Berlin"; const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); @@ -121,12 +149,12 @@ export const GET = apiHandler(async () => { moodWindow, priorityJson, ] = await Promise.all([ - fetchMeasurementWindowSeries(user.id, since, [ + fetchMeasurementWindowSeries(userId, since, [ ...behaviourTypes, ...outcomeTypes, ]), - fetchMoodWindowSeries(user.id, tz, since), - loadUserSourcePriority(user.id), + fetchMoodWindowSeries(userId, tz, since), + loadUserSourcePriority(userId), ]); const { moodDaily, moodCapped } = moodWindow; @@ -141,14 +169,14 @@ export const GET = apiHandler(async () => { environmentSeries, customMetricSeries, ] = await Promise.all([ - fetchComplianceSeries(user.id, tz, since), - fetchSymptomSeries(user.id, tz, since), - fetchLabDraws(user.id, tz, since), + fetchComplianceSeries(userId, tz, since), + fetchSymptomSeries(userId, tz, since), + fetchLabDraws(userId, tz, since), // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / // daylight). Empty when the module is off / no home set, so the channels // degrade to absent. The module gate is implicit: no rows ⇒ no channels. - fetchEnvironmentSeries(user.id, since), - fetchCustomMetricBehaviourSeries(user.id, tz, since), + fetchEnvironmentSeries(userId, since), + fetchCustomMetricBehaviourSeries(userId, tz, since), ]); const points = (key: string): DailySeriesPoint[] => @@ -219,13 +247,13 @@ export const GET = apiHandler(async () => { }); const [retrospectiveDecisions, recentDecisions] = await Promise.all([ syncAcceptedPatterns({ - userId: user.id, + userId, family: PATTERN_FAMILIES.discoveryRetrospective, accepted: result.discovered.map(toEvidence), computedAt: now, }), syncAcceptedPatterns({ - userId: user.id, + userId, family: PATTERN_FAMILIES.discoveryRecent, accepted: emerging.emerging.map(toEvidence), computedAt: now, @@ -285,10 +313,10 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess({ + return { ...result, discovered, emerging: emergingWithDecisions, labCorrelations, - }); -}); + }; +} diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 7e4d0b2e8..2f55a8ec9 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -24,6 +24,7 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read"; const DEFAULT_TIMEZONE = "Europe/Berlin"; @@ -38,28 +39,16 @@ async function resolveTimezone(userId: string): Promise { export const GET = apiHandler(async () => { const { user } = await requireRecordAuth("read", "measurements"); - const reminders = await prisma.measurementReminder.findMany({ - // A booked visit's one-shot reminder rides this same engine with - // `origin: ENCOUNTER`. It is not a checkup and must not appear on a - // Vorsorge surface, or the list fills with appointments. Four read - // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` - // proves every one of them, and the DTO mapper refuses such a row - // outright so a site that lost its filter fails loudly. - where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } }, - // Most-urgent first; a null next-due (uncomputable / disabled) sinks - // to the end so the actionable items float to the top. - orderBy: [ - { nextDueAt: { sort: "asc", nulls: "last" } }, - { createdAt: "asc" }, - ], - }); + // The query (with its ENCOUNTER-origin exclusion) lives in the shared + // list read so this route and the `/checkups` SSR prefetch cannot drift. + const reminders = await listMeasurementReminders(user.id); annotate({ action: { name: "measurement-reminders.list" }, meta: { total: reminders.length }, }); - return apiSuccess(reminders.map(toMeasurementReminderDto)); + return apiSuccess(reminders); }); async function postReminder(request: NextRequest): Promise { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 350095e60..c4faecd3b 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -26,6 +26,7 @@ import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; +import { listMoodEntriesPage } from "@/lib/mood/list-read"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -78,87 +79,21 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - const { mood, source, from, to, limit, offset, sortBy, sortDir } = - parsed.data; - - const where = { - userId: user.id, - // v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list. - deletedAt: null, - ...(mood && { mood }), - // v1.15.13 — management-list source filter. - ...(source && { source }), - ...(from || to - ? { - date: { - ...(from && { gte: from }), - ...(to && { lte: to }), - }, - } - : {}), - }; - - const [entries, total] = await Promise.all([ - prisma.moodEntry.findMany({ - where, - orderBy: { [sortBy]: sortDir }, - take: limit, - skip: offset, - // v1.8.5 — include the structured-tag link keys so the edit form - // can pre-populate the taxonomy picker. - // v1.12.0 — also carry the per-link `rating` + the tag `kind` so - // the client can split binary tags from rated factors on hydrate. - include: { - tagLinks: { - select: { - rating: true, - moodTag: { select: { key: true, kind: true } }, - }, - }, - // v1.38 — the day context, so the edit dialog can pre-populate the - // sections without a second round trip per row. Sparse by design: most - // rows carry none and the join costs nothing on those. - context: true, - }, - }), - prisma.moodEntry.count({ where }), - ]); + // The query + wire shaping (note decryption, tag split, day context) live + // in the shared list read so this route and the `/mood` SSR prefetch + // cannot drift. + const body = await listMoodEntriesPage(user.id, parsed.data); annotate({ action: { name: "mood-entries.list" }, - meta: { total, limit, offset }, + meta: { + total: body.meta.total, + limit: body.meta.limit, + offset: body.meta.offset, + }, }); - const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({ - // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. - // v1.37 — the five level-A values under the keys the create path takes, - // so the edit form reads back what it wrote instead of mapping column - // names to wire names by hand. The column spelling is stripped, not - // joined: one number under two names is a choice nobody made. - ...shapeLevelA(shapeMoodNote(e)), - tags: parseTags(e.tags), - // v1.8.5 — flat list of binary structured-tag keys attached to the - // entry (rated factors are surfaced separately below). - tagKeys: tagLinks - .filter((link) => link.moodTag.kind !== "RATED") - .map((link) => link.moodTag.key), - // v1.12.0 — rated factors with their per-entry score, so the edit - // form re-renders the sliders without a refetch. - ratedFactors: tagLinks - .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) - .map((link) => ({ - key: link.moodTag.key, - rating: link.rating as number, - })), - // v1.38 — the day context, or null. The ciphertext never leaves the - // server: `contextForWire` reads the note and drops the column. - context: context ? contextForWire(context) : null, - })); - - return apiSuccess({ - entries: entriesWithParsedTags, - meta: { total, limit, offset }, - }); + return apiSuccess(body); }); export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry)); diff --git a/src/app/checkups/__tests__/page-prefetch.test.tsx b/src/app/checkups/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..3ca39318b --- /dev/null +++ b/src/app/checkups/__tests__/page-prefetch.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HydrationBoundary, hashKey } from "@tanstack/react-query"; +import type { ReactElement } from "react"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * The `/checkups` server-prefetch key crux + fail-soft. + * + * The RSC wrapper (`src/app/checkups/page.tsx`) dehydrates the Vorsorge + * reminder list under the SAME zero-arg factory key the client hook + * (`useMeasurementReminders`) mounts on, via the shared + * `listMeasurementReminders` read — so the checkups list paints from the + * first HTML. These tests pin the seeded key (byte-identical hash), the + * JSON-wire shape, and that every error path fails soft to the bare client. + */ + +const getUnswitchedSession = vi.fn(); +const listMeasurementReminders = vi.fn(); + +vi.mock("@/lib/auth/acting-carrier", () => ({ + getUnswitchedSession: () => getUnswitchedSession(), +})); +vi.mock("@/lib/measurement-reminders/list-read", () => ({ + listMeasurementReminders: (userId: string) => + listMeasurementReminders(userId), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import CheckupsPage from "../page"; + +const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } }; + +afterEach(() => { + vi.clearAllMocks(); + delete process.env.DASHBOARD_SSR_PREFETCH; +}); + +function queriesOf( + el: ReactElement, +): { queryHash: string; state: { data: unknown } }[] { + if (el.type !== HydrationBoundary) return []; + const props = el.props as { + state?: { queries: { queryHash: string; state: { data: unknown } }[] }; + }; + return props.state?.queries ?? []; +} + +describe("/checkups RSC prefetch", () => { + it("dehydrates the reminder list under the client's factory key", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + listMeasurementReminders.mockResolvedValue([ + { id: "r1", label: "Blood pressure", nextDueAt: null }, + ]); + + const el = (await CheckupsPage()) as ReactElement; + const hashes = queriesOf(el).map((q) => q.queryHash); + expect(hashes).toContain(hashKey(queryKeys.measurementReminders())); + expect(listMeasurementReminders).toHaveBeenCalledWith("u1"); + }); + + it("JSON-round-trips the list to the wire shape", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + const createdAt = new Date("2026-07-18T06:00:00.000Z"); + listMeasurementReminders.mockResolvedValue([{ id: "r1", createdAt }]); + + const el = (await CheckupsPage()) as ReactElement; + const seeded = queriesOf(el).find( + (q) => q.queryHash === hashKey(queryKeys.measurementReminders()), + ); + expect((seeded!.state.data as { createdAt: unknown }[])[0]!.createdAt).toBe( + createdAt.toISOString(), + ); + }); + + it("fails soft to the bare client when the read throws", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + listMeasurementReminders.mockRejectedValue(new Error("db blip")); + + const el = (await CheckupsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + // Null also means "acting on somebody else's record" — `getUnswitchedSession` + // collapses both into the same answer, and the page treats them the same. + it("fails soft when there is no session", async () => { + getUnswitchedSession.mockResolvedValue(null); + const el = (await CheckupsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(listMeasurementReminders).not.toHaveBeenCalled(); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await CheckupsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getUnswitchedSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx new file mode 100644 index 000000000..7f6a08714 --- /dev/null +++ b/src/app/checkups/page-client.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; + +/** + * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated + * feature surface for "wann muss ich was wo machen". Auth-gated; the + * section component owns the list + create flow. + * + * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, + * measurements): a pull invalidates the measurement-reminders read so the + * list + server-computed next-due repaint. + * + * The page now holds two things, split by a segmented control rather than by a + * second nav entry: the preventive-care list, and the visits. They answer the + * same question from two directions — what do I still have to arrange, and what + * did I actually attend — and the page's own sentence already covers both. A + * visit carries no module gate for the same reason this page never had one. + * + * The pull invalidates both reads. Refreshing one and leaving the other stale + * is how a page ends up showing a visit that closed a checkup still listed as + * due. + */ +export default function CheckupsPageClient() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [view, setView] = useState("vorsorge"); + + const refresh = useCallback( + () => + invalidateKeys(queryClient, [ + queryKeys.measurementReminders(), + queryKeys.encounters(), + ]), + [queryClient], + ); + const pull = usePullToRefresh({ onRefresh: refresh }); + + if (!mounted || isLoading) { + return ; + } + + return ( +
+ + + + + {t("checkups.tabVorsorge")} + + + {t("checkups.tabVisits")} + + + + + + + {/* Mounted only on the active tab, so the visits read does not fire + for a person who never opens it. */} + + + +
+ ); +} diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index a9d754672..512af7b52 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,80 +1,74 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read"; +import { queryKeys } from "@/lib/query-keys"; -import { useAuth } from "@/hooks/use-auth"; -import { useMounted } from "@/hooks/use-mounted"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; -import { PageAuthGate } from "@/components/ui/page-auth-gate"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; -import { VisitsSection } from "@/components/encounters/visits-section"; -import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import CheckupsPageClient from "./page-client"; /** - * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated - * feature surface for "wann muss ich was wo machen". Auth-gated; the - * section component owns the list + create flow. + * Thin RSC wrapper around the (client) checkups page. * - * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, - * measurements): a pull invalidates the measurement-reminders read so the - * list + server-computed next-due repaint. + * The page's above-the-fold content — the Vorsorge reminder list — waited for + * the client `queryKeys.measurementReminders()` cell to fetch after hydrate, + * so the first paint flashed a skeleton before the list filled in. This + * wrapper runs the SAME read the API route uses (`listMeasurementReminders`, + * shared so filter/order/DTO cannot drift) during SSR and hands it to + * TanStack through `HydrationBoundary`. * - * The page now holds two things, split by a segmented control rather than by a - * second nav entry: the preventive-care list, and the visits. They answer the - * same question from two directions — what do I still have to arrange, and what - * did I actually attend — and the page's own sentence already covers both. A - * visit carries no module gate for the same reason this page never had one. - * - * The pull invalidates both reads. Refreshing one and leaving the other stale - * is how a page ends up showing a visit that closed a checkup still listed as - * due. + * Contract notes (the v1.30.9 dashboard template): + * - The query key comes ONLY from the central factory — + * `queryKeys.measurementReminders()` is a deterministic zero-arg tuple, so + * the server-seeded key equals the client's `useQuery` key by construction. + * - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is + * exactly what the client `queryFn` produces from the wire (ISO date + * strings), never a Date-carrying sibling that poisons the cell. + * - Only the PRIMARY read is prefetched. The visits tab mounts its own read + * lazily on first open, and that stays client-lazy on purpose — most + * visits to this page never open it. + * - Record identity: the session comes from `getUnswitchedSession()`, which + * answers null while the browser is acting on somebody else's record — + * the prefetch must never serialise the delegate's own reminders into a + * page opened on the owner's record. + * - Fail-soft: no session or a read hiccup renders the page exactly as + * before this wrapper existed — the client cell owns the fetch. */ -export default function CheckupsPage() { - const { isAuthenticated, isLoading } = useAuth(); - const mounted = useMounted(); - const queryClient = useQueryClient(); - const { t } = useTranslations(); - const [view, setView] = useState("vorsorge"); - - const refresh = useCallback( - () => - invalidateKeys(queryClient, [ - queryKeys.measurementReminders(), - queryKeys.encounters(), - ]), - [queryClient], - ); - const pull = usePullToRefresh({ onRefresh: refresh }); - - if (!mounted || isLoading) { - return ; +export default async function CheckupsPage() { + // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e + // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which + // only see CLIENT fetches — keep governing what every prefetched page paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; } + let dehydratedState = null; + try { + const session = await getUnswitchedSession(); + if (session) { + const list = await listMeasurementReminders(session.user.id); + const queryClient = new QueryClient(); + // Match the client cell's wire shape exactly (JSON semantics, ISO date + // strings) — same-key-different-shape is silent cache poison. + queryClient.setQueryData( + queryKeys.measurementReminders(), + JSON.parse(JSON.stringify(list)), + ); + dehydratedState = dehydrate(queryClient); + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + + if (dehydratedState === null) { + return ; + } return ( -
- - - - - {t("checkups.tabVorsorge")} - - - {t("checkups.tabVisits")} - - - - - - - {/* Mounted only on the active tab, so the visits read does not fire - for a person who never opens it. */} - - - -
+ + + ); } diff --git a/src/app/mood/__tests__/page-prefetch.test.tsx b/src/app/mood/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..b9c5b515e --- /dev/null +++ b/src/app/mood/__tests__/page-prefetch.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HydrationBoundary, hashKey } from "@tanstack/react-query"; +import type { ReactElement } from "react"; + +import { queryKeys } from "@/lib/query-keys"; +import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size"; + +/** + * The `/mood` server-prefetch key crux + fail-soft. + * + * The RSC wrapper (`src/app/mood/page.tsx`) dehydrates the mood list's FIRST + * page under the parameterised `queryKeys.moodEntriesList(…)` tuple the + * client list mounts with (no filters, page 1, `moodLoggedAt desc`), via the + * shared `listMoodEntriesPage` read. These tests pin the seeded key + * (byte-identical hash), the first-mount parameters (`MOOD_LIST_PAGE_SIZE`, + * offset 0), the module-gate parity, and the fail-soft paths. + */ + +const getUnswitchedSession = vi.fn(); +const listMoodEntriesPage = vi.fn(); +const resolveModuleMap = vi.fn(); + +vi.mock("@/lib/auth/acting-carrier", () => ({ + getUnswitchedSession: () => getUnswitchedSession(), +})); +vi.mock("@/lib/mood/list-read", () => ({ + listMoodEntriesPage: (userId: string, params: unknown) => + listMoodEntriesPage(userId, params), +})); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (userId: string) => resolveModuleMap(userId), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import MoodPage from "../page"; + +const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } }; + +/** The client list's first-mount key (no filters, page 1, default sort). */ +const FIRST_MOUNT_KEY = queryKeys.moodEntriesList({ + mood: undefined, + source: undefined, + from: undefined, + to: undefined, + page: 1, + sortBy: "moodLoggedAt", + sortDir: "desc", +}); + +afterEach(() => { + vi.clearAllMocks(); + delete process.env.DASHBOARD_SSR_PREFETCH; +}); + +function queriesOf( + el: ReactElement, +): { queryHash: string; state: { data: unknown } }[] { + if (el.type !== HydrationBoundary) return []; + const props = el.props as { + state?: { queries: { queryHash: string; state: { data: unknown } }[] }; + }; + return props.state?.queries ?? []; +} + +describe("/mood RSC prefetch", () => { + it("dehydrates the first list page under the client's first-mount key", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({}); + listMoodEntriesPage.mockResolvedValue({ + entries: [], + meta: { total: 0, limit: MOOD_LIST_PAGE_SIZE, offset: 0 }, + }); + + const el = (await MoodPage()) as ReactElement; + const hashes = queriesOf(el).map((q) => q.queryHash); + expect(hashes).toContain(hashKey(FIRST_MOUNT_KEY)); + // The read runs with the client's first-mount slice parameters. + expect(listMoodEntriesPage).toHaveBeenCalledWith("u1", { + limit: MOOD_LIST_PAGE_SIZE, + offset: 0, + sortBy: "moodLoggedAt", + sortDir: "desc", + }); + }); + + it("JSON-round-trips the page body to the wire shape", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({}); + const loggedAt = new Date("2026-07-18T06:00:00.000Z"); + listMoodEntriesPage.mockResolvedValue({ + entries: [{ id: "m1", moodLoggedAt: loggedAt }], + meta: { total: 1, limit: MOOD_LIST_PAGE_SIZE, offset: 0 }, + }); + + const el = (await MoodPage()) as ReactElement; + const seeded = queriesOf(el).find( + (q) => q.queryHash === hashKey(FIRST_MOUNT_KEY), + ); + const data = seeded!.state.data as { + entries: { moodLoggedAt: unknown }[]; + }; + expect(data.entries[0]!.moodLoggedAt).toBe(loggedAt.toISOString()); + }); + + it("skips the prefetch when the mood module is explicitly off", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ mood: false }); + const el = (await MoodPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(listMoodEntriesPage).not.toHaveBeenCalled(); + }); + + it("fails soft to the bare client when the read throws", async () => { + getUnswitchedSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({}); + listMoodEntriesPage.mockRejectedValue(new Error("db blip")); + + const el = (await MoodPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + // Null also means "acting on somebody else's record" — `getUnswitchedSession` + // collapses both into the same answer, and the page treats them the same. + it("fails soft when there is no session", async () => { + getUnswitchedSession.mockResolvedValue(null); + const el = (await MoodPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(listMoodEntriesPage).not.toHaveBeenCalled(); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await MoodPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getUnswitchedSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx new file mode 100644 index 000000000..b5f593750 --- /dev/null +++ b/src/app/mood/page-client.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useState } from "react"; +import Link from "next/link"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useMounted } from "@/hooks/use-mounted"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { MoodForm } from "@/components/mood/mood-form"; +import { MoodList } from "@/components/mood/mood-list"; +import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/ui/page-header"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { ArrowRight, Plus, Wrench } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useTranslations } from "@/lib/i18n/context"; + +export default function MoodPageClient() { + const { user, isAuthenticated, isLoading } = useAuth(); + const { canManage, inSharedRecord } = useRecordCapabilities(); + const mounted = useMounted(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + // v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row + // into. The ref lives on the `` footer slot so the + // Sheet branch can sticky-pin Save / Cancel above the keyboard. + const [footerEl, setFooterEl] = useState(null); + const { t } = useTranslations(); + + // Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the + // per-user toggle AND the operator server-wide kill-switch). Default-on: an + // absent key reads as enabled, so a direct URL hit only bounces on an + // explicit `false`. Every `/api/mood-entries/*` route also enforces the gate + // server-side, so this is a UX redirect, not the security boundary. + const enabled = inSharedRecord || user?.modules?.mood !== false; + + // v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches + // whatever this page currently has mounted (`type: "active"` scopes the + // invalidation to visible queries). Suspended while the add-sheet is + // open so a drag inside the form can't arm the gesture. + const refreshVisible = useCallback( + () => queryClient.invalidateQueries({ type: "active" }), + [queryClient], + ); + const pull = usePullToRefresh({ + onRefresh: refreshVisible, + disabled: dialogOpen, + }); + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + // `!mounted` keeps the hydration render identical to the SSR HTML: the + // auth query is fired by the early-hydrating shell and can settle before + // this page boundary hydrates, so `isLoading` alone flipped the branch + // and React logged hydration error #418 — same fix as the measurements + // page; see `useMounted`. + if (!mounted || isLoading || (isAuthenticated && !enabled)) { + return ; + } + + return ( +
+ + {t("mood.title")}} + description={t("mood.subtitle")} + actions={ + <> + {/* v1.17 — the wrench is the one customize entry point: it + links to /settings/mood, which owns the tag groups, custom + tags, visibility, and picker order. Same glyph, slot (left + of the add button) and responsive 44-px mobile tap floor + as the medications header. + + v1.36.x — and gated like the other four wrenches. It was the + one of five left open, so a delegate tapped it straight into + the "not part of what was shared" panel: `/settings` is not a + shared-record destination and the shell answers before the + page does. */} + {canManage && ( + + )} + {/* v1.36.x — a mood entry is not one of the verbs a grant + admits, so it stays with the account whose record it is. A + delegate at any level gets no add path here. */} + {canManage && ( + + )} + + } + /> + + } + > + setDialogOpen(false)} + onCancel={() => setDialogOpen(false)} + footerSlot={footerEl} + /> + + + setDialogOpen(true)} /> + + {/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing + screeners, and the mood insights page form one mental-health + domain but used to be three unconnected islands. A quiet pointer + here (module-gated — no nav change) rather than a merge: capture + vs. screener stay two defensible surfaces, just cross-linked. */} + {user?.modules?.mentalHealth === true ? ( + + {t("mood.mentalWellbeingLink")} +
+ ); +} diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 8668a82f7..b4f257453 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -1,156 +1,100 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import Link from "next/link"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { useMounted } from "@/hooks/use-mounted"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { MoodForm } from "@/components/mood/mood-form"; -import { MoodList } from "@/components/mood/mood-list"; -import { Button } from "@/components/ui/button"; -import { PageHeader } from "@/components/ui/page-header"; -import { PageAuthGate } from "@/components/ui/page-auth-gate"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { ArrowRight, Plus, Wrench } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { listMoodEntriesPage } from "@/lib/mood/list-read"; +import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { queryKeys } from "@/lib/query-keys"; -export default function MoodPage() { - const { user, isAuthenticated, isLoading } = useAuth(); - const { canManage, inSharedRecord } = useRecordCapabilities(); - const mounted = useMounted(); - const router = useRouter(); - const queryClient = useQueryClient(); - const [dialogOpen, setDialogOpen] = useState(false); - // v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row - // into. The ref lives on the `` footer slot so the - // Sheet branch can sticky-pin Save / Cancel above the keyboard. - const [footerEl, setFooterEl] = useState(null); - const { t } = useTranslations(); +import MoodPageClient from "./page-client"; - // Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the - // per-user toggle AND the operator server-wide kill-switch). Default-on: an - // absent key reads as enabled, so a direct URL hit only bounces on an - // explicit `false`. Every `/api/mood-entries/*` route also enforces the gate - // server-side, so this is a UX redirect, not the security boundary. - const enabled = inSharedRecord || user?.modules?.mood !== false; - - // v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches - // whatever this page currently has mounted (`type: "active"` scopes the - // invalidation to visible queries). Suspended while the add-sheet is - // open so a drag inside the form can't arm the gesture. - const refreshVisible = useCallback( - () => queryClient.invalidateQueries({ type: "active" }), - [queryClient], - ); - const pull = usePullToRefresh({ - onRefresh: refreshVisible, - disabled: dialogOpen, - }); - - useEffect(() => { - if (isLoading) return; - if (!isAuthenticated) { - router.push("/auth/login"); - } else if (!enabled) { - router.push("/"); - } - }, [isLoading, isAuthenticated, enabled, router]); - - // `!mounted` keeps the hydration render identical to the SSR HTML: the - // auth query is fired by the early-hydrating shell and can settle before - // this page boundary hydrates, so `isLoading` alone flipped the branch - // and React logged hydration error #418 — same fix as the measurements - // page; see `useMounted`. - if (!mounted || isLoading || (isAuthenticated && !enabled)) { - return ; +/** + * Thin RSC wrapper around the (client) mood page. + * + * The page's primary content — the mood management list — waited for the + * client `queryKeys.moodEntriesList(…)` cell to fetch the first page after + * hydrate, so the first paint flashed a skeleton before the entries filled + * in. This wrapper runs the SAME read the API route uses + * (`listMoodEntriesPage`, shared so filter/order/wire shape cannot drift) + * with the list's first-mount defaults during SSR and hands it to TanStack + * through `HydrationBoundary`. + * + * Contract notes (the v1.30.9 dashboard template): + * - THE CRUX — the list key is parameterised. The seeded tuple spells the + * client's first-mount state exactly: no filters, page 1, the default + * `moodLoggedAt desc` sort, and `MOOD_LIST_PAGE_SIZE` rows (the shared + * constant the client's `queryFn` also uses). Any other page/filter state + * keys a different slot and fetches client-side as before. + * - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is + * exactly what the client `queryFn` produces from the wire (ISO date + * strings, decrypted note as plain `note`), never a Date-carrying sibling + * that poisons the cell. + * - Module-gate parity: the client page redirects when `modules.mood` is + * explicitly `false`; skip the prefetch then (the client never mounts the + * list). + * - Record identity: the session comes from `getUnswitchedSession()`, which + * answers null while the browser is acting on somebody else's record — + * the prefetch must never serialise the delegate's own entries into a + * page opened on the owner's record. + * - Fail-soft: no session, a module lookup hiccup, or a read hiccup renders + * the page exactly as before this wrapper existed — the client cell owns + * the fetch. + */ +export default async function MoodPage() { + // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e + // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which + // only see CLIENT fetches — keep governing what every prefetched page paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; } + let dehydratedState = null; + try { + const session = await getUnswitchedSession(); + if (session) { + const { user } = session; + const modules = await resolveModuleMap(user.id); + // Mirror the client gate (`user?.modules?.mood !== false`): an absent + // key reads as enabled; only an explicit `false` disables. + if (modules.mood !== false) { + const body = await listMoodEntriesPage(user.id, { + limit: MOOD_LIST_PAGE_SIZE, + offset: 0, + sortBy: "moodLoggedAt", + sortDir: "desc", + }); + const queryClient = new QueryClient(); + // Match the client cell's wire shape exactly (JSON semantics, ISO + // date strings) — same-key-different-shape is silent cache poison. + queryClient.setQueryData( + queryKeys.moodEntriesList({ + mood: undefined, + source: undefined, + from: undefined, + to: undefined, + page: 1, + sortBy: "moodLoggedAt", + sortDir: "desc", + }), + JSON.parse(JSON.stringify(body)), + ); + dehydratedState = dehydrate(queryClient); + } + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + + if (dehydratedState === null) { + return ; + } return ( -
- - {t("mood.title")}} - description={t("mood.subtitle")} - actions={ - <> - {/* v1.17 — the wrench is the one customize entry point: it - links to /settings/mood, which owns the tag groups, custom - tags, visibility, and picker order. Same glyph, slot (left - of the add button) and responsive 44-px mobile tap floor - as the medications header. - - v1.36.x — and gated like the other four wrenches. It was the - one of five left open, so a delegate tapped it straight into - the "not part of what was shared" panel: `/settings` is not a - shared-record destination and the shell answers before the - page does. */} - {canManage && ( - - )} - {/* v1.36.x — a mood entry is not one of the verbs a grant - admits, so it stays with the account whose record it is. A - delegate at any level gets no add path here. */} - {canManage && ( - - )} - - } - /> - - } - > - setDialogOpen(false)} - onCancel={() => setDialogOpen(false)} - footerSlot={footerEl} - /> - - - setDialogOpen(true)} /> - - {/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing - screeners, and the mood insights page form one mental-health - domain but used to be three unconnected islands. A quiet pointer - here (module-gated — no nav change) rather than a merge: capture - vs. screener stay two defensible surfaces, just cross-linked. */} - {user?.modules?.mentalHealth === true ? ( - - {t("mood.mentalWellbeingLink")} -
+ + + ); } diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx index a9533aab7..af764bb5c 100644 --- a/src/components/charts/__tests__/chart-error-state.test.tsx +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -29,6 +29,9 @@ vi.mock("@/hooks/use-auth", () => ({ function mockQueryError() { vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data: undefined, isLoading: false, diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx index 8a02ddda0..d98a1eab5 100644 --- a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -39,6 +39,9 @@ describe(" — accessibility label", () => { const data = buildData(); vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-comparison.test.tsx b/src/components/charts/__tests__/health-chart-comparison.test.tsx index 052e584c7..e1c34d431 100644 --- a/src/components/charts/__tests__/health-chart-comparison.test.tsx +++ b/src/components/charts/__tests__/health-chart-comparison.test.tsx @@ -30,6 +30,9 @@ const sample30Days = vi.hoisted(() => { }); vi.mock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data: sample30Days.map((row) => { const ts = Date.parse(row.measuredAt); diff --git a/src/components/charts/__tests__/health-chart-data-table.test.tsx b/src/components/charts/__tests__/health-chart-data-table.test.tsx index 794b5b962..68c3b3d2d 100644 --- a/src/components/charts/__tests__/health-chart-data-table.test.tsx +++ b/src/components/charts/__tests__/health-chart-data-table.test.tsx @@ -30,6 +30,9 @@ async function renderChart( extraProps: Record = {}, ) { vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx index 45005b146..cbffd9eb9 100644 --- a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx @@ -38,6 +38,9 @@ vi.mock("@/hooks/use-auth", () => ({ async function renderChart(data: unknown[]): Promise { vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-heading-level.test.tsx b/src/components/charts/__tests__/health-chart-heading-level.test.tsx index bcd67184d..5343a6b0a 100644 --- a/src/components/charts/__tests__/health-chart-heading-level.test.tsx +++ b/src/components/charts/__tests__/health-chart-heading-level.test.tsx @@ -50,6 +50,9 @@ describe(" — card heading level", () => { const data = buildData(); vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-mini.test.tsx b/src/components/charts/__tests__/health-chart-mini.test.tsx index 9825e2d83..fa8e3a027 100644 --- a/src/components/charts/__tests__/health-chart-mini.test.tsx +++ b/src/components/charts/__tests__/health-chart-mini.test.tsx @@ -22,6 +22,9 @@ const sampleData = vi.hoisted(() => { }); vi.mock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data: sampleData, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx index 2226282d6..c0af0a7d3 100644 --- a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx @@ -39,6 +39,9 @@ describe(" — empty-window state (W11-M6)", () => { const data = buildData(); vi.doMock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data, isLoading: false }), useQueryClient: () => ({ cancelQueries: () => Promise.resolve(), diff --git a/src/components/charts/__tests__/health-chart-polish.test.tsx b/src/components/charts/__tests__/health-chart-polish.test.tsx index 9d487d263..2dc052ed8 100644 --- a/src/components/charts/__tests__/health-chart-polish.test.tsx +++ b/src/components/charts/__tests__/health-chart-polish.test.tsx @@ -41,6 +41,9 @@ const sampleSeries = vi.hoisted(() => { }); vi.mock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: () => ({ data: sampleSeries.map((row) => { const ts = Date.parse(row.measuredAt); diff --git a/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx index b763bd522..d207c76fd 100644 --- a/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx +++ b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx @@ -22,6 +22,9 @@ import { renderToStaticMarkup } from "react-dom/server"; const fetchCalls: string[] = []; vi.mock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: ({ queryFn, queryKey, diff --git a/src/components/charts/__tests__/health-chart-range.test.tsx b/src/components/charts/__tests__/health-chart-range.test.tsx index f795238b0..9cbed264c 100644 --- a/src/components/charts/__tests__/health-chart-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-range.test.tsx @@ -15,6 +15,9 @@ import { renderToStaticMarkup } from "react-dom/server"; const fetchCalls: string[] = []; vi.mock("@tanstack/react-query", () => ({ + // `health-chart` imports `keepPreviousData` for its placeholder option; + // the identity stand-in keeps the module-level destructure satisfied. + keepPreviousData: (previous: unknown) => previous, useQuery: ({ queryFn, queryKey, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2e68cfcfb..36100c9e9 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; @@ -772,7 +772,13 @@ export function HealthChart({ ); }, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({ + // A range-tab change re-keys the query; without placeholder data every + // switch dropped the painted chart to a skeleton for the fetch round-trip. + // Keeping the previous range's series visible is only honest because the + // render below pairs it with a visual dimming while `isPlaceholderData` + // is true — the old range must never masquerade as the new one. + placeholderData: keepPreviousData, // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData` // so the `["chart-data"]` prefix lands in `measurementDependentKeys` // and a fresh measurement evicts every per-chart daily cache in one @@ -1867,7 +1873,16 @@ export function HealthChart({ /> ) : ( <> -
+
{visibleBands.length > 0 ? ( // v1.4.27 R3d MB2 — band overlay positioning fix. The // overlay used to inset `right: 18px` while the chart diff --git a/src/components/gamification/achievement-unlock-notifier.tsx b/src/components/gamification/achievement-unlock-notifier.tsx index e138c97a3..da352a3e4 100644 --- a/src/components/gamification/achievement-unlock-notifier.tsx +++ b/src/components/gamification/achievement-unlock-notifier.tsx @@ -80,7 +80,12 @@ export function AchievementUnlockNotifier({ // to re-render on the same cadence. const { data } = useAchievementsQuery({ enabled: !!user && achievementsEnabled, - refetchInterval: 2 * 60 * 1000, + // 5 min: every in-app write already invalidates the achievements cell + // (the `*DependentKeys` bundles all carry it), so this poll exists ONLY + // to surface an unlock earned by out-of-band ingest (an iOS batch, a + // Withings sync). A toast for those arriving up to five minutes later is + // an honest trade against an app-global 2-minute poll. + refetchInterval: 5 * 60 * 1000, }); useEffect(() => { diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 4bd5dd642..71858888b 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -11,7 +11,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { + illnessDependentKeys, + invalidateKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import type { IllnessCorrelationResponse, @@ -119,7 +124,14 @@ export function useIllnessDayLogList( function useInvalidateIllness() { const qc = useQueryClient(); - return () => qc.invalidateQueries({ queryKey: queryKeys.illness() }); + // An episode write can flip Rest Mode, which frames the dashboard hero and + // the Today digest — both unmounted from here, so the bundle invalidation + // is paired with the forced inactive refetch. The write routes hard-evict + // the server analytics bucket, so the refetch returns post-write context. + return async () => { + await invalidateKeys(qc, illnessDependentKeys); + await refetchInactiveDailyReads(qc); + }; } /** Create an episode. */ diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx index 0d3db1a7e..5b4566086 100644 --- a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx @@ -19,6 +19,9 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: undefined }), useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + // The remember-message control resolves the client for its about-me + // invalidation; the marker tests never trigger it. + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { MessageThread } from "../message-thread"; diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 7f9427eb0..94498638a 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -52,6 +52,9 @@ vi.mock("@tanstack/react-query", () => ({ isPending: false, isError: false, }), + // The remember-message control resolves the client for its about-me + // invalidation; these render tests never trigger it. + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { diff --git a/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx b/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx index 660c7fba0..6a254a551 100644 --- a/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx +++ b/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx @@ -5,7 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { CoachPlanDTO } from "@/hooks/use-coach-plans"; -import { PlanProposalCards } from "../plan-proposal-card"; +import { + PlanProposalCards, + PROPOSAL_POLL_MS, + PROPOSAL_POLL_MAX_ATTEMPTS, + nextProposalPollInterval, +} from "../plan-proposal-card"; /** * The plan-proposal confirm cards at the thread tail. SSR string render @@ -80,3 +85,21 @@ describe("", () => { expect(html).toContain("after dinner"); }); }); + +describe("nextProposalPollInterval", () => { + it("polls at the slow interval below the attempt ceiling", () => { + expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS); + expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe( + PROPOSAL_POLL_MS, + ); + }); + + it("stops polling once the attempt ceiling is reached", () => { + // Watched red: asserting an interval at the cap fails — the ceiling is + // live, not decorative. + expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false); + expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe( + false, + ); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 475c537e9..5651c244c 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useId, useRef, useState } from "react"; import Link from "next/link"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BookmarkPlus, @@ -24,6 +24,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { @@ -944,6 +945,7 @@ ChatBubble.displayName = "ChatBubble"; */ function RememberUserMessage({ content }: { content: string }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); // On settle the button unmounts while it holds focus, which would // drop keyboard focus to . The confirmation paragraph takes @@ -961,6 +963,13 @@ function RememberUserMessage({ content }: { content: string }) { }); }, onSuccess: (data) => { + if (data.adopted) { + // The adoption changed the stored self-context — refresh the + // Settings → AI about-me read so it shows the new entry. + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMe(), + }); + } setSettled(data.adopted ? "adopted" : "duplicate"); }, onError: () => { diff --git a/src/components/insights/coach-panel/plan-proposal-card.tsx b/src/components/insights/coach-panel/plan-proposal-card.tsx index b30f4c312..9341f9fa9 100644 --- a/src/components/insights/coach-panel/plan-proposal-card.tsx +++ b/src/components/insights/coach-panel/plan-proposal-card.tsx @@ -33,7 +33,30 @@ import { */ /** Slow poll (ms) — proposals arrive via the post-turn background worker. */ -const PROPOSAL_POLL_MS = 20_000; +export const PROPOSAL_POLL_MS = 20_000; + +/** + * Attempt ceiling for the proposal poll. The extractor runs right after the + * turn and lands its proposal within a couple of minutes; 15 polls (~5 min + * of coverage) catch it with margin, and every settled response counts an + * attempt. Without the cap an open thread polled the proposed list every + * 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval` + * (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and + * the next mount of a fresh conversation restarts the budget. + */ +export const PROPOSAL_POLL_MAX_ATTEMPTS = 15; + +/** + * Decide whether the proposal query schedules its next poll. Pure so the + * ceiling is unit-testable: returns the slow interval until the attempt cap + * is reached, then `false` (poll off). + */ +export function nextProposalPollInterval( + dataUpdateCount: number, +): number | false { + if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false; + return PROPOSAL_POLL_MS; +} type SettledOutcome = "accepted" | "declined"; @@ -49,7 +72,7 @@ export function PlanProposalCards({ const query = useCoachPlans({ filter: { status: "proposed" }, - refetchInterval: PROPOSAL_POLL_MS, + refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount), }); // Quiet on error by design: the thread is the Coach's surface, and a failed diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index e182e4941..e0fb697cd 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BellPlus, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import type { CoachSuggestion } from "@/lib/ai/coach/types"; /** @@ -31,6 +32,7 @@ export function ReminderSuggestionCard({ suggestion: CoachSuggestion; }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState< "accepted" | "dismissed" | "stopped" | null >(null); @@ -44,6 +46,12 @@ export function ReminderSuggestionCard({ return action; }, onSuccess: (action) => { + if (action === "accept") { + // The accept minted a `MeasurementReminder` — refresh the checkups + // list and the Today rail so it does not stay invisible until the + // next poll. + void invalidateReminderReads(queryClient); + } setSettled( action === "accept" ? "accepted" diff --git a/src/components/insights/coach-panel/self-context-adopt-offer.tsx b/src/components/insights/coach-panel/self-context-adopt-offer.tsx index 753f5ea64..c5f0aa7fb 100644 --- a/src/components/insights/coach-panel/self-context-adopt-offer.tsx +++ b/src/components/insights/coach-panel/self-context-adopt-offer.tsx @@ -14,12 +14,13 @@ * vertical cost once settled. */ import { useEffect, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { BookmarkPlus, Check, Loader2, X } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; export interface SelfContextAdoptOfferProps { /** The clarifying question the user just answered. */ @@ -47,6 +48,7 @@ export function SelfContextAdoptOffer({ onSettled, }: SelfContextAdoptOfferProps) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); const adopt = useMutation({ @@ -57,6 +59,13 @@ export function SelfContextAdoptOffer({ }); }, onSuccess: (data) => { + if (data.adopted) { + // The adoption changed the stored self-context — refresh the + // Settings → AI about-me read so it shows the new entry. + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMe(), + }); + } setSettled(data.adopted ? "adopted" : "duplicate"); onSettled?.(data.adopted ? "adopted" : "duplicate"); }, diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index bba60b15f..304d6e7d4 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -1,13 +1,15 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Check, Loader2, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; /** @@ -33,13 +35,24 @@ export function SuggestedActionCard({ action: CoachSuggestedAction; }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null); const apply = useMutation({ mutationFn: async () => { await apiPost("/api/coach/suggested-actions", action.params); }, - onSuccess: () => setSettled("applied"), + onSuccess: () => { + // Both arms mint a reminder row the checkups list and the Today rail + // read; the note arm additionally lands on the coach-reminders list. + void invalidateReminderReads(queryClient); + if (action.params.actionType === "reminder.note") { + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachRemindersAll(), + }); + } + setSettled("applied"); + }, onError: () => toast.error(t("coach.suggestedAction.failed")), }); diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index abbe508d0..ea9e079e3 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -32,7 +32,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments"; import { PageHeader } from "@/components/ui/page-header"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -93,6 +93,15 @@ export function MentalWellbeing() { void queryClient.invalidateQueries({ queryKey: queryKeys.mentalHealthAssessments(), }); + // A completed screening can satisfy a due checkup reminder, so the + // checkups list and the Today rail must repaint. Honest constraint: + // the server satisfy runs async via pg-boss, so this invalidation + // narrows the stale window but cannot close it — a refetch racing + // the job may still see the pre-satisfy row until the next tick. + void queryClient.invalidateQueries({ + queryKey: queryKeys.measurementReminders(), + }); + void refetchInactiveDailyReads(queryClient); }, }); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 6ced5006f..af85175cb 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -105,6 +105,7 @@ import { } from "./mood-context-fields"; import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size"; import { localizedApiError } from "@/lib/api/localized-error"; // Re-export the score map under the legacy local name to keep the @@ -140,8 +141,6 @@ interface MoodEntry { moodLoggedAt: string; } -const PAGE_SIZE = 25; - const MOOD_LEVELS_LIST = [ "SUPER_GUT", "GUT", diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index f304f6bab..d7d0f8c8a 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -70,7 +70,7 @@ import { withBaseToken, } from "@/lib/api/optimistic-token"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { buildScoreConfigRows, @@ -210,12 +210,12 @@ export function ScoreSection() { queryKey: queryKeys.healthScoreConfig(), }); // The number now means something else. Every surface that holds a - // computed score has to go, or the change reads as broken. + // computed score has to go, or the change reads as broken. The daily + // reads are unmounted on the Settings page and run with + // `refetchOnMount: false`, so a plain invalidation marks them stale + // without ever refetching — force the inactive refetch instead. void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); - void queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - }); - void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void refetchInactiveDailyReads(queryClient); toastWrittenOutcome("success", t("settings.sections.score.saved")); }, onError: (err) => { diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 926719653..c5cef3cc4 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -48,7 +48,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { DEFAULT_DEVICE_TYPE_PRIORITY, DEFAULT_SOURCE_PRIORITY, @@ -160,10 +160,15 @@ export function SourcesSection() { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); - // The analytics aggregator folds source priority into per-day - // canonical-row picks — flush its cache so the charts re-paint - // with the new picker on the next mount. + // The analytics aggregator, the per-chart daily aggregates, the + // measurement list and the dashboard snapshot all fold source + // priority into their per-day canonical-row picks — flush them so + // every chart and tile re-paints with the new picker, not only the + // analytics report. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); + void refetchInactiveDailyReads(queryClient); setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, @@ -179,7 +184,12 @@ export function SourcesSection() { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); + // Same fan-out as the save arm: the defaults change the canonical-row + // picks everywhere the overrides did. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); + void refetchInactiveDailyReads(queryClient); setDraft(null); toast.success(t("settings.sections.sources.resetSuccess")); }, diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 3df0feeed..29a9689f1 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { SettingsInfoTile } from "./_info-tile"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -95,6 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { // Every chart/band depends on these thresholds — invalidate everything. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // The dashboard bands + score context read the same thresholds through + // the daily reads, which are unmounted on Settings — force the refetch. + void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.saveSuccess")); }, onError: (err) => @@ -115,6 +118,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // Same daily-reads truth as the save arm above. + void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, onError: () => toast.error(t("thresholds.saveError")), diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 0774aad98..c69acb2ce 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -11,7 +11,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -259,6 +259,9 @@ function TargetEditSheetBody({ queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // The dashboard bands + score context read the same thresholds through + // the daily reads, which are unmounted here — force their refetch. + void refetchInactiveDailyReads(queryClient); toast.success(t("targets.edit.saveSuccess")); onOpenChange(false); }, @@ -285,6 +288,8 @@ function TargetEditSheetBody({ queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // Same daily-reads truth as the save arm above. + void refetchInactiveDailyReads(queryClient); toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); }, diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 947a97536..1f1065bd6 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -19,6 +19,7 @@ import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { invalidateKeys, queryKeys, + refetchInactiveDailyReads, vaccinationDependentKeys, } from "@/lib/query-keys"; import type { @@ -154,7 +155,14 @@ export function useLinkDocumentToVaccination() { targetKind: "document", targetIds: [documentId], }), - onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys), + onSuccess: () => + Promise.all([ + invalidateKeys(qc, vaccinationDependentKeys), + // The linked-document badge can surface on the daily reads' checkup + // items; force the unmounted snapshot/digest to refetch like the + // sibling mutations do (via `invalidateReminderReads`). + refetchInactiveDailyReads(qc), + ]), }); } diff --git a/src/hooks/use-coach-plans.ts b/src/hooks/use-coach-plans.ts index b9f77cc43..3c4f38158 100644 --- a/src/hooks/use-coach-plans.ts +++ b/src/hooks/use-coach-plans.ts @@ -14,7 +14,12 @@ * prose editor by design: the PATCH contract never accepts the encrypted * free-text fields. */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type UseQueryOptions, +} from "@tanstack/react-query"; import { queryKeys } from "@/lib/query-keys"; import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch"; @@ -63,12 +68,14 @@ export function useCoachPlans(opts?: { filter?: PlanFilter; enabled?: boolean; /** - * Optional poll interval (ms). The chat thread uses a slow poll because - * proposals land asynchronously (the memory-refresh worker runs after the - * turn); the management page reads once. TanStack pauses the interval in - * background tabs by default, so the poll never runs unwatched. + * Optional poll interval (ms), or TanStack's interval callback so a caller + * can bound its poll (the chat thread caps attempts — see + * `nextProposalPollInterval` in `plan-proposal-card.tsx`). Proposals land + * asynchronously (the memory-refresh worker runs after the turn); the + * management page reads once. TanStack pauses the interval in background + * tabs by default, so the poll never runs unwatched. */ - refetchInterval?: number; + refetchInterval?: UseQueryOptions["refetchInterval"]; }) { const filter = opts?.filter; return useQuery({ diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index a70944b13..0b66f26cc 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -15,6 +15,7 @@ import { encounterDependentKeys, invalidateKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import type { PractitionerDTO } from "@/lib/practitioners/dto"; @@ -45,7 +46,14 @@ export function usePractitioners(q?: string, enabled = true) { export function usePractitionerMutations() { const qc = useQueryClient(); - const invalidate = () => invalidateKeys(qc, encounterDependentKeys); + // Renaming a practice changes the label a rail appointment renders under, + // and the daily reads are unmounted from the address book — pair the + // bundle invalidation with the forced inactive refetch. + const invalidate = () => + Promise.all([ + invalidateKeys(qc, encounterDependentKeys), + refetchInactiveDailyReads(qc), + ]); const create = useMutation({ mutationKey: queryKeys.practitionerCreate(), diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index b75193c13..e770551f3 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -271,6 +271,19 @@ export function invalidateUserMedicationListLayout(userId: string): void { caches.medicationListLayout.deleteByPrefix(userId); } +/** + * Invalidate the analytics bucket after an illness / cycle / encounter write. + * `resolveRestMode` (active-episode context) feeds both the analytics report + * and the dashboard snapshot, and the daily-digest extras cell lives under the + * same bucket — all keyed `${userId}|…`. Hard-evict, mirroring the mood-write + * posture: these are interactive single-entry writes, and a marked-stale SWR + * cell would hand the user back the pre-write rest-mode / appointment context + * on the very next read. + */ +export function invalidateUserHealthContext(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); +} + /** Invalidate surfaces whose server decision includes pattern dismissal. */ export function invalidateUserCorrelationPatterns(userId: string): void { caches.analytics.deleteByPrefix(`${userId}|`); diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index bb692496e..9195f7bcb 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -1,5 +1,6 @@ /** Fitbit sync orchestration and bounded cohort polling. */ import pLimit from "p-limit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { isReauthRequired, recordSyncSuccess } from "@/lib/integrations/status"; @@ -161,6 +162,14 @@ export async function syncUserFitbit( await recordSyncSuccess(userId, "fitbit"); } + // Background-sync posture: mark the analytics cells stale (never hard-evict + // from a poll) and sweep the workouts cache so the imported rows reach the + // cached readers. Fires on a partial failure too — rows that DID land must + // not stay invisible for the rest of the TTL window. + if (total > 0) { + invalidateUserMeasurements(userId); + } + annotate({ action: { name: "fitbit.sync", details: { imported: total, failed } }, }); diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index 830ab4ebc..429463e18 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -124,6 +125,12 @@ export async function runWhoopResourceSync( // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { + // Background-sync posture: mark the analytics cells stale (never + // hard-evict from a poll) and sweep the workouts cache so the + // imported rows reach the cached readers. This driver is the only + // sync tail the webhook / per-resource cron paths pass through — + // `syncUserWhoop` covers the manual + backfill paths itself. + invalidateUserMeasurements(userId); fireAndForget(enqueueReminderSatisfy(userId), { action: "reminder.satisfy.enqueue", }); diff --git a/src/lib/measurement-reminders/list-read.ts b/src/lib/measurement-reminders/list-read.ts new file mode 100644 index 000000000..15d9543e1 --- /dev/null +++ b/src/lib/measurement-reminders/list-read.ts @@ -0,0 +1,32 @@ +/** + * The one Vorsorge-reminder list read, shared by `GET + * /api/measurement-reminders` and the `/checkups` RSC prefetch wrapper so the + * two cannot drift in filter, order, or DTO shape — the SSR-seeded cell must + * be byte-compatible with what the client `queryFn` gets from the wire. + */ +import { prisma } from "@/lib/db"; +import { + toMeasurementReminderDto, + type MeasurementReminderDtoShape, +} from "@/lib/measurement-reminders/dto"; + +export async function listMeasurementReminders( + userId: string, +): Promise { + const reminders = await prisma.measurementReminder.findMany({ + // A booked visit's one-shot reminder rides this same engine with + // `origin: ENCOUNTER`. It is not a checkup and must not appear on a + // Vorsorge surface, or the list fills with appointments. Four read + // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` + // proves every one of them, and the DTO mapper refuses such a row + // outright so a site that lost its filter fails loudly. + where: { userId, deletedAt: null, origin: { not: "ENCOUNTER" } }, + // Most-urgent first; a null next-due (uncomputable / disabled) sinks + // to the end so the actionable items float to the top. + orderBy: [ + { nextDueAt: { sort: "asc", nulls: "last" } }, + { createdAt: "asc" }, + ], + }); + return reminders.map(toMeasurementReminderDto); +} diff --git a/src/lib/mood/list-page-size.ts b/src/lib/mood/list-page-size.ts new file mode 100644 index 000000000..9bfafd783 --- /dev/null +++ b/src/lib/mood/list-page-size.ts @@ -0,0 +1,9 @@ +/** + * Page size of the mood management list — shared by the client list query + * (`components/mood/mood-list.tsx`) and the `/mood` RSC prefetch wrapper, so + * the server-seeded first page carries exactly the slice the client's + * first-mount `queryFn` would fetch. Deliberately dependency-free: the client + * bundle must be able to import it, so it cannot live in the (server-only) + * list read next to the query it parameterises. + */ +export const MOOD_LIST_PAGE_SIZE = 25; diff --git a/src/lib/mood/list-read.ts b/src/lib/mood/list-read.ts new file mode 100644 index 000000000..89dd947b3 --- /dev/null +++ b/src/lib/mood/list-read.ts @@ -0,0 +1,112 @@ +/** + * The one mood-entry list read, shared by `GET /api/mood-entries` and the + * `/mood` RSC prefetch wrapper so the two cannot drift in filter, order, or + * wire shape — the SSR-seeded cell must be byte-compatible with what the + * client `queryFn` gets from the wire. Ciphertext never leaves this module: + * the note is decrypted onto `note` and the context rides `contextForWire`. + */ +import { prisma } from "@/lib/db"; +import { shapeMoodNote } from "@/lib/crypto/note-cipher"; +import { shapeLevelA } from "@/lib/mood/level-a"; +import { contextForWire } from "@/lib/mood/context"; + +export interface MoodEntryListParams { + mood?: string; + source?: string; + from?: string; + to?: string; + limit: number; + offset: number; + sortBy: string; + sortDir: "asc" | "desc"; +} + +function parseTags(tags: string | null): string[] { + if (!tags) return []; + try { + return JSON.parse(tags) as string[]; + } catch { + return []; + } +} + +export async function listMoodEntriesPage( + userId: string, + params: MoodEntryListParams, +) { + const { mood, source, from, to, limit, offset, sortBy, sortDir } = params; + + const where = { + userId, + // v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list. + deletedAt: null, + ...(mood && { mood }), + // v1.15.13 — management-list source filter. + ...(source && { source }), + ...(from || to + ? { + date: { + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + } + : {}), + }; + + const [entries, total] = await Promise.all([ + prisma.moodEntry.findMany({ + where, + orderBy: { [sortBy]: sortDir }, + take: limit, + skip: offset, + // v1.8.5 — include the structured-tag link keys so the edit form + // can pre-populate the taxonomy picker. + // v1.12.0 — also carry the per-link `rating` + the tag `kind` so + // the client can split binary tags from rated factors on hydrate. + include: { + tagLinks: { + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, + }, + // v1.38 — the day context, so the edit dialog can pre-populate the + // sections without a second round trip per row. Sparse by design: most + // rows carry none and the join costs nothing on those. + context: true, + }, + }), + prisma.moodEntry.count({ where }), + ]); + + const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({ + // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. + // v1.37 — the five level-A values under the keys the create path takes, + // so the edit form reads back what it wrote instead of mapping column + // names to wire names by hand. The column spelling is stripped, not + // joined: one number under two names is a choice nobody made. + ...shapeLevelA(shapeMoodNote(e)), + tags: parseTags(e.tags), + // v1.8.5 — flat list of binary structured-tag keys attached to the + // entry (rated factors are surfaced separately below). + tagKeys: tagLinks + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + // v1.12.0 — rated factors with their per-entry score, so the edit + // form re-renders the sliders without a refetch. + ratedFactors: tagLinks + .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), + // v1.38 — the day context, or null. The ciphertext never leaves the + // server: `contextForWire` reads the note and drops the column. + context: context ? contextForWire(context) : null, + })); + + return { + entries: entriesWithParsedTags, + meta: { total, limit, offset }, + }; +} diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index f92104550..3af82a725 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -40,6 +40,7 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { fetchSgvEntries, @@ -185,6 +186,14 @@ export async function syncUserNightscout( entries, ); + // Background-sync posture: mark the analytics cells stale (never hard-evict + // from a poll) so the inserted rows reach the cached readers. Fires on a + // partial failure too — rows that DID land must not stay invisible for the + // rest of the TTL window. + if (inserted > 0) { + invalidateUserMeasurements(userId); + } + if (failedRows > 0) { // Partial write failure: keep the tick honest. `transient` because the // ~48 h re-fetch window self-heals a transient DB hiccup, while a diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index aa11c88f5..8fb9bae01 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -51,6 +51,7 @@ import { emitInsertedMeasurementArrivals, type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -498,6 +499,14 @@ export async function syncUserOura( () => {}, ); + // Background-sync posture: mark the analytics cells stale (never hard-evict + // from a poll) so the imported rows reach the cached readers. Fires on a + // partial failure too — rows that DID land must not stay invisible for the + // rest of the TTL window. + if (imported > 0) { + invalidateUserMeasurements(userId); + } + if (result.failures.length > 0) { // Partial failure: keep the cycle honest. Record the failure and do NOT // stamp success, so the freshness surface reflects that some collections diff --git a/src/lib/polar/sync-workouts.ts b/src/lib/polar/sync-workouts.ts index 7084ae511..5b2df23bc 100644 --- a/src/lib/polar/sync-workouts.ts +++ b/src/lib/polar/sync-workouts.ts @@ -30,6 +30,7 @@ */ import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { annotate, getEvent } from "@/lib/logging/context"; import { markSyncFailureRecorded, @@ -102,6 +103,13 @@ export async function syncUserPolarWorkouts(userId: string): Promise { throw markSyncFailureRecorded(err); } + // Sweep the workouts cache (and mark the analytics cells stale — workouts + // feed achievements + analytics) so the upserted rows reach the cached + // readers, matching the `/api/workouts/batch` and Google Health writers. + if (imported > 0) { + invalidateUserMeasurements(userId); + } + return imported; } diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index 8b44200fe..f249f3288 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -50,6 +50,7 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -276,6 +277,14 @@ export async function syncUserPolar(userId: string): Promise { () => {}, ); + // Background-sync posture: mark the analytics cells stale (never hard-evict + // from a poll) so the imported rows reach the cached readers. Fires on a + // partial failure too — rows that DID land must not stay invisible for the + // rest of the TTL window. + if (imported > 0) { + invalidateUserMeasurements(userId); + } + if (failures.length > 0) { // Partial failure: import what the healthy collections returned, but keep // the cycle honest — record ONE failure and do NOT stamp success, so the diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 4346959ac..dbec8a3e2 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -240,6 +240,22 @@ export const medicationDependentKeys = [ */ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; +/** + * Keys invalidated when an illness episode starts, ends, is edited, deleted, + * restored, or a day-log lands. Rest Mode derives from the active-episode set + * and both daily reads annotate from it (the hero band's "unwell since" frame, + * the digest's paused nudges), so an episode write must reach the snapshot and + * digest keys, not only the `["illness"]` tree. Pair every call site with + * `refetchInactiveDailyReads` — the daily reads are typically unmounted while + * the user is on the illness surface, and a bare invalidation would mark them + * stale without refetching (the recurring v1.16.11/v1.29.1/v1.32.19 class). + */ +export const illnessDependentKeys = [ + queryKeys.illness(), + queryKeys.dashboardSnapshot(), + queryKeys.dailyDigest(), +]; + /** * Keys invalidated when a visit or an address-book entry changes. * @@ -257,6 +273,12 @@ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; export const encounterDependentKeys = [ queryKeys.encounters(), queryKeys.practitioners(), + // A booked / rescheduled / removed appointment is a row the Today digest + // surfaces, so it must appear on the rail in the same tick as the visit + // list. Pair call sites with `refetchInactiveDailyReads` (or the + // `invalidateReminderReads` helper, which carries it) — the digest is + // typically unmounted while the user is on the visits surface. + queryKeys.dailyDigest(), ]; /** diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 767df552d..d8fd1ff32 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -72,6 +72,14 @@ export const measurementKeys = { * fetch window; the factory packs those into a single tuple to keep * the cache layout byte-identical with the pre-v1.4.40 layout. */ + /** + * Prefix key — invalidates every `chartData(…)` / `chartSeriesBatch(…)` + * slot at once via TanStack's hierarchical-prefix semantics. For call + * sites outside the factory that need the whole per-chart cache flushed + * (a source-priority reorder changes every chart's canonical-row picks). + */ + chartDataRoot: () => ["chart-data"] as const, + chartData: ( types: string, valueMode: string, diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts index 346850464..38e5600ba 100644 --- a/src/lib/strava/sync.ts +++ b/src/lib/strava/sync.ts @@ -25,6 +25,7 @@ */ import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { annotate, getEvent } from "@/lib/logging/context"; import { markSyncFailureRecorded, @@ -244,6 +245,14 @@ export async function syncUserStrava( } await recordSyncSuccess(userId, "strava"); + + // Background-sync posture: sweep the workouts cache and mark the analytics + // cells stale (workouts feed achievements + analytics) so the upserted rows + // reach the cached readers, matching the `/api/workouts/batch` writer. + if (imported > 0) { + invalidateUserMeasurements(userId); + } + annotate({ action: { name: "strava.sync.complete", details: { imported } }, }); diff --git a/src/lib/whoop/sync.ts b/src/lib/whoop/sync.ts index 06855227c..d71734513 100644 --- a/src/lib/whoop/sync.ts +++ b/src/lib/whoop/sync.ts @@ -5,6 +5,7 @@ * `sync-core.ts`. Resource leaves depend only on that core; this parent owns * the static leaf graph and the full per-user sequencing policy. */ +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { getEvent } from "@/lib/logging/context"; import { isReauthRequired, @@ -99,5 +100,15 @@ export async function syncUserWhoop( if (!anyFailed && !allSoftSkipped) { await recordSyncSuccess(userId, "whoop"); } + + // Background-sync posture: mark the analytics cells stale (never hard-evict + // from a poll) and sweep the workouts cache so the imported rows reach the + // cached readers. Fires on a partial failure too — rows that DID land must + // not stay invisible for the rest of the TTL window. The per-resource + // webhook/cron jobs run their own call in `runWhoopResourceSync`. + if (total > 0) { + invalidateUserMeasurements(userId); + } + return { imported: total, failed: anyFailed || allSoftSkipped }; }