From e0387c5ceb79b7f7962d06a3fe7049e9e470fef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 20:34:49 +0200 Subject: [PATCH] perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the dashboard's RSC server-prefetch pattern to three more route roots so their above-the-fold content paints populated on first load instead of flashing a shell that fills in on mount. Each route gains a thin RSC wrapper that runs the same cached read its API route uses, dehydrates the result under the exact client query key, and hands it to the client leaf through a HydrationBoundary. Every wrapper fails soft to today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH kill-switch the e2e server sets. - /insights: prefetch the dashboard snapshot — the same locale-keyed cell the dashboard warms, resolved once server-side so the key matches by construction. The AI briefing and the heavy analytics aggregations stay client-lazy (the briefing must never trigger generation; a cold analytics read would trade FCP for TTFB). - /medications: prefetch the medications list through the shared caches.medications cell (extracted to src/lib/medications/list-read.ts). The list cell keeps refetchOnMount: "always" — a cross-device take/skip produces no client mutation event, so the mount refetch is a correctness gate; the prefetch removes the initial skeleton flash without dropping that guarantee. - /coach: prefetch the nudge status (extracted to src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is ready at hydrate, collapsing the one sequential waterfall. Only runs when the Coach surface is reachable; the streaming conversation is never warmed. Add per-route tests pinning server-key === client-key, JSON wire-shape round-trip, and the fail-soft / module-gate paths. Repoint the source-reading insights, take-all-due, and Coach gate-cascade tests at the client leaves. --- src/app/__tests__/insights-b3-wiring.test.ts | 2 +- src/app/__tests__/insights-polish.test.ts | 2 +- .../api/insights/coach/nudge-status/route.ts | 38 +- src/app/api/medications/route.ts | 248 +------ .../coach/__tests__/page-prefetch.test.tsx | 106 +++ src/app/coach/page-client.tsx | 199 +++++ src/app/coach/page.tsx | 258 ++----- .../insights/__tests__/page-prefetch.test.tsx | 111 +++ .../insights/__tests__/page-skeletons.test.ts | 2 +- src/app/insights/page-client.tsx | 611 +++++++++++++++ src/app/insights/page.tsx | 673 ++--------------- .../__tests__/list-view-toggle.test.tsx | 4 +- .../__tests__/page-prefetch.test.tsx | 122 +++ src/app/medications/page-client.tsx | 636 ++++++++++++++++ src/app/medications/page.tsx | 697 ++---------------- .../__tests__/take-all-due.test.tsx | 2 +- src/lib/ai/coach/nudge-status.ts | 54 ++ .../__tests__/coach-cascade.test.tsx | 2 +- .../__tests__/coach-user-disable.test.tsx | 5 + src/lib/medications/list-read.ts | 268 +++++++ 20 files changed, 2349 insertions(+), 1691 deletions(-) create mode 100644 src/app/coach/__tests__/page-prefetch.test.tsx create mode 100644 src/app/coach/page-client.tsx create mode 100644 src/app/insights/__tests__/page-prefetch.test.tsx create mode 100644 src/app/insights/page-client.tsx create mode 100644 src/app/medications/__tests__/page-prefetch.test.tsx create mode 100644 src/app/medications/page-client.tsx create mode 100644 src/lib/ai/coach/nudge-status.ts create mode 100644 src/lib/medications/list-read.ts diff --git a/src/app/__tests__/insights-b3-wiring.test.ts b/src/app/__tests__/insights-b3-wiring.test.ts index 1598c3e7c..00620551d 100644 --- a/src/app/__tests__/insights-b3-wiring.test.ts +++ b/src/app/__tests__/insights-b3-wiring.test.ts @@ -20,7 +20,7 @@ import { join } from "node:path"; */ const ROOT = join(__dirname, "../../.."); -const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx"); +const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx"); const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts"); const ADVISOR_HOOK_PATH = join( ROOT, diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index cdb7063d3..93ff804fc 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -29,7 +29,7 @@ import { join } from "node:path"; const ROOT = join(__dirname, "../../.."); const DASHBOARD_PATH = join(ROOT, "src/app/page-client.tsx"); -const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx"); +const INSIGHTS_PATH = join(ROOT, "src/app/insights/page-client.tsx"); const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx"); function load(path: string): string { diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index 13653ac7d..f36bf27f5 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -24,46 +24,16 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); await requireAssistantSurface("coach"); - // The newest assistant message across the caller's conversations — the - // proactive nudge writes one, and so does every normal Coach reply. We - // intentionally key on assistant (not user) messages: the unread dot - // means "the Coach said something", and the open-stamp clears it. - const [lastAssistant, seen] = await Promise.all([ - prisma.coachMessage.findFirst({ - where: { role: "assistant", conversation: { userId: user.id } }, - orderBy: { createdAt: "desc" }, - // v1.22 (W5) — also return the owning conversation id so the FAB can - // deep-link straight into the proactive thread (`/coach?c=`) - // instead of opening a blank new chat and relying on a most-recent - // heuristic to find it. - select: { createdAt: true, conversationId: true }, - }), - prisma.user.findUnique({ - where: { id: user.id }, - select: { coachLastSeenAt: true }, - }), - ]); - - const nudgedAt = lastAssistant?.createdAt ?? null; - const lastSeenAt = seen?.coachLastSeenAt ?? null; - const unread = - nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt); - - return apiSuccess({ - nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, - unread, - // The conversation holding the newest assistant message. Present - // whenever a Coach reply exists; the FAB only consumes it on the - // unread path. - conversationId: lastAssistant?.conversationId ?? null, - }); + // Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both + // readers compute the unread signal identically. + return apiSuccess(await readCoachNudgeStatus(user.id)); }); export const dynamic = "force-dynamic"; diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f63f9dea9..97cc5c2e8 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -15,262 +15,24 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; -import { - computeDisplayDue, - OVERDUE_LOOKBACK_MS, - toResolvedSlotMark, - type ResolvedSlotMark, -} from "@/lib/medications/scheduling/next-due"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { readMedicationsListCached } from "@/lib/medications/list-read"; import { NextRequest } from "next/server"; -type MedicationsListResult = Array>; - -async function buildMedicationsList( - userId: string, - userTz: string, -): Promise { - const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds( - new Date(), - userTz, - ); - - // v1.15.10 — the slots the user has already acted on near "now", so the - // next-due search can skip them. A resolved slot is a taken, deliberately - // skipped, or cron-auto-missed row. Bound the window to [today-start, - // today-end + 2d] — the next-due lookahead only needs the slots adjacent to - // now, and a tight window keeps the read cheap. The 60s list cache covers - // the rest. - const resolvedWindowEnd = new Date( - todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000, - ); - // v1.16.4 — the open-overdue search reaches back as far as the widest - // band tail (weekly on-time + overdue), so the resolved-slot read must - // cover the same horizon or a long-resolved past slot would resurface - // as "overdue". - const resolvedWindowStart = new Date( - todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS, - ); - - const [ - medications, - latestIntakes, - todayEvents, - resolvedEvents, - eraFloors, - usableStock, - inventoryCounts, - ] = await Promise.all([ - prisma.medication.findMany({ - where: { userId }, - include: { schedules: true }, - orderBy: { createdAt: "desc" }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the last-taken map. - where: { - userId, - deletedAt: null, - skipped: false, - takenAt: { not: null }, - }, - _max: { takenAt: true }, - }), - prisma.medicationIntakeEvent.groupBy({ - by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the today-count map. - // v1.16.9 — count only ACTIONED rows (taken or skipped). The - // dashboard projector mints pending rows for every slot of the - // day, so counting all rows made `todayEventCount` cover every - // passed dose after any dashboard visit — and the cards' - // overdue-pill suppression (`todayEventCount < passedDoseCount`) - // went dark nondeterministically for genuinely overdue doses. - where: { - userId, - deletedAt: null, - scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, - OR: [{ takenAt: { not: null } }, { skipped: true }], - }, - _count: { id: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { - userId, - deletedAt: null, - scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd }, - OR: [ - { takenAt: { not: null } }, - { skipped: true }, - { autoMissed: true }, - ], - }, - // v1.16.9 — `takenAt` rides along so the ad-hoc shape - // (`scheduledFor === takenAt`) is detectable: such a row must not - // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's - // genuinely-due 20:00 dose). - select: { medicationId: true, scheduledFor: true, takenAt: true }, - }), - // v1.16.4 — current-era floor per medication: the newest revision's - // `validUntil` is where the LIVE schedule rows became valid. The - // open-overdue search mints from the live rows, so it must not reach - // past this boundary into a previous era's cadence. - prisma.medicationScheduleRevision.groupBy({ - by: ["medicationId"], - // Superseded rows are audit records — a correction may have - // shortened the era, so the boundary reads only active rows. - where: { medication: { userId }, supersededByRevisionId: null }, - _max: { validUntil: true }, - }), - // v1.16.10 — usable stock per medication (one batched aggregate, - // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE - // containers with units left — the same usable-container filter - // the GLP-1 details endpoint applies. Feeds the list payload's - // `stockUnitsRemaining` / `stockDosesRemaining` for the table view. - prisma.medicationInventoryItem.groupBy({ - by: ["medicationId"], - where: { - userId, - state: { in: ["ACTIVE", "IN_USE"] }, - unitsRemaining: { gt: 0 }, - }, - _sum: { unitsRemaining: true }, - }), - // …and the any-state item count, so a medication whose containers - // are all used up / expired reads as stock 0 (tracking is ON, the - // supply ran out) instead of null (tracking off). - prisma.medicationInventoryItem.groupBy({ - by: ["medicationId"], - where: { userId }, - _count: { id: true }, - }), - ]); - - const resolvedSlotsByMedId = new Map(); - for (const e of resolvedEvents) { - const mark = toResolvedSlotMark(e); - const list = resolvedSlotsByMedId.get(e.medicationId); - if (list) list.push(mark); - else resolvedSlotsByMedId.set(e.medicationId, [mark]); - } - - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } - - const lastTakenAtByMedicationId = Object.fromEntries( - latestIntakes.map((entry) => [ - entry.medicationId, - entry._max.takenAt ? entry._max.takenAt.toISOString() : null, - ]), - ); - // v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine - // (rolling cadences re-anchor on it). Same groupBy as the ISO map. - const lastTakenAtDateByMedicationId = Object.fromEntries( - latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), - ); - const todayEventCountByMedId = Object.fromEntries( - todayEvents.map( - (entry: { medicationId: string; _count: { id: number } }) => [ - entry.medicationId, - entry._count.id, - ], - ), - ); - - const usableUnitsByMedId = new Map(); - for (const entry of usableStock) { - usableUnitsByMedId.set( - entry.medicationId, - Number(entry._sum.unitsRemaining ?? 0), - ); - } - const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); - - let categoryMap: Record = {}; - try { - categoryMap = await getMedicationCategories(medications.map((m) => m.id)); - } catch { - getEvent()?.addWarning("Medication categories could not be loaded"); - } - - // v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived; - // the list GET is cached 60 s on userId, so a 60 s staleness window is - // accepted here as it already is for `todayEventCount`. - const now = new Date(); - - return medications.map((m) => { - // v1.16.4 — an OPEN overdue slot (anchor passed, still inside its - // catch-up band, unresolved) surfaces FIRST with `nextDueOverdue: - // true`; only a closed or resolved band falls through to the future - // next-due. Keeps the card on the still-takeable dose instead of - // jumping ahead the minute the anchor passes. - const display = computeDisplayDue({ - medication: { - id: m.id, - startsOn: m.startsOn, - endsOn: m.endsOn, - oneShot: m.oneShot, - createdAt: m.createdAt, - }, - schedules: m.schedules, - now, - userTz, - lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, - resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], - eraStart: eraStartByMedId.get(m.id) ?? null, - }); - // v1.16.10 — dose-derived stock for the table view. NULL when the - // medication has no inventory items at all (tracking off); 0 when - // tracking is on but every container is used up / expired. - const tracksInventory = trackedMedIds.has(m.id); - const stockUnitsRemaining = tracksInventory - ? (usableUnitsByMedId.get(m.id) ?? 0) - : null; - const stockDosesRemaining = - stockUnitsRemaining === null - ? null - : Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1)); - return { - ...m, - // v1.16.12 — Decimal → number so the wire stays a JSON number, not - // the string Prisma would otherwise serialise a Decimal to. - unitsPerDose: Number(m.unitsPerDose), - category: categoryMap[m.id] ?? "OTHER", - lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, - todayEventCount: todayEventCountByMedId[m.id] ?? 0, - nextDueAt: display ? display.at.toISOString() : null, - nextDueOverdue: display?.overdue ?? false, - stockUnitsRemaining, - stockDosesRemaining, - }; - }); -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); - const userTz = user.timezone || "Europe/Berlin"; - // Cache the list shape on userId. The 60 s fresh TTL bounds the // cross-midnight staleness window for `todayEventCount` and the - // next-due fields; v1.16.8 reads through `cachedSwr`, so a visit + // next-due fields; the read goes through `cachedSwr`, so a visit // minutes after the last one serves the prior list immediately while // one background recompute warms a fresh entry (the 10-minute stale // window bounds how old that served list can be). Interactive writes // via POST/PUT/DELETE hard-evict through // `invalidateUserMedications({ evict: true })` before the next read - // lands; background sync paths mark the bucket stale instead. - const result = await cachedSwr( - caches.medications as ServerCache, - user.id, - () => buildMedicationsList(user.id, userTz), - annotate, - ); + // lands; background sync paths mark the bucket stale instead. The RSC + // wrapper (`src/app/medications/page.tsx`) shares this exact cell. + const result = await readMedicationsListCached(user); annotate({ action: { name: "medication.list" }, diff --git a/src/app/coach/__tests__/page-prefetch.test.tsx b/src/app/coach/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..c12ee7115 --- /dev/null +++ b/src/app/coach/__tests__/page-prefetch.test.tsx @@ -0,0 +1,106 @@ +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"; + +/** + * v1.30.x — the `/coach` server-prefetch key crux + availability gate. + * + * The RSC wrapper (`src/app/coach/page.tsx`) dehydrates the coach nudge status + * under `queryKeys.coachNudgeStatus()` so the auto-open-most-recent decision is + * available at hydrate (collapsing the nudge → auto-open waterfall). These + * tests pin: the exact client key; the read is only run when the Coach surface + * is reachable (operator flag ON + not user-disabled); and every unreachable / + * error path fails soft. The streaming conversation is never prefetched here. + */ + +const getSession = vi.fn(); +const requireAssistantSurface = vi.fn(); +const readCoachNudgeStatus = vi.fn(); + +vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() })); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: (s: string) => requireAssistantSurface(s), +})); +vi.mock("@/lib/ai/coach/nudge-status", () => ({ + readCoachNudgeStatus: (id: string) => readCoachNudgeStatus(id), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import CoachPage from "../page"; + +const NUDGE = { + nudgedAt: "2026-07-18T08:00:00.000Z", + unread: true, + conversationId: "c1", +}; + +afterEach(() => { + vi.clearAllMocks(); + delete process.env.DASHBOARD_SSR_PREFETCH; +}); + +function dehydratedQuery( + el: ReactElement, +): { queryHash: string; state: { data: unknown } } | null { + if (el.type !== HydrationBoundary) return null; + const props = el.props as { + state?: { queries: { queryHash: string; state: { data: unknown } }[] }; + }; + const q = props.state?.queries?.[0]; + return q ? { queryHash: q.queryHash, state: q.state } : null; +} + +describe("/coach RSC prefetch", () => { + it("dehydrates the nudge status under the EXACT client key", async () => { + getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } }); + requireAssistantSurface.mockResolvedValue({}); + readCoachNudgeStatus.mockResolvedValue(NUDGE); + + const el = (await CoachPage()) as ReactElement; + const q = dehydratedQuery(el); + expect(q).not.toBeNull(); + expect(q!.queryHash).toBe(hashKey(queryKeys.coachNudgeStatus())); + expect(q!.state.data).toEqual(NUDGE); + }); + + it("skips the prefetch when the user opted out of the Coach", async () => { + getSession.mockResolvedValue({ user: { id: "u1", disableCoach: true } }); + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(requireAssistantSurface).not.toHaveBeenCalled(); + expect(readCoachNudgeStatus).not.toHaveBeenCalled(); + }); + + it("fails soft when the operator Coach flag is off (surface throws)", async () => { + getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } }); + requireAssistantSurface.mockRejectedValue(new Error("assistant disabled")); + + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readCoachNudgeStatus).not.toHaveBeenCalled(); + }); + + it("fails soft when the read throws", async () => { + getSession.mockResolvedValue({ user: { id: "u1", disableCoach: false } }); + requireAssistantSurface.mockResolvedValue({}); + readCoachNudgeStatus.mockRejectedValue(new Error("db blip")); + + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + it("fails soft when there is no session", async () => { + getSession.mockResolvedValue(null); + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await CoachPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx new file mode 100644 index 000000000..7e21273f9 --- /dev/null +++ b/src/app/coach/page-client.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; + +import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; +import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab"; +import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; +import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; + +/** + * v1.12.0 (Coach v2 #6) — full-page Coach conversation. + * + * Renders the exact same `` surface the drawer uses + * (history + streaming thread + composer + provenance + settings) at + * full width and height. There is no forked chat logic — the page is a + * thin chrome wrapper around the shared surface. + * + * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer + * nested under `app/insights/layout.tsx`, so it renders in the standard + * page chrome WITHOUT the Insights tab strip. It still sits inside the + * authenticated `` (which provides the `` + * the bottom-right FAB drawer reopens through), so the page inherits the + * same Coach feature gating as every other Coach surface. When the + * operator's master flag is off or the user opted out, the page redirects + * back to `/insights` rather than painting a dead chat shell. + * + * v1.18.10 (W4) — the page is now a single full-bleed conversation + * surface: the inner panel chrome (the bordered/rounded card) is gone so + * the chat is no longer a window-inside-a-window, and the top "back to the + * drawer" minimize control is removed (pointless on the dedicated page — + * the separate bottom-right FAB drawer is the compact surface). The chat + * claims the full content width and the viewport height below the top bar. + * + * v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar + * AND the top conversation-history strip entirely. The composer is the + * single control hub (leading `+` actions menu = new chat + open + * conversations, settings deep-link, mic, send); conversation history is a + * left slide-in drawer opened from that menu. The composer keeps one + * constant, centred, max-width-capped column across the new-chat hero and + * the active conversation. All of that lives in ``'s + * page branch, so the page stays a thin gating + sizing wrapper. + * + * v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing + * the v1.19.0 (W7) "always resume most-recent" default the maintainer + * disliked. Resolution order on mount: + * - `?c=` → open that specific thread (explicit deep-link, unchanged). + * The dedicated conversation-history page (`/coach/conversations`, added + * in v1.21.4) routes back here with `?c=` when a row is selected. + * - `?c=new` or no param → the new-chat hero. + * - EXCEPTION: when the Coach has proactively written an UNREAD message + * (`/api/insights/coach/nudge-status` → `unread`), auto-open the + * most-recent thread so the user lands on what the Coach said. + * The search-param read sits in a Suspense child so the client-bailout + * never de-opts the route. + * + * 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so + * every other cross-surface hand-off (a metric card's "ask about this", the + * Today check-in's "Adjust", a workout detail's coach button) dropped its + * context at the URL boundary and landed on a blank composer (F1-2 / F4-1 / + * F6-1). Two additive params close that gap, both seeding the SAME props + * `` already exposes for the drawer's suggested-prompt + * chips — no new plumbing inside the conversation surface itself: + * - `?scope=` — narrows the snapshot the FIRST turn of a + * fresh conversation reads (validated against the closed enum; an + * unrecognised value is silently dropped rather than reaching the chat + * route with a free-form string). + * - `?ask=` — seeds the composer prefill (mirrors `prefill` on the + * in-app `askCoach()` launch call). The user still reviews/sends it — + * this is a prefill, not an auto-send. + * Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope + * only ever applies to a fresh conversation's first turn. + */ +function CoachPageBody() { + const searchParams = useSearchParams(); + // `?c=new` is an explicit "start a fresh chat" escape hatch; any other + // value is treated as a conversation id to open. A blank/absent param + // defaults to the new-chat hero. + const rawC = searchParams.get("c"); + const deepLinkedId = rawC && rawC !== "new" ? rawC : null; + // v1.28.51 (Documents R3, Design A) — `?doc=` seeds a fresh chat SCOPED + // to a stored document (the vault detail sheet's "Ask the Coach" action). An + // explicit `?c=` thread wins over it (that thread carries its own scope). + const rawDoc = searchParams.get("doc"); + const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null; + + // A fresh conversation only — an existing thread (`?c=`) or a doc-scoped + // chat (`?doc=`) keeps its own established scope. + const freshChat = deepLinkedId === null && seedDocumentId === null; + const rawScope = freshChat ? searchParams.get("scope") : null; + const scopeResult = rawScope + ? coachScopeSourceSchema.safeParse(rawScope) + : null; + const launchScope: CoachLaunchScope | null = scopeResult?.success + ? { metric: scopeResult.data } + : null; + const seedPrefill = freshChat ? searchParams.get("ask") : null; + + // C1 exception — an unread coach-initiated message opens the most-recent + // conversation (which holds that proactive turn). Only consulted when the + // entry did not pin a specific thread or ask for a fresh chat. + // A `?doc=` open is an explicit fresh doc-scoped chat — never override it by + // resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=` + // hand-off is likewise an explicit fresh-chat request. + const exceptionEligible = + deepLinkedId === null && + rawC !== "new" && + seedDocumentId === null && + launchScope === null && + !seedPrefill; + const { data: nudge } = useQuery({ + queryKey: queryKeys.coachNudgeStatus(), + queryFn: async (): Promise => + apiGet("/api/insights/coach/nudge-status"), + enabled: exceptionEligible, + staleTime: 5 * 60 * 1000, + }); + const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true; + + return ( + ` open so the first + // turn is created + sent through the hardened fenced document endpoint. + initialDocumentId={seedDocumentId} + // 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a + // cross-surface hand-off carried in the URL. + launchScope={launchScope} + prefill={seedPrefill} + // Default is the new-chat hero; only resume most-recent for the + // unread coach-initiated exception. + autoOpenMostRecent={hasUnreadCoachMessage} + /> + ); +} + +export default function CoachPageClient() { + const router = useRouter(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + const coachUnavailable = !flags.coach || disableCoach; + + // Coach surface gating mirrors `` / ``: + // operator master flag OR per-user opt-out hides the Coach entirely. + // Send a direct navigator back to the Insights mother page so the + // route is never a dead-end. + useEffect(() => { + if (coachUnavailable) { + router.replace("/insights"); + } + }, [coachUnavailable, router]); + + // Reopening the bottom-right FAB drawer is no longer wired to a page + // control, but keep the launch context referenced so the lint rule does + // not flag the unused hook — the drawer the page hands back to lives in + // the shared ``. + void launch; + + if (coachUnavailable) return null; + + return ( +
` paints directly onto the page so it reads as + // one continuous conversation, not a window inside a window. The + // separate bottom-right FAB/overlay drawer stays the compact surface. + // + // v1.18.10 (W10) — height is mobile-aware. The fixed `` + // (64px + iOS home-indicator inset) is mobile-only; the desktop + // full-bleed layout has no bottom nav. On mobile the page must subtract + // the TopBar (`4rem`) AND the BottomNav band (`4rem` + + // `env(safe-area-inset-bottom)`) so the docked composer / Stop control + // always clears the nav instead of sitting under it. On `md+` the nav + // is hidden, so the page reclaims the full height below the TopBar. + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + > + {/* `useSearchParams` requires a Suspense boundary so the client-search + bailout never opts the whole route out of static optimisation. */} + + + +
+ ); +} diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 4e6b1c169..134c08b2e 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -1,199 +1,85 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { Suspense, useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; - -import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; -import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab"; -import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; -import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { getSession } from "@/lib/auth/session"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; -import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; + +import CoachPageClient from "./page-client"; /** - * v1.12.0 (Coach v2 #6) — full-page Coach conversation. + * Thin RSC wrapper around the (client) full-page Coach. * - * Renders the exact same `` surface the drawer uses - * (history + streaming thread + composer + provenance + settings) at - * full width and height. There is no forked chat logic — the page is a - * thin chrome wrapper around the shared surface. + * The default new-chat hero path carries the one genuine sequential waterfall + * on this route: the client `coachNudgeStatus` cell fetches + * `/api/insights/coach/nudge-status` after hydrate, and the conversation's + * `autoOpenMostRecent` decision is gated on that result — so the hero "pops in + * after a beat" while the nudge round-trip resolves. This wrapper runs the SAME + * read the API route uses (`readCoachNudgeStatus`) during SSR and hands it to + * TanStack via `HydrationBoundary`, so the auto-open decision is available at + * hydrate and the waterfall step collapses. * - * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer - * nested under `app/insights/layout.tsx`, so it renders in the standard - * page chrome WITHOUT the Insights tab strip. It still sits inside the - * authenticated `` (which provides the `` - * the bottom-right FAB drawer reopens through), so the page inherits the - * same Coach feature gating as every other Coach surface. When the - * operator's master flag is off or the user opted out, the page redirects - * back to `/insights` rather than painting a dead chat shell. - * - * v1.18.10 (W4) — the page is now a single full-bleed conversation - * surface: the inner panel chrome (the bordered/rounded card) is gone so - * the chat is no longer a window-inside-a-window, and the top "back to the - * drawer" minimize control is removed (pointless on the dedicated page — - * the separate bottom-right FAB drawer is the compact surface). The chat - * claims the full content width and the viewport height below the top bar. - * - * v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar - * AND the top conversation-history strip entirely. The composer is the - * single control hub (leading `+` actions menu = new chat + open - * conversations, settings deep-link, mic, send); conversation history is a - * left slide-in drawer opened from that menu. The composer keeps one - * constant, centred, max-width-capped column across the new-chat hero and - * the active conversation. All of that lives in ``'s - * page branch, so the page stays a thin gating + sizing wrapper. - * - * v1.19.1 (C1/C5) — the Coach now DEFAULTS to the new-chat hero, reversing - * the v1.19.0 (W7) "always resume most-recent" default the maintainer - * disliked. Resolution order on mount: - * - `?c=` → open that specific thread (explicit deep-link, unchanged). - * The dedicated conversation-history page (`/coach/conversations`, added - * in v1.21.4) routes back here with `?c=` when a row is selected. - * - `?c=new` or no param → the new-chat hero. - * - EXCEPTION: when the Coach has proactively written an UNREAD message - * (`/api/insights/coach/nudge-status` → `unread`), auto-open the - * most-recent thread so the user lands on what the Coach said. - * The search-param read sits in a Suspense child so the client-bailout - * never de-opts the route. - * - * 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so - * every other cross-surface hand-off (a metric card's "ask about this", the - * Today check-in's "Adjust", a workout detail's coach button) dropped its - * context at the URL boundary and landed on a blank composer (F1-2 / F4-1 / - * F6-1). Two additive params close that gap, both seeding the SAME props - * `` already exposes for the drawer's suggested-prompt - * chips — no new plumbing inside the conversation surface itself: - * - `?scope=` — narrows the snapshot the FIRST turn of a - * fresh conversation reads (validated against the closed enum; an - * unrecognised value is silently dropped rather than reaching the chat - * route with a free-form string). - * - `?ask=` — seeds the composer prefill (mirrors `prefill` on the - * in-app `askCoach()` launch call). The user still reviews/sends it — - * this is a prefill, not an auto-send. - * Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope - * only ever applies to a fresh conversation's first turn. + * Contract notes (the v1.30.9 dashboard template): + * - The query key comes ONLY from the central factory — + * `queryKeys.coachNudgeStatus()` 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; the read already returns ISO + * strings, so the shape is byte-identical to the client's + * ((await res.json()).data) wire. + * - The client cell carries `staleTime: 5min` with the default + * `refetchOnMount`, so the seeded fresh value is used at hydrate without an + * immediate refetch. When the client query is disabled (a `?c=`/`?doc=`/ + * `?scope=` deep-link makes it `enabled: false`) the seeded value simply + * sits unused — harmless. + * - Availability parity: prefetch only when the Coach surface is actually + * reachable — the operator `coach` assistant flag is ON + * (`requireAssistantSurface` throws otherwise) AND the user has not opted + * out (`disableCoach`). Mirrors the client's `!flags.coach || disableCoach` + * redirect gate, so a disabled account does no wasted work. + * - Left CLIENT-LAZY on purpose: the streaming conversation itself (the SSE + * thread, snapshot, any provider-touching path) is NOT prefetched or warmed + * — it depends on URL params + the nudge outcome and must never be triggered + * server-side. + * - Fail-soft: no session, the Coach being off, or a DB blip renders the page + * exactly as before this wrapper existed — the client cell owns the fetch. */ -function CoachPageBody() { - const searchParams = useSearchParams(); - // `?c=new` is an explicit "start a fresh chat" escape hatch; any other - // value is treated as a conversation id to open. A blank/absent param - // defaults to the new-chat hero. - const rawC = searchParams.get("c"); - const deepLinkedId = rawC && rawC !== "new" ? rawC : null; - // v1.28.51 (Documents R3, Design A) — `?doc=` seeds a fresh chat SCOPED - // to a stored document (the vault detail sheet's "Ask the Coach" action). An - // explicit `?c=` thread wins over it (that thread carries its own scope). - const rawDoc = searchParams.get("doc"); - const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null; +export default async function CoachPage() { + // 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 ; + } - // A fresh conversation only — an existing thread (`?c=`) or a doc-scoped - // chat (`?doc=`) keeps its own established scope. - const freshChat = deepLinkedId === null && seedDocumentId === null; - const rawScope = freshChat ? searchParams.get("scope") : null; - const scopeResult = rawScope - ? coachScopeSourceSchema.safeParse(rawScope) - : null; - const launchScope: CoachLaunchScope | null = scopeResult?.success - ? { metric: scopeResult.data } - : null; - const seedPrefill = freshChat ? searchParams.get("ask") : null; - - // C1 exception — an unread coach-initiated message opens the most-recent - // conversation (which holds that proactive turn). Only consulted when the - // entry did not pin a specific thread or ask for a fresh chat. - // A `?doc=` open is an explicit fresh doc-scoped chat — never override it by - // resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=` - // hand-off is likewise an explicit fresh-chat request. - const exceptionEligible = - deepLinkedId === null && - rawC !== "new" && - seedDocumentId === null && - launchScope === null && - !seedPrefill; - const { data: nudge } = useQuery({ - queryKey: queryKeys.coachNudgeStatus(), - queryFn: async (): Promise => - apiGet("/api/insights/coach/nudge-status"), - enabled: exceptionEligible, - staleTime: 5 * 60 * 1000, - }); - const hasUnreadCoachMessage = exceptionEligible && nudge?.unread === true; - - return ( - ` open so the first - // turn is created + sent through the hardened fenced document endpoint. - initialDocumentId={seedDocumentId} - // 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a - // cross-surface hand-off carried in the URL. - launchScope={launchScope} - prefill={seedPrefill} - // Default is the new-chat hero; only resume most-recent for the - // unread coach-initiated exception. - autoOpenMostRecent={hasUnreadCoachMessage} - /> - ); -} - -export default function CoachPage() { - const router = useRouter(); - const launch = useCoachLaunch(); - const flags = useFeatureFlags(); - const disableCoach = useDisableCoach(); - - const coachUnavailable = !flags.coach || disableCoach; - - // Coach surface gating mirrors `` / ``: - // operator master flag OR per-user opt-out hides the Coach entirely. - // Send a direct navigator back to the Insights mother page so the - // route is never a dead-end. - useEffect(() => { - if (coachUnavailable) { - router.replace("/insights"); + let dehydratedState = null; + try { + const session = await getSession(); + if (session && !session.user.disableCoach) { + // Throws `AssistantDisabledError` when the operator flag is off → caught + // below, prefetch skipped, client path stands (and redirects to /insights). + await requireAssistantSurface("coach"); + const nudge = await readCoachNudgeStatus(session.user.id); + const queryClient = new QueryClient(); + queryClient.setQueryData( + queryKeys.coachNudgeStatus(), + JSON.parse(JSON.stringify(nudge)), + ); + dehydratedState = dehydrate(queryClient); } - }, [coachUnavailable, router]); - - // Reopening the bottom-right FAB drawer is no longer wired to a page - // control, but keep the launch context referenced so the lint rule does - // not flag the unused hook — the drawer the page hands back to lives in - // the shared ``. - void launch; - - if (coachUnavailable) return null; + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + if (dehydratedState === null) { + return ; + } return ( -
` paints directly onto the page so it reads as - // one continuous conversation, not a window inside a window. The - // separate bottom-right FAB/overlay drawer stays the compact surface. - // - // v1.18.10 (W10) — height is mobile-aware. The fixed `` - // (64px + iOS home-indicator inset) is mobile-only; the desktop - // full-bleed layout has no bottom nav. On mobile the page must subtract - // the TopBar (`4rem`) AND the BottomNav band (`4rem` + - // `env(safe-area-inset-bottom)`) so the docked composer / Stop control - // always clears the nav instead of sitting under it. On `md+` the nav - // is hidden, so the page reclaims the full height below the TopBar. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" - > - {/* `useSearchParams` requires a Suspense boundary so the client-search - bailout never opts the whole route out of static optimisation. */} - - - -
+ + + ); } diff --git a/src/app/insights/__tests__/page-prefetch.test.tsx b/src/app/insights/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..48c73e65b --- /dev/null +++ b/src/app/insights/__tests__/page-prefetch.test.tsx @@ -0,0 +1,111 @@ +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"; + +/** + * v1.30.x — the `/insights` server-prefetch key crux + fail-soft. + * + * The RSC wrapper (`src/app/insights/page.tsx`) dehydrates the dashboard + * snapshot under the SAME locale-keyed cell the dashboard warms, so the + * Insights hero's snapshot-derived band paints from the first HTML. + * + * THE CRUX: the snapshot cell is locale-keyed. The locale is resolved ONCE + * server-side by `readDashboardSnapshotCached` and returned; the client keys on + * the SAME resolved locale from the i18n context. These tests pin that the + * server dehydrates under `queryKeys.dashboardSnapshot()` + * (byte-identical hash) + the widget layout, and that every error path fails + * soft to the bare client. + */ + +const getSession = vi.fn(); +const readDashboardSnapshotCached = vi.fn(); + +vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() })); +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import InsightsPage 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("/insights RSC prefetch", () => { + it("dehydrates the snapshot under the locale-keyed client key", async () => { + getSession.mockResolvedValue(SESSION); + readDashboardSnapshotCached.mockResolvedValue({ + body: { + healthScore: { tension: "steady" }, + layout: { version: 1, widgets: [] }, + }, + locale: "de", + }); + + const el = (await InsightsPage()) as ReactElement; + const queries = queriesOf(el); + const hashes = queries.map((q) => q.queryHash); + // The client's `useDashboardSnapshot` keys on the SAME resolved locale. + expect(hashes).toContain(hashKey(queryKeys.dashboardSnapshot("de"))); + // And the widget layout the snapshot carries is seeded too. + expect(hashes).toContain(hashKey(queryKeys.dashboardWidgets())); + // A zero-arg / wrong-locale key must NOT be what was seeded. + expect(hashes).not.toContain(hashKey(queryKeys.dashboardSnapshot())); + expect(hashes).not.toContain(hashKey(queryKeys.dashboardSnapshot("en"))); + }); + + it("JSON-round-trips the snapshot body to the wire shape", async () => { + getSession.mockResolvedValue(SESSION); + const cachedAt = new Date("2026-07-18T06:00:00.000Z"); + readDashboardSnapshotCached.mockResolvedValue({ + body: { cachedAt, layout: { version: 1, widgets: [] } }, + locale: "en", + }); + + const el = (await InsightsPage()) as ReactElement; + const snap = queriesOf(el).find( + (q) => q.queryHash === hashKey(queryKeys.dashboardSnapshot("en")), + ); + expect((snap!.state.data as { cachedAt: unknown }).cachedAt).toBe( + cachedAt.toISOString(), + ); + }); + + it("fails soft to the bare client when the read throws", async () => { + getSession.mockResolvedValue(SESSION); + readDashboardSnapshotCached.mockRejectedValue(new Error("db blip")); + + const el = (await InsightsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + it("fails soft when there is no session", async () => { + getSession.mockResolvedValue(null); + const el = (await InsightsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readDashboardSnapshotCached).not.toHaveBeenCalled(); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await InsightsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/insights/__tests__/page-skeletons.test.ts b/src/app/insights/__tests__/page-skeletons.test.ts index 1b225f1a2..5767f6e05 100644 --- a/src/app/insights/__tests__/page-skeletons.test.ts +++ b/src/app/insights/__tests__/page-skeletons.test.ts @@ -25,7 +25,7 @@ import { join } from "node:path"; */ describe("insights mother-page dynamic-skeleton loaders use the shared BlockSkeleton", () => { const src = readFileSync( - join(process.cwd(), "src/app/insights/page.tsx"), + join(process.cwd(), "src/app/insights/page-client.tsx"), "utf8", ); diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx new file mode 100644 index 000000000..439522754 --- /dev/null +++ b/src/app/insights/page-client.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { Fragment, useCallback, type ReactNode } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { SlidersHorizontal, TrendingUp } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useFeatureFlags } from "@/hooks/use-feature-flags"; +import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { + orderedVisibleSectionIds, + type InsightsSectionId, +} from "@/lib/insights-layout"; +import { useMounted } from "@/hooks/use-mounted"; +import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; +import { useTranslations } from "@/lib/i18n/context"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { HeroStrip } from "@/components/insights/hero-strip"; +import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; +// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as +// `InsightsAnalyticsData`; aliased back to the local name to keep the +// rest of this file readable. +import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics"; + +/** + * v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind + * `next/dynamic`. `` (the only above-the-fold piece) stays an + * eager import so the initial paint shows the greeting and health-score + * badge without a flash; the briefing, correlation row and trends row + * each carry their own icon-set + chart wiring (a chart card alone weighs + * in at the lucide tree-shake limit) and used to land on every Insights + * cold mount. + * + * v1.11.3 — every loader fallback now routes through the shared + * `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The + * fixed guessed heights pinned each placeholder taller (or shorter) than + * the resolved block, so the page jumped as each chunk landed. The shared + * skeleton carries a `min-h` floor — enough to hold the row open at cold + * mount — and lets the block grow into its true height without fighting a + * hard-coded pixel guess, killing the resolve-time layout shift. Reduced + * motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`). + */ +function BlockSkeleton({ + minHeight, + decorative = false, +}: { + /** Tailwind `min-h-*` utility holding the row open before the chunk lands. */ + minHeight: string; + /** Decorative placeholders (cards that may un-mount) hide from a11y. */ + decorative?: boolean; +}) { + return ( + + ); +} + +const DailyBriefing = dynamic( + () => + import("@/components/insights/daily-briefing").then((mod) => ({ + default: mod.DailyBriefing, + })), + { + ssr: false, + loading: () => , + }, +); +const TrendsRow = dynamic( + () => + import("@/components/insights/trends-row").then((mod) => ({ + default: mod.TrendsRow, + })), + { + ssr: false, + loading: () => , + }, +); +// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so +// it renders as its own full-width section directly above the daily briefing. +// Both this strip and the vitals grid read the ONE shared derived batch the +// page owns (passed in via `batch`), so the lift adds no second request. +const WellnessScores = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.WellnessScores, + })), + { + ssr: false, + loading: () => , + }, +); +// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of +// personal-typical-range + passthrough-reframe tiles). Deferred behind +// `next/dynamic` like the other below-the-fold blocks; each tile owns its +// own derived-metric query and un-mounts when its vital is absent. +const VitalsDashboard = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.VitalsDashboard, + })), + { + ssr: false, + loading: () => , + }, +); +// v1.10.0 — categorical events (WX-B). The device-flagged event awareness +// timeline. Deferred like the other below-the-fold blocks; the card +// un-mounts itself when the user has no such events (no skeleton-then-empty +// flash — `ssr: false` + the card's own data gate), so it carries no +// loading placeholder of its own. +const RhythmEventsCard = dynamic( + () => + import("@/components/insights/rhythm-events-card").then((mod) => ({ + default: mod.RhythmEventsCard, + })), + { ssr: false }, +); +// v1.28.50 — ECG recording surface (waveform-backed). Deferred like the +// other below-the-fold blocks; the section un-mounts itself when the user +// has no recordings (`ssr: false` + its own data gate), so it carries no +// loading placeholder of its own. +const EcgSection = dynamic( + () => + import("@/components/insights/ecg-section").then((mod) => ({ + default: mod.EcgSection, + })), + { ssr: false }, +); +// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own +// read-only `/api/insights/health-status` query. +const HealthStatusCard = dynamic( + () => + import("@/components/insights/health-status-card").then((mod) => ({ + default: mod.HealthStatusCard, + })), + { ssr: false }, +); +// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening +// signal only, never a diagnosis. +const BreathingScreeningCard = dynamic( + () => + import("@/components/insights/breathing-screening-card").then((mod) => ({ + default: mod.BreathingScreeningCard, + })), + { ssr: false }, +); +// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer +// than two panels or no shared analyte. +const LabsChangesCard = dynamic( + () => + import("@/components/insights/labs-changes-card").then((mod) => ({ + default: mod.LabsChangesCard, + })), + { ssr: false }, +); +// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from +// a buried below-the-fold tile to the top-of-overview daily read (the +// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it +// owns its own derived-metric query and renders four calm states. The chunk +// loader's skeleton shares the card's `min-h-48` footprint (and the in-card +// `CardSkeleton` matches it too) so the top of the page does not shift across +// loading → any resolved state. Decorative → `aria-hidden`. +const CoincidentDeviationCard = dynamic( + () => + import("@/components/insights/coincident-deviation-card").then((mod) => ({ + default: mod.CoincidentDeviationCard, + })), + { + ssr: false, + loading: () => , + }, +); + +// v1.11.0 — period-narrative card (Pillar P1). The calm "your week/month in +// review" summary, drawn from the read-only stale-while-revalidate narrative +// route. Deferred behind `next/dynamic` like the other below-the-hero blocks; +// it owns its own query and un-mounts when no narrative exists. The loader +// skeleton shares the card's `min-h-40` footprint so the page does not shift. +const PeriodNarrativeCard = dynamic( + () => + import("@/components/insights/period-narrative-card").then((mod) => ({ + default: mod.PeriodNarrativeCard, + })), + { + ssr: false, + loading: () => , + }, +); + +// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when +// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the +// sidebar nav entry gates on), so the cycle reads never fire for an account +// without the feature. Deferred behind `next/dynamic` like the other +// below-the-hero blocks; it owns its own calendar + insights reads and renders +// nothing while resolving / on error, so it carries no loading placeholder. +const CycleInsightSummaryCard = dynamic( + () => + import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({ + default: mod.CycleInsightSummaryCard, + })), + { ssr: false }, +); + +// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a +// gated sibling tile (NOT the summary teaser — that stays further down). Mounted +// only for a cycle-tracking account, so its calendar read never fires otherwise. +// Deferred behind `next/dynamic`; it renders nothing while resolving / on error / +// when there is no active cycle, so it carries no loading placeholder. +const CycleRingTile = dynamic( + () => + import("@/components/cycle/cycle-ring-tile").then((mod) => ({ + default: mod.CycleRingTile, + })), + { ssr: false }, +); + +/** + * v1.4.25 W4d — Insights mother page. + * + * The page used to be a single 1.8k-LOC monolith that scroll-anchored + * six per-metric sections beneath the hero. W4a/c carved those out + * into routed sub-pages under `/insights/{slug}`; this file now holds + * the overview-only surface: + * + * - The sticky tab strip lives in `src/app/insights/layout.tsx` + * (the shared `` mounts it). The strip handles + * navigation to every sub-page + the regenerate affordance. + * - Hero + DailyBriefing + Trends row + advisor card stay here — + * they're the cross-metric overview. The per-metric correlation + * cards moved onto the metric pages they belong to (Weight owns + * weight × weekday, Pulse owns mood × pulse, …), so the overview + * no longer renders a duplicate correlation row. + * - The CoachDrawer is mounted in the mother-page body only (the maintainer + * directive). Navigating to a sub-page unmounts the drawer. + * + * The per-section status cards (BP/Weight/Pulse/etc.) and their + * heavy chart wiring moved to the matching sub-pages. + */ + +/** + * The mother page only checks whether the comprehensive payload arrived + * (the EmptyState gates on `!data`); the metric-specific shape lives on + * the sub-pages now. Keep this slim — anything more is dead weight here. + */ +interface ComprehensiveData { + totalMeasurements: number; +} + +export default function InsightsPageClient() { + const { isAuthenticated, user } = useAuth(); + const mounted = useMounted(); + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + // v1.30.1 M12 — the overview fans out into ~6 independent queries + // (comprehensive / advisor / analytics / snapshot / derived batch / + // layout) mounted by different hooks below, so there's no single + // `refetch()` that covers the page. Refetching every currently-mounted + // ("active") query is the same blanket approach the audit's fallback + // suggests for PWA-resume staleness, and it's the right shape here + // regardless of resume vs. an explicit pull. + const refreshInsights = useCallback( + () => queryClient.refetchQueries({ type: "active" }), + [queryClient], + ); + const pull = usePullToRefresh({ onRefresh: refreshInsights }); + + // v1.4.33 IW9 — scroll-to-top on route mount centralised in the + // shared `useScrollResetOnRoute()` hook. The mother page + the + // `` both consume the same hook; the legacy duplicate + // RAF that lived here pre-v1.4.33 produced a visible double-snap on + // slow hydrates (chart skeletons inflating between the two + // callbacks). + useScrollResetOnRoute(); + + // v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level + // ``; the drawer is mounted next to the provider in + // `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach + // entry points (the action button + suggested-prompt chips), so the + // overview page no longer reads the launch context — the drawer + its + // own launcher stay untouched. + const flags = useFeatureFlags(); + + // v1.4.36 W1 — drop the page-level `isLoading` gate that used to + // block the entire shell on `/api/insights/comprehensive`. The + // comprehensive payload still feeds the empty-state decision but + // every other section now mounts in parallel under its own + // boundary, so the user sees the hero + tile skeletons + // within ~500 ms instead of waiting on the slowest fan-out. The + // empty-state branch only fires once the query has resolved AND + // reported zero measurements; while it's in-flight the page paints + // the regular shell and the tiles fill in as their data lands. + const { data, isLoading, isFetched, isError, refetch } = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: async () => { + try { + return await apiGet("/api/insights/comprehensive"); + } catch { + throw new Error(t("insights.loadError")); + } + }, + enabled: isAuthenticated, + }); + + // The advisor query is also mounted by the layout shell; the page + // consumer re-reads from the same cache key so this call is free + // beyond the React-state subscription. + const advisor = useInsightsAdvisorQuery(isAuthenticated); + + // v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only + // field) for the hero score card, so it stays on the default thick + // slice. The shared hook still centralises the cache settings so the + // consumer dedups with the sub-page mounts that ride the slim slice + // instead. Correlations now live on the per-metric pages, so the + // overview no longer reads `analytics.correlations`. + const analyticsQuery = useAnalyticsQuery(); + const analytics = analyticsQuery.data as AnalyticsData | undefined; + + // v1.21.2 (A5 / A6) — the dashboard snapshot is the single server source + // for the ambient narrative the hero surfaces: the Tension Verdict and + // return-to-baseline ride its `healthScore`. The hero score number itself + // stays on the analytics payload (unchanged). Reads the same shared + // `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights + // visit pays at most one extra round-trip and usually a warm cache hit. + // (The A4 recall/forward-look block left the briefing card — the card now + // opens straight on the day's signals.) + const snapshotQuery = useDashboardSnapshot(isAuthenticated); + const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null; + const heroTension = snapshotHealthScore?.tension ?? null; + const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; + + // v1.12.6 — the page owns the ONE overview derived batch. The wellness + // strip (above the briefing) and the vitals grid (below it) both read this + // single query instance, so the wellness lift adds no second request. + const dashboardDerived = useDashboardDerived(isAuthenticated); + + // v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives + // both the section render order/visibility below and the Vitals grid's + // per-tile order/visibility (passed into ). Defaults to + // the canonical layout while in-flight so the first paint matches the + // default order with no flicker. + // v1.15.18 — the overview layout (sections + tiles) still drives the section + // render order/visibility + the Vitals grid below. CUSTOMISING it (and the + // nav-pill order) moved out of an in-page "Anpassen" edit mode into + // Settings → Insights, reached via the top-right cog on the tab strip. + const { layout } = useInsightsLayoutQuery(isAuthenticated); + + // Error branch — a transient 500 / network drop (after the query's + // retries are exhausted) settles the comprehensive query with no data. + // Without this gate the page would fall through to the "no data yet — + // add a measurement" empty-state, which reads false for a user with + // history. Surface an error + a Retry that refetches the one query, + // mirroring the error pattern. + if (isError) { + return ( +
+ refetch()} + /> +
+ ); + } + + // Empty-state shortcut — only paint once the comprehensive query has + // resolved AND reported zero measurements. While it's in-flight we + // fall through to the streamed shell so the user gets the hero + + // skeleton tiles inside the first paint budget. + if (!isLoading && isFetched && !data) { + return ( + } + title={t("insights.emptyTitle")} + description={t("insights.emptyDescription")} + ctaSize="lg" + action={ + + } + /> + ); + } + + // Gated on `mounted`: the auth query can resolve before this + // boundary hydrates, and a greeting that personalises during the + // hydration render disagrees with the name-less SSR text — React + // #418. See `useMounted`. + const heroGreetingName = + mounted && user?.username?.trim() && user.username.trim().length > 0 + ? user.username.split(/\s+/)[0] + : null; + const briefingPayload = advisor.payload?.dailyBriefing ?? null; + const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null; + + // v1.4.36 QA C2 — no `` wrappers below. The mother page is + // `"use client"`, the below-the-fold blocks load via `next/dynamic` + // with `{ ssr: false }` (no Promise-throw on hydrate), and the + // TanStack Query hooks each return their own loading state without + // ever throwing a thenable. Wrapping these in `` would be + // dead code — Suspense never engages and the `loading` props inside + // each section already drive the skeleton. The perceptual win we + // ship is "early-skeleton paint": the page-level `isLoading` gate is + // gone, the hero + each section's own loader skeleton paints inside + // the first paint budget while data fills in. Genuinely streamed + // server children would require a Server-Component refactor; that's + // a v1.5.x track. + + // v1.12.6 — overview section order, top → bottom: + // 1. "Guten Morgen" Hero + Coach questions (HeroStrip). + // 2. Wellnesswerte — the wellness-score strip (lifted OUT of + // so it sits ABOVE the briefing as its own + // full-width section). + // 3. "Heute auf einen Blick" briefing (DailyBriefing). + // 4. Vitalwerte — the vitals grid (+ mobility) inside , + // which no longer renders the wellness strip. + // 5. Trends (TrendsRow). + // 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). + // 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus + // the rhythm-events alert timeline, kept out of the AI gate so a + // health alert is never hidden. + // The wellness strip and the vitals grid share the ONE `dashboardDerived` + // batch the page owns — one request, two sections. The per-metric + // correlation cards moved onto the metric pages, so the overview renders no + // correlation row. The duplicate footer "prepare assessments" control was + // removed in v1.12.4 — the tab-strip regenerate button is the single + // affordance. The generic disclaimer lives once in the layout-shell footer. + + // v1.15.11 W2 — decouple the section render order from the JSX. Each section + // id maps to its existing node here; the customizable region below renders + // `orderedVisibleSectionIds(layout)` against this registry. Every existing + // feature/data gate is preserved INSIDE each entry: a section the layout + // marks visible but whose gate is off (briefing flag, cycle-enabled) still + // resolves to `null`, exactly as today. With the default layout the order + + // gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays + // anchored above this region, OUTSIDE the customizable set. + const SECTION_REGISTRY: Record = { + "wellness-scores": ( + : undefined + } + // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: + // hide Strain so the strip stays compact instead of growing a sixth + // tile. Strain stays visible for non-cycle accounts. + hideStrain={user?.modules?.cycle === true} + /> + ), + "daily-briefing": flags.briefing ? ( + + ) : null, + vitals: , + trends: ( + + ), + "period-review": flags.briefing ? ( + + ) : null, + // v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account; + // for everyone else this is nothing (no card, no layout gap). The card + // itself stays silent until its reads resolve. + "cycle-summary": user?.cycleTrackingEnabled ? ( + + ) : null, + signals: , + "rhythm-events": , + "health-status": , + breathing: , + "labs-changes": , + ecg: , + }; + + const orderedSectionIds = orderedVisibleSectionIds(layout); + const everySectionHidden = orderedSectionIds.length === 0; + + return ( + // v1.12.7 (L3) — one consistent vertical rhythm down the overview. The + // page used `space-y-8` (32 px) between top-level blocks while the vitals + // wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify + // to `space-y-6` — it matches the vitals wrap and tightens the overview in + // line with the "Insights gives away too much space" direction. + // + // v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to + // bottom. Every section renders as a real `
` (or `null` — never a + // `display: contents` wrapper, which used to collapse the cycle-summary box + // so `space-y` skipped its margin and the cycle→signals seam read as + // zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its + // card, so the rhythm is even regardless of which sections are present. +
+ + " line with a discreet connect-provider hint, matching + // the briefing card footer. Only when a briefing is actually shown. + noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- + // baseline off the dashboard snapshot. The hero localises the keys and + // forwards them to the score card; null leaves the card quiet. + tension={heroTension} + returnToBand={heroReturnToBand} + /> + + {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the + overview (section show/hide + order) and sorting the nav pills now + live ONLY in Settings → Insights, reached via the top-right cog on the + tab strip — no duplicate in-page entry point. */} + {everySectionHidden ? ( + /* v1.15.18 — empty-state: every section hidden. The page is never + blank — surface a hint + a link to the Insights settings section so + the user can bring sections back. */ + } + title={t("insights.editMode.emptyTitle")} + description={t("insights.editMode.emptyDescription")} + ctaSize="lg" + action={ + + } + /> + ) : ( + /* v1.15.11 W2 — the customizable region: sections render in the + resolved layout order, skipping any the user has hidden. A + layout-visible-but-gate-off section resolves to `null` from the + registry, so the `space-y-6` rhythm closes the gap with no hole. + Each registry node is wrapped in a keyed Fragment so React keeps a + stable identity across a reorder. */ + orderedSectionIds.map((id) => ( + {SECTION_REGISTRY[id]} + )) + )} +
+ ); +} diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 07bbfb523..bb0ae6b11 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -1,611 +1,86 @@ -"use client"; - -import { Fragment, useCallback, type ReactNode } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { SlidersHorizontal, TrendingUp } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { - orderedVisibleSectionIds, - type InsightsSectionId, -} from "@/lib/insights-layout"; -import { useMounted } from "@/hooks/use-mounted"; -import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; -import { useTranslations } from "@/lib/i18n/context"; -import { Button } from "@/components/ui/button"; -import { EmptyState } from "@/components/ui/empty-state"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; -import { HeroStrip } from "@/components/insights/hero-strip"; -import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; -import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; -// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as -// `InsightsAnalyticsData`; aliased back to the local name to keep the -// rest of this file readable. -import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics"; + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; + +import { getSession } from "@/lib/auth/session"; +import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; +import { queryKeys } from "@/lib/query-keys"; + +import InsightsPageClient from "./page-client"; /** - * v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind - * `next/dynamic`. `` (the only above-the-fold piece) stays an - * eager import so the initial paint shows the greeting and health-score - * badge without a flash; the briefing, correlation row and trends row - * each carry their own icon-set + chart wiring (a chart card alone weighs - * in at the lucide tree-shake limit) and used to land on every Insights - * cold mount. + * Thin RSC wrapper around the (client) Insights overview page. * - * v1.11.3 — every loader fallback now routes through the shared - * `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The - * fixed guessed heights pinned each placeholder taller (or shorter) than - * the resolved block, so the page jumped as each chunk landed. The shared - * skeleton carries a `min-h` floor — enough to hold the row open at cold - * mount — and lets the block grow into its true height without fighting a - * hard-coded pixel guess, killing the resolve-time layout shift. Reduced - * motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`). + * The Insights root fans out into ~6 mount queries after hydrate; the highest- + * value, cheapest, and safest to lift onto the server is the dashboard snapshot + * — the SAME `queryKeys.dashboardSnapshot(locale)` cell the dashboard warms + * (the hero's Tension Verdict + return-to-baseline ride it). Reading it through + * the shared `caches.analytics` SWR cell during SSR and handing it to TanStack + * via `HydrationBoundary` means an Insights visit no longer re-pays the snapshot + * round-trip on the client, and the hero's snapshot-derived band paints from the + * first HTML instead of after a client fetch. + * + * Contract notes (the v1.30.9 dashboard template): + * - THE CRUX — the snapshot cell is locale-keyed + * (`queryKeys.dashboardSnapshot(locale)`). The locale is resolved ONCE + * server-side by `readDashboardSnapshotCached` (via `resolveServerLocale`) + * and the client's `useDashboardSnapshot` keys on the SAME resolved locale + * from the i18n context — the identical mechanism the dashboard already + * relies on, so the server-seeded key equals the client 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. + * - The client snapshot cell reads `refetchOnMount: false`, so the seeded + * fresh value lands and is used without an immediate refetch. + * - The `["user","dashboardWidgets"]` layout the snapshot carries is seeded too + * (mirroring the client `queryFn`'s own seed), removing the widgets stage + * from the chart-overlay-prefs waterfall exactly as on the dashboard. + * - Left CLIENT-LAZY on purpose: the AI daily briefing (`GET /api/insights/ + * generate`) — prefetching it must never trigger provider generation + * (generation stays cron + explicit-button only), so it keeps its own lazy + * read; and the heavy thick-analytics / comprehensive aggregations, whose + * COLD SWR cell is a multi-query scan — prefetching them synchronously would + * trade FCP for TTFB. Both stay on the client cells that own them. + * - Fail-soft: no session or a builder hiccup renders the page exactly as + * before this wrapper existed — the client cells own the fetch. */ -function BlockSkeleton({ - minHeight, - decorative = false, -}: { - /** Tailwind `min-h-*` utility holding the row open before the chunk lands. */ - minHeight: string; - /** Decorative placeholders (cards that may un-mount) hide from a11y. */ - decorative?: boolean; -}) { - return ( - - ); -} - -const DailyBriefing = dynamic( - () => - import("@/components/insights/daily-briefing").then((mod) => ({ - default: mod.DailyBriefing, - })), - { - ssr: false, - loading: () => , - }, -); -const TrendsRow = dynamic( - () => - import("@/components/insights/trends-row").then((mod) => ({ - default: mod.TrendsRow, - })), - { - ssr: false, - loading: () => , - }, -); -// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so -// it renders as its own full-width section directly above the daily briefing. -// Both this strip and the vitals grid read the ONE shared derived batch the -// page owns (passed in via `batch`), so the lift adds no second request. -const WellnessScores = dynamic( - () => - import("@/components/insights/derived").then((mod) => ({ - default: mod.WellnessScores, - })), - { - ssr: false, - loading: () => , - }, -); -// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of -// personal-typical-range + passthrough-reframe tiles). Deferred behind -// `next/dynamic` like the other below-the-fold blocks; each tile owns its -// own derived-metric query and un-mounts when its vital is absent. -const VitalsDashboard = dynamic( - () => - import("@/components/insights/derived").then((mod) => ({ - default: mod.VitalsDashboard, - })), - { - ssr: false, - loading: () => , - }, -); -// v1.10.0 — categorical events (WX-B). The device-flagged event awareness -// timeline. Deferred like the other below-the-fold blocks; the card -// un-mounts itself when the user has no such events (no skeleton-then-empty -// flash — `ssr: false` + the card's own data gate), so it carries no -// loading placeholder of its own. -const RhythmEventsCard = dynamic( - () => - import("@/components/insights/rhythm-events-card").then((mod) => ({ - default: mod.RhythmEventsCard, - })), - { ssr: false }, -); -// v1.28.50 — ECG recording surface (waveform-backed). Deferred like the -// other below-the-fold blocks; the section un-mounts itself when the user -// has no recordings (`ssr: false` + its own data gate), so it carries no -// loading placeholder of its own. -const EcgSection = dynamic( - () => - import("@/components/insights/ecg-section").then((mod) => ({ - default: mod.EcgSection, - })), - { ssr: false }, -); -// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own -// read-only `/api/insights/health-status` query. -const HealthStatusCard = dynamic( - () => - import("@/components/insights/health-status-card").then((mod) => ({ - default: mod.HealthStatusCard, - })), - { ssr: false }, -); -// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening -// signal only, never a diagnosis. -const BreathingScreeningCard = dynamic( - () => - import("@/components/insights/breathing-screening-card").then((mod) => ({ - default: mod.BreathingScreeningCard, - })), - { ssr: false }, -); -// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer -// than two panels or no shared analyte. -const LabsChangesCard = dynamic( - () => - import("@/components/insights/labs-changes-card").then((mod) => ({ - default: mod.LabsChangesCard, - })), - { ssr: false }, -); -// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from -// a buried below-the-fold tile to the top-of-overview daily read (the -// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it -// owns its own derived-metric query and renders four calm states. The chunk -// loader's skeleton shares the card's `min-h-48` footprint (and the in-card -// `CardSkeleton` matches it too) so the top of the page does not shift across -// loading → any resolved state. Decorative → `aria-hidden`. -const CoincidentDeviationCard = dynamic( - () => - import("@/components/insights/coincident-deviation-card").then((mod) => ({ - default: mod.CoincidentDeviationCard, - })), - { - ssr: false, - loading: () => , - }, -); - -// v1.11.0 — period-narrative card (Pillar P1). The calm "your week/month in -// review" summary, drawn from the read-only stale-while-revalidate narrative -// route. Deferred behind `next/dynamic` like the other below-the-hero blocks; -// it owns its own query and un-mounts when no narrative exists. The loader -// skeleton shares the card's `min-h-40` footprint so the page does not shift. -const PeriodNarrativeCard = dynamic( - () => - import("@/components/insights/period-narrative-card").then((mod) => ({ - default: mod.PeriodNarrativeCard, - })), - { - ssr: false, - loading: () => , - }, -); - -// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when -// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the -// sidebar nav entry gates on), so the cycle reads never fire for an account -// without the feature. Deferred behind `next/dynamic` like the other -// below-the-hero blocks; it owns its own calendar + insights reads and renders -// nothing while resolving / on error, so it carries no loading placeholder. -const CycleInsightSummaryCard = dynamic( - () => - import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({ - default: mod.CycleInsightSummaryCard, - })), - { ssr: false }, -); - -// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a -// gated sibling tile (NOT the summary teaser — that stays further down). Mounted -// only for a cycle-tracking account, so its calendar read never fires otherwise. -// Deferred behind `next/dynamic`; it renders nothing while resolving / on error / -// when there is no active cycle, so it carries no loading placeholder. -const CycleRingTile = dynamic( - () => - import("@/components/cycle/cycle-ring-tile").then((mod) => ({ - default: mod.CycleRingTile, - })), - { ssr: false }, -); - -/** - * v1.4.25 W4d — Insights mother page. - * - * The page used to be a single 1.8k-LOC monolith that scroll-anchored - * six per-metric sections beneath the hero. W4a/c carved those out - * into routed sub-pages under `/insights/{slug}`; this file now holds - * the overview-only surface: - * - * - The sticky tab strip lives in `src/app/insights/layout.tsx` - * (the shared `` mounts it). The strip handles - * navigation to every sub-page + the regenerate affordance. - * - Hero + DailyBriefing + Trends row + advisor card stay here — - * they're the cross-metric overview. The per-metric correlation - * cards moved onto the metric pages they belong to (Weight owns - * weight × weekday, Pulse owns mood × pulse, …), so the overview - * no longer renders a duplicate correlation row. - * - The CoachDrawer is mounted in the mother-page body only (the maintainer - * directive). Navigating to a sub-page unmounts the drawer. - * - * The per-section status cards (BP/Weight/Pulse/etc.) and their - * heavy chart wiring moved to the matching sub-pages. - */ - -/** - * The mother page only checks whether the comprehensive payload arrived - * (the EmptyState gates on `!data`); the metric-specific shape lives on - * the sub-pages now. Keep this slim — anything more is dead weight here. - */ -interface ComprehensiveData { - totalMeasurements: number; -} - -export default function InsightsPage() { - const { isAuthenticated, user } = useAuth(); - const mounted = useMounted(); - const { t } = useTranslations(); - const queryClient = useQueryClient(); - - // v1.30.1 M12 — the overview fans out into ~6 independent queries - // (comprehensive / advisor / analytics / snapshot / derived batch / - // layout) mounted by different hooks below, so there's no single - // `refetch()` that covers the page. Refetching every currently-mounted - // ("active") query is the same blanket approach the audit's fallback - // suggests for PWA-resume staleness, and it's the right shape here - // regardless of resume vs. an explicit pull. - const refreshInsights = useCallback( - () => queryClient.refetchQueries({ type: "active" }), - [queryClient], - ); - const pull = usePullToRefresh({ onRefresh: refreshInsights }); - - // v1.4.33 IW9 — scroll-to-top on route mount centralised in the - // shared `useScrollResetOnRoute()` hook. The mother page + the - // `` both consume the same hook; the legacy duplicate - // RAF that lived here pre-v1.4.33 produced a visible double-snap on - // slow hydrates (chart skeletons inflating between the two - // callbacks). - useScrollResetOnRoute(); - - // v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level - // ``; the drawer is mounted next to the provider in - // `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach - // entry points (the action button + suggested-prompt chips), so the - // overview page no longer reads the launch context — the drawer + its - // own launcher stay untouched. - const flags = useFeatureFlags(); - - // v1.4.36 W1 — drop the page-level `isLoading` gate that used to - // block the entire shell on `/api/insights/comprehensive`. The - // comprehensive payload still feeds the empty-state decision but - // every other section now mounts in parallel under its own - // boundary, so the user sees the hero + tile skeletons - // within ~500 ms instead of waiting on the slowest fan-out. The - // empty-state branch only fires once the query has resolved AND - // reported zero measurements; while it's in-flight the page paints - // the regular shell and the tiles fill in as their data lands. - const { data, isLoading, isFetched, isError, refetch } = useQuery({ - queryKey: queryKeys.insightsComprehensive(), - queryFn: async () => { - try { - return await apiGet("/api/insights/comprehensive"); - } catch { - throw new Error(t("insights.loadError")); - } - }, - enabled: isAuthenticated, - }); - - // The advisor query is also mounted by the layout shell; the page - // consumer re-reads from the same cache key so this call is free - // beyond the React-state subscription. - const advisor = useInsightsAdvisorQuery(isAuthenticated); - - // v1.4.33 IW2 — the mother page reads `healthScore` (a thick-only - // field) for the hero score card, so it stays on the default thick - // slice. The shared hook still centralises the cache settings so the - // consumer dedups with the sub-page mounts that ride the slim slice - // instead. Correlations now live on the per-metric pages, so the - // overview no longer reads `analytics.correlations`. - const analyticsQuery = useAnalyticsQuery(); - const analytics = analyticsQuery.data as AnalyticsData | undefined; - - // v1.21.2 (A5 / A6) — the dashboard snapshot is the single server source - // for the ambient narrative the hero surfaces: the Tension Verdict and - // return-to-baseline ride its `healthScore`. The hero score number itself - // stays on the analytics payload (unchanged). Reads the same shared - // `queryKeys.dashboardSnapshot()` cell the dashboard warms, so the insights - // visit pays at most one extra round-trip and usually a warm cache hit. - // (The A4 recall/forward-look block left the briefing card — the card now - // opens straight on the day's signals.) - const snapshotQuery = useDashboardSnapshot(isAuthenticated); - const snapshotHealthScore = snapshotQuery.data?.healthScore ?? null; - const heroTension = snapshotHealthScore?.tension ?? null; - const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; - - // v1.12.6 — the page owns the ONE overview derived batch. The wellness - // strip (above the briefing) and the vitals grid (below it) both read this - // single query instance, so the wellness lift adds no second request. - const dashboardDerived = useDashboardDerived(isAuthenticated); - - // v1.15.11 W2 — the resolved overview layout (sections + tiles). Drives - // both the section render order/visibility below and the Vitals grid's - // per-tile order/visibility (passed into ). Defaults to - // the canonical layout while in-flight so the first paint matches the - // default order with no flicker. - // v1.15.18 — the overview layout (sections + tiles) still drives the section - // render order/visibility + the Vitals grid below. CUSTOMISING it (and the - // nav-pill order) moved out of an in-page "Anpassen" edit mode into - // Settings → Insights, reached via the top-right cog on the tab strip. - const { layout } = useInsightsLayoutQuery(isAuthenticated); - - // Error branch — a transient 500 / network drop (after the query's - // retries are exhausted) settles the comprehensive query with no data. - // Without this gate the page would fall through to the "no data yet — - // add a measurement" empty-state, which reads false for a user with - // history. Surface an error + a Retry that refetches the one query, - // mirroring the error pattern. - if (isError) { - return ( -
- refetch()} - /> -
- ); +export default async function InsightsPage() { + // 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 ; } - // Empty-state shortcut — only paint once the comprehensive query has - // resolved AND reported zero measurements. While it's in-flight we - // fall through to the streamed shell so the user gets the hero + - // skeleton tiles inside the first paint budget. - if (!isLoading && isFetched && !data) { - return ( - } - title={t("insights.emptyTitle")} - description={t("insights.emptyDescription")} - ctaSize="lg" - action={ - - } - /> - ); + let dehydratedState = null; + try { + const session = await getSession(); + if (session) { + const { user } = session; + const { body, locale } = await readDashboardSnapshotCached(user); + // Match the client cell's wire shape exactly (JSON semantics, ISO date + // strings) — same-key-different-shape is silent cache poison. + const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record< + string, + unknown + >; + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody); + queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout); + dehydratedState = dehydrate(queryClient); + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. } - // Gated on `mounted`: the auth query can resolve before this - // boundary hydrates, and a greeting that personalises during the - // hydration render disagrees with the name-less SSR text — React - // #418. See `useMounted`. - const heroGreetingName = - mounted && user?.username?.trim() && user.username.trim().length > 0 - ? user.username.split(/\s+/)[0] - : null; - const briefingPayload = advisor.payload?.dailyBriefing ?? null; - const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null; - - // v1.4.36 QA C2 — no `` wrappers below. The mother page is - // `"use client"`, the below-the-fold blocks load via `next/dynamic` - // with `{ ssr: false }` (no Promise-throw on hydrate), and the - // TanStack Query hooks each return their own loading state without - // ever throwing a thenable. Wrapping these in `` would be - // dead code — Suspense never engages and the `loading` props inside - // each section already drive the skeleton. The perceptual win we - // ship is "early-skeleton paint": the page-level `isLoading` gate is - // gone, the hero + each section's own loader skeleton paints inside - // the first paint budget while data fills in. Genuinely streamed - // server children would require a Server-Component refactor; that's - // a v1.5.x track. - - // v1.12.6 — overview section order, top → bottom: - // 1. "Guten Morgen" Hero + Coach questions (HeroStrip). - // 2. Wellnesswerte — the wellness-score strip (lifted OUT of - // so it sits ABOVE the briefing as its own - // full-width section). - // 3. "Heute auf einen Blick" briefing (DailyBriefing). - // 4. Vitalwerte — the vitals grid (+ mobility) inside , - // which no longer renders the wellness strip. - // 5. Trends (TrendsRow). - // 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). - // 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus - // the rhythm-events alert timeline, kept out of the AI gate so a - // health alert is never hidden. - // The wellness strip and the vitals grid share the ONE `dashboardDerived` - // batch the page owns — one request, two sections. The per-metric - // correlation cards moved onto the metric pages, so the overview renders no - // correlation row. The duplicate footer "prepare assessments" control was - // removed in v1.12.4 — the tab-strip regenerate button is the single - // affordance. The generic disclaimer lives once in the layout-shell footer. - - // v1.15.11 W2 — decouple the section render order from the JSX. Each section - // id maps to its existing node here; the customizable region below renders - // `orderedVisibleSectionIds(layout)` against this registry. Every existing - // feature/data gate is preserved INSIDE each entry: a section the layout - // marks visible but whose gate is off (briefing flag, cycle-enabled) still - // resolves to `null`, exactly as today. With the default layout the order + - // gates reproduce the pre-v1.15.11 page byte-for-byte. HeroStrip stays - // anchored above this region, OUTSIDE the customizable set. - const SECTION_REGISTRY: Record = { - "wellness-scores": ( - : undefined - } - // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: - // hide Strain so the strip stays compact instead of growing a sixth - // tile. Strain stays visible for non-cycle accounts. - hideStrain={user?.modules?.cycle === true} - /> - ), - "daily-briefing": flags.briefing ? ( - - ) : null, - vitals: , - trends: ( - - ), - "period-review": flags.briefing ? ( - - ) : null, - // v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account; - // for everyone else this is nothing (no card, no layout gap). The card - // itself stays silent until its reads resolve. - "cycle-summary": user?.cycleTrackingEnabled ? ( - - ) : null, - signals: , - "rhythm-events": , - "health-status": , - breathing: , - "labs-changes": , - ecg: , - }; - - const orderedSectionIds = orderedVisibleSectionIds(layout); - const everySectionHidden = orderedSectionIds.length === 0; - + if (dehydratedState === null) { + return ; + } return ( - // v1.12.7 (L3) — one consistent vertical rhythm down the overview. The - // page used `space-y-8` (32 px) between top-level blocks while the vitals - // wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify - // to `space-y-6` — it matches the vitals wrap and tightens the overview in - // line with the "Insights gives away too much space" direction. - // - // v1.15.10 — `space-y-6` is now the SINGLE inter-section gap, top to - // bottom. Every section renders as a real `
` (or `null` — never a - // `display: contents` wrapper, which used to collapse the cycle-summary box - // so `space-y` skipped its margin and the cycle→signals seam read as - // zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its - // card, so the rhythm is even regardless of which sections are present. -
- - " line with a discreet connect-provider hint, matching - // the briefing card footer. Only when a briefing is actually shown. - noProviderStale={briefingPayload !== null && !advisor.hasProvider} - // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- - // baseline off the dashboard snapshot. The hero localises the keys and - // forwards them to the score card; null leaves the card quiet. - tension={heroTension} - returnToBand={heroReturnToBand} - /> - - {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the - overview (section show/hide + order) and sorting the nav pills now - live ONLY in Settings → Insights, reached via the top-right cog on the - tab strip — no duplicate in-page entry point. */} - {everySectionHidden ? ( - /* v1.15.18 — empty-state: every section hidden. The page is never - blank — surface a hint + a link to the Insights settings section so - the user can bring sections back. */ - } - title={t("insights.editMode.emptyTitle")} - description={t("insights.editMode.emptyDescription")} - ctaSize="lg" - action={ - - } - /> - ) : ( - /* v1.15.11 W2 — the customizable region: sections render in the - resolved layout order, skipping any the user has hidden. A - layout-visible-but-gate-off section resolves to `null` from the - registry, so the `space-y-6` rhythm closes the gap with no hole. - Each registry node is wrapped in a keyed Fragment so React keeps a - stable identity across a reorder. */ - orderedSectionIds.map((id) => ( - {SECTION_REGISTRY[id]} - )) - )} -
+ + + ); } diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx index db9dd60c7..9d61ff953 100644 --- a/src/app/medications/__tests__/list-view-toggle.test.tsx +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -34,7 +34,9 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({ LogIntakeDialog: () => null, })); -import MedicationsPage from "@/app/medications/page"; +// v1.30.x — `@/app/medications/page` is now an async RSC prefetch wrapper; the +// interactive surface this test renders is the client leaf it wraps. +import MedicationsPage from "@/app/medications/page-client"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { MedicationListLayout } from "@/lib/medication-list-layout"; diff --git a/src/app/medications/__tests__/page-prefetch.test.tsx b/src/app/medications/__tests__/page-prefetch.test.tsx new file mode 100644 index 000000000..2618b2176 --- /dev/null +++ b/src/app/medications/__tests__/page-prefetch.test.tsx @@ -0,0 +1,122 @@ +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"; + +/** + * v1.30.x — the `/medications` server-prefetch key crux + fail-soft. + * + * The RSC wrapper (`src/app/medications/page.tsx`) dehydrates the medications + * list under `queryKeys.medications()` so the client cell reads it back on + * mount instead of flashing skeletons. These tests pin the load-bearing rules: + * - the server dehydrates under the EXACT client key (byte-identical hash); + * - the value is JSON-round-tripped to the wire shape (Dates → ISO strings); + * - any prefetch error / a disabled module / no session fails soft to the + * bare client path (the client cell owns the fetch). + */ + +const getSession = vi.fn(); +const resolveModuleMap = vi.fn(); +const readMedicationsListCached = vi.fn(); + +vi.mock("@/lib/auth/session", () => ({ getSession: () => getSession() })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (id: string) => resolveModuleMap(id), +})); +vi.mock("@/lib/medications/list-read", () => ({ + readMedicationsListCached: (u: unknown) => readMedicationsListCached(u), +})); +vi.mock("../page-client", () => ({ + default: () => null, +})); + +import MedicationsPage from "../page"; + +const SESSION = { + user: { id: "u1", timezone: "Europe/Berlin", disableCoach: false }, +}; + +afterEach(() => { + vi.clearAllMocks(); + delete process.env.DASHBOARD_SSR_PREFETCH; +}); + +/** The single dehydrated query on a HydrationBoundary element, or null. */ +function dehydratedQuery( + el: ReactElement, +): { queryHash: string; state: { data: unknown } } | null { + if (el.type !== HydrationBoundary) return null; + const props = el.props as { + state?: { queries: { queryHash: string; state: { data: unknown } }[] }; + }; + const q = props.state?.queries?.[0]; + return q ? { queryHash: q.queryHash, state: q.state } : null; +} + +describe("/medications RSC prefetch", () => { + it("dehydrates the list under the EXACT client key (byte-identical hash)", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ medications: true }); + readMedicationsListCached.mockResolvedValue([ + { id: "m1", name: "Ramipril" }, + ]); + + const el = (await MedicationsPage()) as ReactElement; + const q = dehydratedQuery(el); + expect(q).not.toBeNull(); + // The client cell looks up `queryKeys.medications()`; the server MUST have + // dehydrated under the same hash or the prefetch silently no-ops. + expect(q!.queryHash).toBe(hashKey(queryKeys.medications())); + expect(q!.state.data).toEqual([{ id: "m1", name: "Ramipril" }]); + }); + + it("JSON-round-trips the value to the wire shape (Dates → ISO strings)", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ medications: true }); + const takenAt = new Date("2026-07-18T08:30:00.000Z"); + readMedicationsListCached.mockResolvedValue([ + { id: "m1", createdAt: takenAt, schedules: [{ id: "s1" }] }, + ]); + + const el = (await MedicationsPage()) as ReactElement; + const q = dehydratedQuery(el); + // A Date must land as its ISO string — the shape the client's + // (await res.json()).data would carry — never a live Date object. + expect((q!.state.data as { createdAt: unknown }[])[0].createdAt).toBe( + takenAt.toISOString(), + ); + }); + + it("fails soft to the bare client when the read throws", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ medications: true }); + readMedicationsListCached.mockRejectedValue(new Error("db blip")); + + const el = (await MedicationsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + }); + + it("skips the prefetch when the medications module is off", async () => { + getSession.mockResolvedValue(SESSION); + resolveModuleMap.mockResolvedValue({ medications: false }); + + const el = (await MedicationsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readMedicationsListCached).not.toHaveBeenCalled(); + }); + + it("fails soft when there is no session", async () => { + getSession.mockResolvedValue(null); + const el = (await MedicationsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(readMedicationsListCached).not.toHaveBeenCalled(); + }); + + it("honours the DASHBOARD_SSR_PREFETCH kill-switch (no session read)", async () => { + process.env.DASHBOARD_SSR_PREFETCH = "false"; + const el = (await MedicationsPage()) as ReactElement; + expect(el.type).not.toBe(HydrationBoundary); + expect(getSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx new file mode 100644 index 000000000..63d2542a0 --- /dev/null +++ b/src/app/medications/page-client.tsx @@ -0,0 +1,636 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; +import { MedicationCard } from "@/components/medications/medication-card"; +import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; +import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; +import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog"; +import { deriveDueMedications } from "@/components/medications/take-all-due"; +import { + MedicationTable, + MedicationTableSkeleton, +} from "@/components/medications/medication-table"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + CheckCheck, + CheckCircle2, + Loader2, + Pill, + Plus, + Wrench, +} from "lucide-react"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; +import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; +import { applyMedicationOrder } from "@/lib/medications/medication-order"; + +interface Schedule { + id: string; + windowStart: string; + windowEnd: string; + label: string | null; + dose: string | null; + daysOfWeek: string | null; + /** v1.5 — first-class times-of-day. */ + timesOfDay?: string[]; + /** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */ + rrule?: string | null; + /** v1.5 — flexible-rolling interval in days. */ + rollingIntervalDays?: number | null; + /** v1.5 — reminder grace window in minutes. */ + reminderGraceMinutes?: number | null; + /** v1.16.1 — explicit per-dose on-time windows (band model). */ + doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; +} + +interface Medication { + id: string; + name: string; + dose: string; + category: string; + /** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */ + treatmentClass?: string; + /** v1.4.25 W4d — doses per pen/vial for inventory math. */ + dosesPerUnit?: number | null; + /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ + deliveryForm?: string; + /** v1.8.5 — per-medication injection-site tracking opt-in. */ + trackInjectionSites?: boolean; + /** v1.8.5 — per-medication allowed / preferred injection sites. */ + allowedInjectionSites?: string[]; + active: boolean; + notificationsEnabled: boolean; + pausedAt: string | null; + lastTakenAt: string | null; + /** List-only count of today's ACTIONED intake events (taken/skipped). */ + todayEventCount?: number; + /** + * v1.8.4 — server-computed next due instant (canonical recurrence + * engine). Threaded straight through to both card variants, which render + * it instead of re-deriving the timestamp client-side. + */ + nextDueAt?: string | null; + /** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */ + nextDueOverdue?: boolean; + /** v1.5 — medication-level course start date (ISO string). */ + startsOn?: string | null; + /** v1.5 — medication-level course end date (ISO string). */ + endsOn?: string | null; + /** v1.5 — single-administration medication. */ + oneShot?: boolean; + /** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */ + asNeeded?: boolean; + /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ + atcCode?: string | null; + /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ + rxNormCode?: string | null; + /** + * v1.16.10 — usable inventory units left across the medication's + * containers (ACTIVE / IN_USE with units remaining). Null = inventory + * tracking off (no items ever registered). + */ + stockUnitsRemaining?: number | null; + /** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */ + stockDosesRemaining?: number | null; + schedules: Schedule[]; +} + +/** + * v1.11.3 C4 — loading placeholder for one medication card. Renders the + * real `Card`/`CardContent` shell (so padding, radius and gap match the + * loaded card exactly) with the shared `Skeleton` primitive standing in + * for each populated slot: header title + dose, status pill, next / last + * line, the two compliance bars and the action row. Replacing the bare + * centred spinner with a grid of these stops the layout jump when the + * cards resolve. + */ +function MedicationCardSkeleton() { + return ( + + ); +} + +export default function MedicationsPageClient() { + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); + const { t } = useTranslations(); + // v1.18.1 (D3) — medications is an opt-out module. When the account has it + // turned off the whole page disappears (the nav entry is hidden by the same + // gate) and the list query never fires (the API would 403 anyway). + // Default-on: an absent key reads as enabled, so the page only hides on an + // explicit `false`. + const medicationsEnabled = user?.modules?.medications !== false; + const router = useRouter(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + // v1.5.4 — the retired `/medications/new` route redirects here with + // `?new=1`, so legacy bookmarks keep landing on the create wizard. + // The initial open state reads the query param synchronously so the + // dialog opens on the very first render; a follow-up effect strips + // the param from the URL so a manual close + refresh stays closed. + const shouldOpenFromUrl = searchParams?.get("new") === "1"; + const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); + // v1.29.x — the Today digest's "Log dose" action deep-links straight to + // the overdue medication (`/medications?highlight=`) instead of the + // bare list. Read once on mount; a scroll effect below (once the list + // resolves) brings the target card into view and rings it briefly, then + // clears the state so the ring doesn't linger indefinitely. + const [highlightId, setHighlightId] = useState( + () => searchParams?.get("highlight") ?? null, + ); + // v1.14.0 — the medications-page "Add" choice. The top button now offers + // two paths: log an intake (incl. a backdated one) against an existing + // medication, or create a new medication (the existing wizard). + const [logIntakeOpen, setLogIntakeOpen] = useState(false); + // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header + // button earns its slot only while ≥ 2 medications are currently due. + const [takeAllOpen, setTakeAllOpen] = useState(false); + + // v1.16.10 — the persisted list presentation: cards vs table plus the + // manual medication order, server-side per user + // (`GET`/`PUT /api/medications/layout`). The toggle writes + // optimistically; the order editor lives at /settings/medications. + const { layout, isLayoutLoading } = useMedicationListLayout(isAuthenticated); + + useEffect(() => { + if (shouldOpenFromUrl) { + // Drop the query param so a refresh after closing the dialog + // doesn't keep reopening it. + router.replace("/medications"); + } + }, [shouldOpenFromUrl, router]); + + const { + data: medications, + isLoading, + isError, + refetch: refetchMedications, + } = useQuery({ + queryKey: queryKeys.medications(), + queryFn: async () => { + return apiGet("/api/medications"); + }, + enabled: isAuthenticated && medicationsEnabled, + // v1.16.12 (#316) — refetch on every mount, even inside the global + // staleTime window. App Router unmounts this page on navigation away + // and remounts it on return; without this, a return within 5 min + // serves the cached list and shows nothing the user changed on + // another surface (the iOS app, a Withings sync, a second tab) — the + // client cache can't know to invalidate a cross-device write. "always" + // makes a return-visit fetch fresh; the per-user server cache keeps it + // cheap. Mutation-driven invalidation still covers same-tab writes. + refetchOnMount: "always", + }); + + // v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements. + // Medications is one of the sharpest PWA-resume-staleness surfaces (a + // background-refreshed take/skip from a notification, or a cross-device + // write, wouldn't otherwise show up until the 5-min staleTime lapses or a + // full remount re-triggers `refetchOnMount: "always"` above). + const refreshMedications = useCallback( + () => invalidateKeys(queryClient, medicationDependentKeys), + [queryClient], + ); + const pull = usePullToRefresh({ onRefresh: refreshMedications }); + + // v1.29.x — once the highlighted card is in the DOM, scroll it into view + // and clear the highlight state after a few seconds so the ring is a + // one-time "you tapped here" cue, not a permanent tint (mirrors the + // vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in + // `use-document-upload.ts`). + useEffect(() => { + if (!highlightId || !medications?.length) return; + const card = document.querySelector( + `[data-medication-id="${highlightId}"]`, + ); + card?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlightId(null), 3000); + return () => clearTimeout(timer); + }, [highlightId, medications]); + + // v1.16.11 — the same reminder-thresholds read the cards make (shared + // key + cache), so the header's due-set derivation tiers late / + // very_late exactly like the card pills. Null on failure keeps the + // derivation on the 120/240 defaults the cards also fall back to. + const { data: thresholds } = useQuery({ + queryKey: queryKeys.settingsReminderThresholds(), + queryFn: async () => { + try { + return await apiGet<{ + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + }>("/api/settings/reminder-thresholds"); + } catch { + return null; + } + }, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + + // v1.16.11 — the SAME batched compliance rows the cards / table read + // (shared key + cache, no extra round trip). The due-set derivation + // needs each medication's compliance dose status so an escalated + // (overdue / missed past its band tail) medication — which still + // renders an active take button on its red card — joins the + // take-all-due set. + const { data: complianceRows } = useMedicationComplianceSummaryAll(); + + function openCreate() { + setDialogOpen(true); + } + + // v1.15.20 — seed the detail page's query from the list row before + // navigating. The list response is a superset of the detail GET + // (`{...medication, category, nextDueAt}` plus the list-only + // `lastTakenAt` / `todayEventCount`), so the detail shell paints + // instantly from the seeded cache while its own un-gated query + // refetches the authoritative row in the background. + function seedDetail(med: Medication) { + queryClient.setQueryData(queryKeys.medicationDetail(med.id), med); + } + + // v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab, + // exactly like tapping the medication name. Every everyday edit lives + // inline in the tabs there; the structural wizard is reachable via + // Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links). + // History still lands on the Verlauf tab. + function openEdit(med: Medication) { + seedDetail(med); + router.push(`/medications/${med.id}`); + } + + function openHistory(med: Medication) { + seedDetail(med); + router.push(`/medications/${med.id}?tab=verlauf`); + } + + function closeDialog() { + setDialogOpen(false); + } + + if (authLoading) { + return ( +
+ +
+ ); + } + + // Module off ⇒ the surface disappears entirely. The nav entry is hidden by + // the same gate, so a direct hit renders nothing rather than an orphaned + // shell. + if (isAuthenticated && !medicationsEnabled) { + return null; + } + + if (!isAuthenticated) { + return ( +
+
+

+ {t("medications.title")} +

+

+ {t("medications.loginRequired")} +

+
+
+ ); + } + + // Defensive against stale service-worker responses or any future API + // shape change: only filter when we actually have an array. + const medsArray = Array.isArray(medications) ? medications : []; + // v1.16.10 — the user-defined manual order applies to BOTH views + // (cards and table). Medications not in the saved order keep the + // alphabetical default, appended after the ordered block. + const activeMeds = applyMedicationOrder( + medsArray.filter((m) => m.active), + layout.order, + ); + const inactiveMeds = applyMedicationOrder( + medsArray.filter((m) => !m.active), + layout.order, + ); + const tableView = layout.view === "table"; + + // v1.16.11 (#316) — the currently-due set, derived from the list payload + // the page already holds via the SAME pipeline a card's pill runs (band + // model + server display-due gate + taken-early downgrade), plus the + // batched compliance dose status so escalated (overdue / missed) + // medications past their band tail stay takeable from the batch. Page + // order is preserved so the confirm dialog lists medications as rendered. + const dueMeds = deriveDueMedications(activeMeds, { + tz: user?.timezone || "Europe/Berlin", + thresholds: thresholds ?? undefined, + doseStatusById: new Map( + (complianceRows ?? []).map((row) => [ + row.medicationId, + row.complianceDisplay?.currentDose.status ?? "upcoming", + ]), + ), + }); + + return ( +
+ +
+
+

+ {t("medications.title")} +

+ {/* v1.4.34 IW-G — subtitle stays visible on mobile so the + H1 isn't an unframed label. */} +

+ {t("medications.subtitle")} +

+
+
+ {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a + permanent fixture: it renders only while at least TWO + medications are currently due (a single due dose is the + card's own one-tap job). Calm outline variant so the primary + Add button keeps the visual lead; same responsive 44-px + mobile tap floor as its neighbours. */} + {dueMeds.length >= 2 && ( + + )} + {/* v1.16.11 — the wrench is the one customize entry point: + it links to /settings/medications, which owns the view + preference (cards ⇄ table) and the manual-order editor. + Same glyph, slot (left of the add button) and responsive + 44-px mobile tap floor as the dashboard and insights + headers. */} + + {/* v1.14.0 — the "Add" button is now a choice: log an intake + (incl. a backdated one) against an existing medication, or + create a new medication. v1.12.2 — match the dashboard Add + button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so + both primary "add" entry points clear the WCAG 2.5.5 44px mobile + minimum identically. */} + + + + + + setLogIntakeOpen(true)} + disabled={activeMeds.length === 0} + > + + {t("medications.addChoice.logIntake")} + + + + {t("medications.addChoice.newMedication")} + + + +
+
+ + {isLoading || isLayoutLoading ? ( + // v1.11.3 C4 — skeletons instead of a bare centred spinner so the + // page reserves the loaded layout and does not jump when the + // medications resolve. v1.16.10 — the skeleton follows the + // persisted view (table rows vs card grid) so resolving the data + // never swaps the footprint; while the view preference itself is + // still loading the default cards skeleton stands in. +
+ {tableView && !isLayoutLoading ? ( + + ) : ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} +
+ ) : isError ? ( + void refetchMedications()} + /> + ) : !medications?.length ? ( + // v1.4.15 phase-C5: refactor the inline icon+text+button block + // to the shared EmptyState primitive so the empty path matches + // every other list page in the app (role=status, dashed + // border, consistent icon-bubble + spacing). + } + title={t("medications.emptyTitle")} + description={t("medications.emptyDescription")} + action={ + + } + /> + ) : tableView ? ( + // v1.16.10 — the compact table view. Same payloads, same status + // gating, same mutation hooks as the cards; one row per + // medication, inactive rows pinned after the active block. + + ) : ( +
+ {/* Active medications */} + {activeMeds.length > 0 && ( +
+
+ {activeMeds.map((med) => + // v1.16.11 — an as-needed medication always renders the + // generic card: the GLP-1 variant is built around the + // rolling injection cadence an as-needed med doesn't have. + med.treatmentClass === "GLP1" && !med.asNeeded ? ( + + ) : ( + + ), + )} +
+
+ )} + + {/* Inactive medications */} + {inactiveMeds.length > 0 && ( +
+

+ {t("common.inactive")} ({inactiveMeds.length}) +

+
+ {inactiveMeds.map((med) => + med.treatmentClass === "GLP1" && !med.asNeeded ? ( + + ) : ( + + ), + )} +
+
+ )} +
+ )} + + {/* v1.5.5 — the per-card kebab triggers used to open inline + IntakeImportDialog / ApiEndpointDialog mounts here, but both + state slots were only ever set from inside the dialogs + themselves (never from a list-card prop) so the inline + mounts were dead code. v1.5.5 retires both on the list page: + the import dialog lives in the detail-page intake-history + header (see `src/components/medications/intake-import-dialog.tsx`), + the API endpoint moves to the detail-page Settings → Externe + Integration sub-row. */} + + {/* v1.15.18 — create-only wizard mount. Editing an existing + medication now navigates to the detail page's tabs (the hero's + "Vollständig bearbeiten" reopens the wizard in edit mode from + there); the list page wizard only ever creates. The wizard owns + its own ResponsiveSheet shell with the sticky footer. */} + + + {/* v1.14.0 — log-intake path of the "Add" choice. Lists the active + medications, lets the user pin a schedule slot, and picks a + date+time that supports backdating. Submits to the existing + per-medication intake route (same slot upsert as a live "Taken" + tap) so the one-row-per-dose-slot invariant holds. */} + {/* v1.16.11 (#316) — confirm-all dialog for the due set. Records + through the per-medication intake route in a loop (slot + attribution + inventory consumption identical to N individual + taps — see take-all-due.ts); failed medications stay due. */} + {takeAllOpen && ( + + )} + + {logIntakeOpen && ( + ({ + id: m.id, + name: m.name, + dose: m.dose, + schedules: m.schedules.map((s) => ({ + windowStart: s.windowStart, + label: s.label, + dose: s.dose, + timesOfDay: s.timesOfDay, + })), + }))} + /> + )} +
+ ); +} diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index a01645277..44a96538b 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -1,636 +1,87 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - medicationDependentKeys, - queryKeys, -} from "@/lib/query-keys"; -import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; -import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; -import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; -import { MedicationCard } from "@/components/medications/medication-card"; -import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; -import { LogIntakeDialog } from "@/components/medications/log-intake-dialog"; -import { TakeAllDueDialog } from "@/components/medications/take-all-due-dialog"; -import { deriveDueMedications } from "@/components/medications/take-all-due"; -import { - MedicationTable, - MedicationTableSkeleton, -} from "@/components/medications/medication-table"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { EmptyState } from "@/components/ui/empty-state"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - CheckCheck, - CheckCircle2, - Loader2, - Pill, - Plus, - Wrench, -} from "lucide-react"; -import { apiGet } from "@/lib/api/api-fetch"; -import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary"; -import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; -import { applyMedicationOrder } from "@/lib/medications/medication-order"; +import { getSession } from "@/lib/auth/session"; +import { readMedicationsListCached } from "@/lib/medications/list-read"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { queryKeys } from "@/lib/query-keys"; -interface Schedule { - id: string; - windowStart: string; - windowEnd: string; - label: string | null; - dose: string | null; - daysOfWeek: string | null; - /** v1.5 — first-class times-of-day. */ - timesOfDay?: string[]; - /** v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. */ - rrule?: string | null; - /** v1.5 — flexible-rolling interval in days. */ - rollingIntervalDays?: number | null; - /** v1.5 — reminder grace window in minutes. */ - reminderGraceMinutes?: number | null; - /** v1.16.1 — explicit per-dose on-time windows (band model). */ - doseWindows?: { timeOfDay: string; start: string; end: string }[] | null; -} - -interface Medication { - id: string; - name: string; - dose: string; - category: string; - /** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */ - treatmentClass?: string; - /** v1.4.25 W4d — doses per pen/vial for inventory math. */ - dosesPerUnit?: number | null; - /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ - deliveryForm?: string; - /** v1.8.5 — per-medication injection-site tracking opt-in. */ - trackInjectionSites?: boolean; - /** v1.8.5 — per-medication allowed / preferred injection sites. */ - allowedInjectionSites?: string[]; - active: boolean; - notificationsEnabled: boolean; - pausedAt: string | null; - lastTakenAt: string | null; - /** List-only count of today's ACTIONED intake events (taken/skipped). */ - todayEventCount?: number; - /** - * v1.8.4 — server-computed next due instant (canonical recurrence - * engine). Threaded straight through to both card variants, which render - * it instead of re-deriving the timestamp client-side. - */ - nextDueAt?: string | null; - /** v1.16.4 — true when `nextDueAt` is an OPEN overdue slot. */ - nextDueOverdue?: boolean; - /** v1.5 — medication-level course start date (ISO string). */ - startsOn?: string | null; - /** v1.5 — medication-level course end date (ISO string). */ - endsOn?: string | null; - /** v1.5 — single-administration medication. */ - oneShot?: boolean; - /** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */ - asNeeded?: boolean; - /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ - atcCode?: string | null; - /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ - rxNormCode?: string | null; - /** - * v1.16.10 — usable inventory units left across the medication's - * containers (ACTIVE / IN_USE with units remaining). Null = inventory - * tracking off (no items ever registered). - */ - stockUnitsRemaining?: number | null; - /** v1.16.10 — dose-derived stock (`stockUnitsRemaining / unitsPerDose`). */ - stockDosesRemaining?: number | null; - schedules: Schedule[]; -} +import MedicationsPageClient from "./page-client"; /** - * v1.11.3 C4 — loading placeholder for one medication card. Renders the - * real `Card`/`CardContent` shell (so padding, radius and gap match the - * loaded card exactly) with the shared `Skeleton` primitive standing in - * for each populated slot: header title + dose, status pill, next / last - * line, the two compliance bars and the action row. Replacing the bare - * centred spinner with a grid of these stops the layout jump when the - * cards resolve. + * Thin RSC wrapper around the (client) medications page. + * + * `/medications` is a high-traffic surface whose above-the-fold content — the + * medication cards — waited for the client `queryKeys.medications()` cell to + * fetch `/api/medications` after hydrate, so the first paint flashed skeletons + * before the list filled in. This wrapper runs the SAME cached read the API + * route uses (`readMedicationsListCached`, the shared `caches.medications` SWR + * cell) during SSR and hands it to TanStack through `HydrationBoundary`, so the + * mounted list cell starts warm instead of skeleton-first. + * + * Contract notes (the v1.30.9 dashboard template): + * - The query key comes ONLY from the central factory — `queryKeys.medications()` + * is a deterministic zero-arg tuple, so the server-seeded key equals the + * client's `useQuery` key by construction (no window/locale param to drift). + * - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is exactly + * what the client `queryFn` produces from the wire ((await res.json()).data — + * Prisma `Date`s as ISO strings, Decimals as their JSON form), never a + * Date-carrying sibling that would poison the cell. + * - Module-gate parity: the client list query is gated on the `medications` + * module being on; skip the prefetch when it is off (the client cell is + * disabled then too, and the page renders nothing). + * - The client cell keeps `refetchOnMount: "always"` — it is a correctness + * gate, not a redundancy: a cross-device / cross-surface take/skip (the iOS + * app, a notification action, a second tab) produces no client mutation + * event, so the list must re-verify on every mount. With the prefetch in + * place the hydrated cards paint immediately and that refetch runs in the + * background — the "empty then fills" flash is gone WITHOUT dropping the + * freshness guarantee. + * - Fail-soft: no session, a module lookup hiccup, or a DB blip renders the + * page exactly as before this wrapper existed — the client cell owns the + * fetch. The prefetch is an accelerator, never a gate. */ -function MedicationCardSkeleton() { - return ( - - ); -} +export default async function MedicationsPage() { + // 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 ; + } -export default function MedicationsPage() { - const { user, isAuthenticated, isLoading: authLoading } = useAuth(); - const { t } = useTranslations(); - // v1.18.1 (D3) — medications is an opt-out module. When the account has it - // turned off the whole page disappears (the nav entry is hidden by the same - // gate) and the list query never fires (the API would 403 anyway). - // Default-on: an absent key reads as enabled, so the page only hides on an - // explicit `false`. - const medicationsEnabled = user?.modules?.medications !== false; - const router = useRouter(); - const queryClient = useQueryClient(); - const searchParams = useSearchParams(); - // v1.5.4 — the retired `/medications/new` route redirects here with - // `?new=1`, so legacy bookmarks keep landing on the create wizard. - // The initial open state reads the query param synchronously so the - // dialog opens on the very first render; a follow-up effect strips - // the param from the URL so a manual close + refresh stays closed. - const shouldOpenFromUrl = searchParams?.get("new") === "1"; - const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); - // v1.29.x — the Today digest's "Log dose" action deep-links straight to - // the overdue medication (`/medications?highlight=`) instead of the - // bare list. Read once on mount; a scroll effect below (once the list - // resolves) brings the target card into view and rings it briefly, then - // clears the state so the ring doesn't linger indefinitely. - const [highlightId, setHighlightId] = useState( - () => searchParams?.get("highlight") ?? null, - ); - // v1.14.0 — the medications-page "Add" choice. The top button now offers - // two paths: log an intake (incl. a backdated one) against an existing - // medication, or create a new medication (the existing wizard). - const [logIntakeOpen, setLogIntakeOpen] = useState(false); - // v1.16.11 (#316) — "Alle fälligen einnehmen" confirm dialog. The header - // button earns its slot only while ≥ 2 medications are currently due. - const [takeAllOpen, setTakeAllOpen] = useState(false); - - // v1.16.10 — the persisted list presentation: cards vs table plus the - // manual medication order, server-side per user - // (`GET`/`PUT /api/medications/layout`). The toggle writes - // optimistically; the order editor lives at /settings/medications. - const { layout, isLayoutLoading } = useMedicationListLayout(isAuthenticated); - - useEffect(() => { - if (shouldOpenFromUrl) { - // Drop the query param so a refresh after closing the dialog - // doesn't keep reopening it. - router.replace("/medications"); - } - }, [shouldOpenFromUrl, router]); - - const { - data: medications, - isLoading, - isError, - refetch: refetchMedications, - } = useQuery({ - queryKey: queryKeys.medications(), - queryFn: async () => { - return apiGet("/api/medications"); - }, - enabled: isAuthenticated && medicationsEnabled, - // v1.16.12 (#316) — refetch on every mount, even inside the global - // staleTime window. App Router unmounts this page on navigation away - // and remounts it on return; without this, a return within 5 min - // serves the cached list and shows nothing the user changed on - // another surface (the iOS app, a Withings sync, a second tab) — the - // client cache can't know to invalidate a cross-device write. "always" - // makes a return-visit fetch fresh; the per-user server cache keeps it - // cheap. Mutation-driven invalidation still covers same-tab writes. - refetchOnMount: "always", - }); - - // v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements. - // Medications is one of the sharpest PWA-resume-staleness surfaces (a - // background-refreshed take/skip from a notification, or a cross-device - // write, wouldn't otherwise show up until the 5-min staleTime lapses or a - // full remount re-triggers `refetchOnMount: "always"` above). - const refreshMedications = useCallback( - () => invalidateKeys(queryClient, medicationDependentKeys), - [queryClient], - ); - const pull = usePullToRefresh({ onRefresh: refreshMedications }); - - // v1.29.x — once the highlighted card is in the DOM, scroll it into view - // and clear the highlight state after a few seconds so the ring is a - // one-time "you tapped here" cue, not a permanent tint (mirrors the - // vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in - // `use-document-upload.ts`). - useEffect(() => { - if (!highlightId || !medications?.length) return; - const card = document.querySelector( - `[data-medication-id="${highlightId}"]`, - ); - card?.scrollIntoView({ behavior: "smooth", block: "center" }); - const timer = setTimeout(() => setHighlightId(null), 3000); - return () => clearTimeout(timer); - }, [highlightId, medications]); - - // v1.16.11 — the same reminder-thresholds read the cards make (shared - // key + cache), so the header's due-set derivation tiers late / - // very_late exactly like the card pills. Null on failure keeps the - // derivation on the 120/240 defaults the cards also fall back to. - const { data: thresholds } = useQuery({ - queryKey: queryKeys.settingsReminderThresholds(), - queryFn: async () => { - try { - return await apiGet<{ - lateMinutes: number; - missedMinutes: number; - lowStockRunwayDays: number | null; - }>("/api/settings/reminder-thresholds"); - } catch { - return null; + let dehydratedState = null; + try { + const session = await getSession(); + if (session) { + const { user } = session; + const modules = await resolveModuleMap(user.id); + // Mirror the client gate (`user?.modules?.medications !== false`): an + // absent key reads as enabled; only an explicit `false` disables. + if (modules.medications !== false) { + const list = await readMedicationsListCached(user); + 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.medications(), + JSON.parse(JSON.stringify(list)), + ); + dehydratedState = dehydrate(queryClient); } - }, - enabled: isAuthenticated, - staleTime: 5 * 60 * 1000, - }); - - // v1.16.11 — the SAME batched compliance rows the cards / table read - // (shared key + cache, no extra round trip). The due-set derivation - // needs each medication's compliance dose status so an escalated - // (overdue / missed past its band tail) medication — which still - // renders an active take button on its red card — joins the - // take-all-due set. - const { data: complianceRows } = useMedicationComplianceSummaryAll(); - - function openCreate() { - setDialogOpen(true); + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. } - // v1.15.20 — seed the detail page's query from the list row before - // navigating. The list response is a superset of the detail GET - // (`{...medication, category, nextDueAt}` plus the list-only - // `lastTakenAt` / `todayEventCount`), so the detail shell paints - // instantly from the seeded cache while its own un-gated query - // refetches the authoritative row in the background. - function seedDetail(med: Medication) { - queryClient.setQueryData(queryKeys.medicationDetail(med.id), med); + if (dehydratedState === null) { + return ; } - - // v1.16.1 — the kebab's Edit lands on the detail page's Übersicht tab, - // exactly like tapping the medication name. Every everyday edit lives - // inline in the tabs there; the structural wizard is reachable via - // Erweitert → Lebenszyklus (and still answers `?edit=1` deep-links). - // History still lands on the Verlauf tab. - function openEdit(med: Medication) { - seedDetail(med); - router.push(`/medications/${med.id}`); - } - - function openHistory(med: Medication) { - seedDetail(med); - router.push(`/medications/${med.id}?tab=verlauf`); - } - - function closeDialog() { - setDialogOpen(false); - } - - if (authLoading) { - return ( -
- -
- ); - } - - // Module off ⇒ the surface disappears entirely. The nav entry is hidden by - // the same gate, so a direct hit renders nothing rather than an orphaned - // shell. - if (isAuthenticated && !medicationsEnabled) { - return null; - } - - if (!isAuthenticated) { - return ( -
-
-

- {t("medications.title")} -

-

- {t("medications.loginRequired")} -

-
-
- ); - } - - // Defensive against stale service-worker responses or any future API - // shape change: only filter when we actually have an array. - const medsArray = Array.isArray(medications) ? medications : []; - // v1.16.10 — the user-defined manual order applies to BOTH views - // (cards and table). Medications not in the saved order keep the - // alphabetical default, appended after the ordered block. - const activeMeds = applyMedicationOrder( - medsArray.filter((m) => m.active), - layout.order, - ); - const inactiveMeds = applyMedicationOrder( - medsArray.filter((m) => !m.active), - layout.order, - ); - const tableView = layout.view === "table"; - - // v1.16.11 (#316) — the currently-due set, derived from the list payload - // the page already holds via the SAME pipeline a card's pill runs (band - // model + server display-due gate + taken-early downgrade), plus the - // batched compliance dose status so escalated (overdue / missed) - // medications past their band tail stay takeable from the batch. Page - // order is preserved so the confirm dialog lists medications as rendered. - const dueMeds = deriveDueMedications(activeMeds, { - tz: user?.timezone || "Europe/Berlin", - thresholds: thresholds ?? undefined, - doseStatusById: new Map( - (complianceRows ?? []).map((row) => [ - row.medicationId, - row.complianceDisplay?.currentDose.status ?? "upcoming", - ]), - ), - }); - return ( -
- -
-
-

- {t("medications.title")} -

- {/* v1.4.34 IW-G — subtitle stays visible on mobile so the - H1 isn't an unframed label. */} -

- {t("medications.subtitle")} -

-
-
- {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a - permanent fixture: it renders only while at least TWO - medications are currently due (a single due dose is the - card's own one-tap job). Calm outline variant so the primary - Add button keeps the visual lead; same responsive 44-px - mobile tap floor as its neighbours. */} - {dueMeds.length >= 2 && ( - - )} - {/* v1.16.11 — the wrench is the one customize entry point: - it links to /settings/medications, which owns the view - preference (cards ⇄ table) and the manual-order editor. - Same glyph, slot (left of the add button) and responsive - 44-px mobile tap floor as the dashboard and insights - headers. */} - - {/* v1.14.0 — the "Add" button is now a choice: log an intake - (incl. a backdated one) against an existing medication, or - create a new medication. v1.12.2 — match the dashboard Add - button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so - both primary "add" entry points clear the WCAG 2.5.5 44px mobile - minimum identically. */} - - - - - - setLogIntakeOpen(true)} - disabled={activeMeds.length === 0} - > - - {t("medications.addChoice.logIntake")} - - - - {t("medications.addChoice.newMedication")} - - - -
-
- - {isLoading || isLayoutLoading ? ( - // v1.11.3 C4 — skeletons instead of a bare centred spinner so the - // page reserves the loaded layout and does not jump when the - // medications resolve. v1.16.10 — the skeleton follows the - // persisted view (table rows vs card grid) so resolving the data - // never swaps the footprint; while the view preference itself is - // still loading the default cards skeleton stands in. -
- {tableView && !isLayoutLoading ? ( - - ) : ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- )} -
- ) : isError ? ( - void refetchMedications()} - /> - ) : !medications?.length ? ( - // v1.4.15 phase-C5: refactor the inline icon+text+button block - // to the shared EmptyState primitive so the empty path matches - // every other list page in the app (role=status, dashed - // border, consistent icon-bubble + spacing). - } - title={t("medications.emptyTitle")} - description={t("medications.emptyDescription")} - action={ - - } - /> - ) : tableView ? ( - // v1.16.10 — the compact table view. Same payloads, same status - // gating, same mutation hooks as the cards; one row per - // medication, inactive rows pinned after the active block. - - ) : ( -
- {/* Active medications */} - {activeMeds.length > 0 && ( -
-
- {activeMeds.map((med) => - // v1.16.11 — an as-needed medication always renders the - // generic card: the GLP-1 variant is built around the - // rolling injection cadence an as-needed med doesn't have. - med.treatmentClass === "GLP1" && !med.asNeeded ? ( - - ) : ( - - ), - )} -
-
- )} - - {/* Inactive medications */} - {inactiveMeds.length > 0 && ( -
-

- {t("common.inactive")} ({inactiveMeds.length}) -

-
- {inactiveMeds.map((med) => - med.treatmentClass === "GLP1" && !med.asNeeded ? ( - - ) : ( - - ), - )} -
-
- )} -
- )} - - {/* v1.5.5 — the per-card kebab triggers used to open inline - IntakeImportDialog / ApiEndpointDialog mounts here, but both - state slots were only ever set from inside the dialogs - themselves (never from a list-card prop) so the inline - mounts were dead code. v1.5.5 retires both on the list page: - the import dialog lives in the detail-page intake-history - header (see `src/components/medications/intake-import-dialog.tsx`), - the API endpoint moves to the detail-page Settings → Externe - Integration sub-row. */} - - {/* v1.15.18 — create-only wizard mount. Editing an existing - medication now navigates to the detail page's tabs (the hero's - "Vollständig bearbeiten" reopens the wizard in edit mode from - there); the list page wizard only ever creates. The wizard owns - its own ResponsiveSheet shell with the sticky footer. */} - - - {/* v1.14.0 — log-intake path of the "Add" choice. Lists the active - medications, lets the user pin a schedule slot, and picks a - date+time that supports backdating. Submits to the existing - per-medication intake route (same slot upsert as a live "Taken" - tap) so the one-row-per-dose-slot invariant holds. */} - {/* v1.16.11 (#316) — confirm-all dialog for the due set. Records - through the per-medication intake route in a loop (slot - attribution + inventory consumption identical to N individual - taps — see take-all-due.ts); failed medications stay due. */} - {takeAllOpen && ( - - )} - - {logIntakeOpen && ( - ({ - id: m.id, - name: m.name, - dose: m.dose, - schedules: m.schedules.map((s) => ({ - windowStart: s.windowStart, - label: s.label, - dose: s.dose, - timesOfDay: s.timesOfDay, - })), - }))} - /> - )} -
+ + + ); } diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index 252558c71..c63d9d7f5 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -508,7 +508,7 @@ describe(" — confirm dialog lists the due medications", () = "utf8", ); const pageSrc = readFileSync( - resolve(__dirname, "../../../app/medications/page.tsx"), + resolve(__dirname, "../../../app/medications/page-client.tsx"), "utf8", ); diff --git a/src/lib/ai/coach/nudge-status.ts b/src/lib/ai/coach/nudge-status.ts new file mode 100644 index 000000000..947ff49e8 --- /dev/null +++ b/src/lib/ai/coach/nudge-status.ts @@ -0,0 +1,54 @@ +/** + * Shared Coach nudge-status read for the two entry points: + * + * - `GET /api/insights/coach/nudge-status` (the client cell's endpoint), and + * - the `/coach` RSC wrapper (`src/app/coach/page.tsx`), which + * server-prefetches the same payload into a dehydrated TanStack cache so + * the auto-open-most-recent decision is available at hydrate instead of + * after the nudge → auto-open sequential round-trip. + * + * `unread` is true when an assistant message exists that is newer than the last + * time the user opened the Coach (`User.coachLastSeenAt`, stamped by + * `POST /api/insights/coach/seen`). Server-authoritative so the signal is + * consistent across web + iOS. + */ +import { prisma } from "@/lib/db"; + +export interface CoachNudgeStatus { + /** ISO timestamp of the newest assistant message, or null. */ + nudgedAt: string | null; + unread: boolean; + /** The conversation holding the newest assistant message, or null. */ + conversationId: string | null; +} + +export async function readCoachNudgeStatus( + userId: string, +): Promise { + // The newest assistant message across the caller's conversations — the + // proactive nudge writes one, and so does every normal Coach reply. We + // intentionally key on assistant (not user) messages: the unread dot means + // "the Coach said something", and the open-stamp clears it. + const [lastAssistant, seen] = await Promise.all([ + prisma.coachMessage.findFirst({ + where: { role: "assistant", conversation: { userId } }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, conversationId: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { coachLastSeenAt: true }, + }), + ]); + + const nudgedAt = lastAssistant?.createdAt ?? null; + const lastSeenAt = seen?.coachLastSeenAt ?? null; + const unread = + nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt); + + return { + nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, + unread, + conversationId: lastAssistant?.conversationId ?? null, + }; +} diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 7f6f1c45c..3bfa7f5bf 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -283,7 +283,7 @@ describe("Coach disable cascade invariant", () => { // v1.12.0 — the full-page Coach route gates on `flags.coach` and // redirects to `/insights` when the operator master flag is off. // v1.18.0 — moved to the standalone top-level `/coach` route. - "src/app/coach/page.tsx", + "src/app/coach/page-client.tsx", // v1.21.4 — the dedicated conversation-history page gates on // `flags.coach` like the main Coach route and renders unavailable // when the operator master flag is off. diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 3b277c0d4..c85a9a275 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -297,7 +297,12 @@ describe("Coach per-user disableCoach invariant", () => { // FAB gate: operator master flag OR per-user opt-out redirects the // page back to `/insights` instead of painting a dead chat shell. // v1.18.0 — moved to the standalone top-level `/coach` route. + // v1.30.x — split into an RSC prefetch wrapper (`page.tsx`, which reads + // `session.user.disableCoach` to skip the nudge prefetch for an opted-out + // account) + the client leaf (`page-client.tsx`, the `useDisableCoach` + // render-path gate). Both legitimately read the per-user flag. "src/app/coach/page.tsx", + "src/app/coach/page-client.tsx", // v1.21.4 — the dedicated conversation-history page mirrors the // Coach route gate: operator master flag OR per-user opt-out marks // the page unavailable instead of painting a dead history shell. diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts new file mode 100644 index 000000000..e5c3426ef --- /dev/null +++ b/src/lib/medications/list-read.ts @@ -0,0 +1,268 @@ +/** + * Shared cached medications-list read for the two entry points: + * + * - `GET /api/medications` (the client cell's endpoint), and + * - the medications RSC wrapper (`src/app/medications/page.tsx`), which + * server-prefetches the same payload into a dehydrated TanStack cache so + * the first HTML paints the medication cards instead of skeletons-until-JS. + * + * Both read through the SAME `caches.medications` SWR cell (keyed on + * `userId`), so an RSC prefetch warms the API path and vice versa — the + * builder never runs twice for one user within the 60 s fresh TTL, and the + * write-invalidation semantics (`invalidateUserMedications`) cover both + * readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard. + */ +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { getMedicationCategories } from "@/lib/medication-category"; +import { + computeDisplayDue, + OVERDUE_LOOKBACK_MS, + toResolvedSlotMark, + type ResolvedSlotMark, +} from "@/lib/medications/scheduling/next-due"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; + +export type MedicationsListResult = Array>; + +export async function buildMedicationsList( + userId: string, + userTz: string, +): Promise { + const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds( + new Date(), + userTz, + ); + + // v1.15.10 — the slots the user has already acted on near "now", so the + // next-due search can skip them. A resolved slot is a taken, deliberately + // skipped, or cron-auto-missed row. Bound the window to [today-start, + // today-end + 2d] — the next-due lookahead only needs the slots adjacent to + // now, and a tight window keeps the read cheap. The 60s list cache covers + // the rest. + const resolvedWindowEnd = new Date( + todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000, + ); + // v1.16.4 — the open-overdue search reaches back as far as the widest + // band tail (weekly on-time + overdue), so the resolved-slot read must + // cover the same horizon or a long-resolved past slot would resurface + // as "overdue". + const resolvedWindowStart = new Date( + todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS, + ); + + const [ + medications, + latestIntakes, + todayEvents, + resolvedEvents, + eraFloors, + usableStock, + inventoryCounts, + ] = await Promise.all([ + prisma.medication.findMany({ + where: { userId }, + include: { schedules: true }, + orderBy: { createdAt: "desc" }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the last-taken map. + where: { + userId, + deletedAt: null, + skipped: false, + takenAt: { not: null }, + }, + _max: { takenAt: true }, + }), + prisma.medicationIntakeEvent.groupBy({ + by: ["medicationId"], + // v1.7.0 sync — exclude tombstoned rows from the today-count map. + // v1.16.9 — count only ACTIONED rows (taken or skipped). The + // dashboard projector mints pending rows for every slot of the + // day, so counting all rows made `todayEventCount` cover every + // passed dose after any dashboard visit — and the cards' + // overdue-pill suppression (`todayEventCount < passedDoseCount`) + // went dark nondeterministically for genuinely overdue doses. + where: { + userId, + deletedAt: null, + scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, + OR: [{ takenAt: { not: null } }, { skipped: true }], + }, + _count: { id: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd }, + OR: [ + { takenAt: { not: null } }, + { skipped: true }, + { autoMissed: true }, + ], + }, + // v1.16.9 — `takenAt` rides along so the ad-hoc shape + // (`scheduledFor === takenAt`) is detectable: such a row must not + // ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's + // genuinely-due 20:00 dose). + select: { medicationId: true, scheduledFor: true, takenAt: true }, + }), + // v1.16.4 — current-era floor per medication: the newest revision's + // `validUntil` is where the LIVE schedule rows became valid. The + // open-overdue search mints from the live rows, so it must not reach + // past this boundary into a previous era's cadence. + prisma.medicationScheduleRevision.groupBy({ + by: ["medicationId"], + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medication: { userId }, supersededByRevisionId: null }, + _max: { validUntil: true }, + }), + // v1.16.10 — usable stock per medication (one batched aggregate, + // not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE + // containers with units left — the same usable-container filter + // the GLP-1 details endpoint applies. Feeds the list payload's + // `stockUnitsRemaining` / `stockDosesRemaining` for the table view. + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { + userId, + state: { in: ["ACTIVE", "IN_USE"] }, + unitsRemaining: { gt: 0 }, + }, + _sum: { unitsRemaining: true }, + }), + // …and the any-state item count, so a medication whose containers + // are all used up / expired reads as stock 0 (tracking is ON, the + // supply ran out) instead of null (tracking off). + prisma.medicationInventoryItem.groupBy({ + by: ["medicationId"], + where: { userId }, + _count: { id: true }, + }), + ]); + + const resolvedSlotsByMedId = new Map(); + for (const e of resolvedEvents) { + const mark = toResolvedSlotMark(e); + const list = resolvedSlotsByMedId.get(e.medicationId); + if (list) list.push(mark); + else resolvedSlotsByMedId.set(e.medicationId, [mark]); + } + + const eraStartByMedId = new Map(); + for (const f of eraFloors) { + if (f._max.validUntil) + eraStartByMedId.set(f.medicationId, f._max.validUntil); + } + + const lastTakenAtByMedicationId = Object.fromEntries( + latestIntakes.map((entry) => [ + entry.medicationId, + entry._max.takenAt ? entry._max.takenAt.toISOString() : null, + ]), + ); + // v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine + // (rolling cadences re-anchor on it). Same groupBy as the ISO map. + const lastTakenAtDateByMedicationId = Object.fromEntries( + latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]), + ); + const todayEventCountByMedId = Object.fromEntries( + todayEvents.map( + (entry: { medicationId: string; _count: { id: number } }) => [ + entry.medicationId, + entry._count.id, + ], + ), + ); + + const usableUnitsByMedId = new Map(); + for (const entry of usableStock) { + usableUnitsByMedId.set( + entry.medicationId, + Number(entry._sum.unitsRemaining ?? 0), + ); + } + const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId)); + + let categoryMap: Record = {}; + try { + categoryMap = await getMedicationCategories(medications.map((m) => m.id)); + } catch { + getEvent()?.addWarning("Medication categories could not be loaded"); + } + + // v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived; + // the list GET is cached 60 s on userId, so a 60 s staleness window is + // accepted here as it already is for `todayEventCount`. + const now = new Date(); + + return medications.map((m) => { + // v1.16.4 — an OPEN overdue slot (anchor passed, still inside its + // catch-up band, unresolved) surfaces FIRST with `nextDueOverdue: + // true`; only a closed or resolved band falls through to the future + // next-due. Keeps the card on the still-takeable dose instead of + // jumping ahead the minute the anchor passes. + const display = computeDisplayDue({ + medication: { + id: m.id, + startsOn: m.startsOn, + endsOn: m.endsOn, + oneShot: m.oneShot, + createdAt: m.createdAt, + }, + schedules: m.schedules, + now, + userTz, + lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null, + resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], + eraStart: eraStartByMedId.get(m.id) ?? null, + }); + // v1.16.10 — dose-derived stock for the table view. NULL when the + // medication has no inventory items at all (tracking off); 0 when + // tracking is on but every container is used up / expired. + const tracksInventory = trackedMedIds.has(m.id); + const stockUnitsRemaining = tracksInventory + ? (usableUnitsByMedId.get(m.id) ?? 0) + : null; + const stockDosesRemaining = + stockUnitsRemaining === null + ? null + : Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1)); + return { + ...m, + // v1.16.12 — Decimal → number so the wire stays a JSON number, not + // the string Prisma would otherwise serialise a Decimal to. + unitsPerDose: Number(m.unitsPerDose), + category: categoryMap[m.id] ?? "OTHER", + lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, + todayEventCount: todayEventCountByMedId[m.id] ?? 0, + nextDueAt: display ? display.at.toISOString() : null, + nextDueOverdue: display?.overdue ?? false, + stockUnitsRemaining, + stockDosesRemaining, + }; + }); +} + +/** + * Resolve + read the medications list through the SWR cache for an already + * authenticated user row. Shared by the API route and the RSC prefetch; the + * `User` row is in hand at both call sites, so there is no extra round-trip. + */ +export async function readMedicationsListCached(user: { + id: string; + timezone: string | null; +}): Promise { + const userTz = user.timezone || "Europe/Berlin"; + return cachedSwr( + caches.medications as ServerCache, + user.id, + () => buildMedicationsList(user.id, userTz), + annotate, + ); +}