From 61114fab907ba9e793e9edc51d38be1343732520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 19:06:56 +0200 Subject: [PATCH] Accelerate the chart range switch, prefetch two list pages, calm two polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeholderData keeps the previous range painted while a health-chart range switch fetches, paired with its truth condition: the container dims and goes inert while isPlaceholderData is true, so the old range cannot masquerade as the new one. The mood chart needs no pairing — its range is a client-side slice over one static-key query and never drops to a skeleton. /checkups and /mood gain the dashboard-template RSC prefetch: the primary list read moves into a shared lib read (route + wrapper consume one query, so filter/order/wire shape cannot drift), the wrapper honours the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through getUnswitchedSession, JSON-round-trips the dehydrated state, and fails soft to the bare client on every error path. Page-prefetch unit tests pin the seeded keys and the fail-soft arms; the SSR hydration spec gains an assertion per page. The achievement-unlock poll drops to five minutes (in-app writes already invalidate the cell; the poll only covers out-of-band ingest), and the plan-proposal poll gains the advisor-style bounded attempt ceiling so an open thread stops polling after its budget. --- e2e/dashboard-ssr-prefetch-hydration.spec.ts | 41 +++ .../customize-shortcut-guard.test.ts | 2 +- .../encounter-reminder-exclusion.test.ts | 7 +- src/app/api/measurement-reminders/route.ts | 21 +- src/app/api/mood-entries/route.ts | 87 +------ .../checkups/__tests__/page-prefetch.test.tsx | 99 ++++++++ src/app/checkups/page-client.tsx | 80 ++++++ src/app/checkups/page.tsx | 130 +++++----- src/app/mood/__tests__/page-prefetch.test.tsx | 137 ++++++++++ src/app/mood/page-client.tsx | 156 ++++++++++++ src/app/mood/page.tsx | 240 +++++++----------- .../__tests__/chart-error-state.test.tsx | 3 + .../health-chart-a11y-label.test.tsx | 3 + .../health-chart-comparison.test.tsx | 3 + .../health-chart-data-table.test.tsx | 3 + .../health-chart-empty-state-gate.test.tsx | 3 + .../health-chart-heading-level.test.tsx | 3 + .../__tests__/health-chart-mini.test.tsx | 3 + .../health-chart-no-data-in-range.test.tsx | 3 + .../__tests__/health-chart-polish.test.tsx | 3 + .../health-chart-preloaded-coverage.test.tsx | 3 + .../__tests__/health-chart-range.test.tsx | 3 + src/components/charts/health-chart.tsx | 21 +- .../achievement-unlock-notifier.tsx | 7 +- .../__tests__/plan-proposal-card.test.tsx | 25 +- .../coach-panel/plan-proposal-card.tsx | 27 +- src/components/mood/mood-list.tsx | 3 +- src/hooks/use-coach-plans.ts | 19 +- src/lib/measurement-reminders/list-read.ts | 32 +++ src/lib/mood/list-page-size.ts | 9 + src/lib/mood/list-read.ts | 112 ++++++++ 31 files changed, 962 insertions(+), 326 deletions(-) create mode 100644 src/app/checkups/__tests__/page-prefetch.test.tsx create mode 100644 src/app/checkups/page-client.tsx create mode 100644 src/app/mood/__tests__/page-prefetch.test.tsx create mode 100644 src/app/mood/page-client.tsx create mode 100644 src/lib/measurement-reminders/list-read.ts create mode 100644 src/lib/mood/list-page-size.ts create mode 100644 src/lib/mood/list-read.ts 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/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__/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/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/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/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/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/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/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 }, + }; +}