From 5e691d3be254ba0a5d61318f9f21e52ffa926882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 23:41:11 +0200 Subject: [PATCH 1/5] fix(insights): give the comprehensive briefing a longer generation budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comprehensive briefing aborted its provider call at the 120s budget on a full-history account — a heavier reasoning model spent longer than that before the first token, so the chain timed out, fell through to no fallback, and the briefing rendered its greeting with an empty body. Raise the comprehensive timeout to 180s so a slow first token no longer clips a generation that would otherwise complete; the per-user response timeout still overrides it for operators who need more, and the worker cap tracks this value. --- src/lib/ai/ai-budgets.ts | 14 +++++++++++++- .../jobs/__tests__/insight-pregenerate.test.ts | 18 +++++++++--------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index d97544066..6735cff37 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -61,8 +61,20 @@ export const AI_BUDGETS = { * generation runs off the request hot path (nightly warm + the on-demand * background warm), and the explicit regenerate is rate-limited, so the * wider ceiling is bounded. + * + * v1.25.12 — raised again to 180 s. The same "operation aborted due to + * timeout" hop (no upstream status) recurred at exactly the 120 s ceiling + * once the codex default slug moved to the heavier-reasoning `gpt-5.5` + * line: a wide account (full-history briefing, raw signals included) spends + * longer in the reasoning channel before the first visible token than the + * 120 s budget allowed, so the single-provider chain timed out with nothing + * to fall back to and the briefing rendered its greeting with no body. The + * per-user `aiResponseTimeoutSeconds` setting still overrides this default + * for operators on even slower backends; this only lifts the floor for + * accounts that never set it. The worker cap in `insight-pregenerate.ts` + * derives from this value plus fixed headroom, so it tracks automatically. */ - comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 120_000 }, + comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 180_000 }, /** * Per-metric status assessment cards — output is a single diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 5dea6830d..219204e0b 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -304,10 +304,10 @@ describe("runInsightPregenerate — outcome tally", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.25.3 — above the default warm budget (~150 s = the 120 s + // v1.25.12 — above the default warm budget (~210 s = the 180 s // comprehensive provider budget + headroom) so the bounded budget // fires before this "slow" generation settles. - 200_000, + 260_000, ); }), ); @@ -323,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(160_000); + await vi.advanceTimersByTimeAsync(230_000); const result = await promise; // The timeout fired, aborted the still-running generation (so its @@ -798,10 +798,10 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // advance past it so the warm continues. setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.25.3 — above the default warm budget (~150 s = the 120 s + // v1.25.12 — above the default warm budget (~210 s = the 180 s // comprehensive provider budget + headroom) so the bounded budget // fires before this "slow" generation settles. - 200_000, + 260_000, ); }), ); @@ -818,7 +818,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); // Advance past the comprehensive budget so withTimeout fires. - await vi.advanceTimersByTimeAsync(160_000); + await vi.advanceTimersByTimeAsync(230_000); const result = await promise; for (const g of statusGenerators) { @@ -916,10 +916,10 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - // v1.25.3 — above the default warm budget (~150 s = the 120 s + // v1.25.12 — above the default warm budget (~210 s = the 180 s // comprehensive provider budget + headroom) so the bounded budget // fires before this "slow" generation settles. - 200_000, + 260_000, ); }), ); @@ -935,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(160_000); + await vi.advanceTimersByTimeAsync(230_000); const result = await promise; // The timeout fired and aborted the still-running generation. From b5eb5274b34bfc889d2acf00357699beaaf6334d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 23:42:09 +0200 Subject: [PATCH 2/5] fix(nav): stop disabled-module entries flickering into the nav on first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolved per-user module map rides the client-only `/api/auth/me` query, so it is still undefined on SSR and the first client paint. The nav model's module gate fails open (`modules?.[key] !== false`), so every module-gated entry — cycle, mood, labs, coach, insights … — rendered for one frame before the query resolved and filtered it out. A user without a module saw its entry flash into the sidebar and the mobile bar, then vanish. Gate the module-dependent filtering behind `useMounted()` in both bars. SSR and the first client paint now render only the core (non-module-gated) destinations; the real module map applies once mounted. Before mount a gated entry is treated as hidden — fail-closed — so SSR and first paint are identical (no hydration mismatch) and a disabled module can never flicker in. The pure nav-model helpers keep their fail-open default for callers that pass a settled map; the bars thread the mounted flag in. Rework the sidebar and bottom-nav SSR tests to assert the fail-closed first-paint contract (core-only, gated entries absent regardless of the map); the resolved post-mount set stays covered by the pure-helper tests in nav-model.test.ts and by e2e. --- .../layout/__tests__/bottom-nav.test.tsx | 34 +++- .../layout/__tests__/sidebar-nav.test.tsx | 153 ++++++++---------- src/components/layout/bottom-nav.tsx | 21 ++- src/components/layout/nav-model.ts | 21 ++- src/components/layout/sidebar-nav.tsx | 11 +- 5 files changed, 139 insertions(+), 101 deletions(-) diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index b1de1dbd8..fba5ead13 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -61,13 +61,24 @@ function render() { } describe(" iOS-parity layout", () => { - it("renders the flanking primary anchors (Home, Meds, Insights)", () => { + it("renders the always-on flanking primary anchors (Home, Meds)", () => { const html = render(); - for (const href of ["/", "/medications", "/insights"]) { + // Home + Meds are not module-gated, so they anchor the strip on first + // paint. The Insights slot IS module-gated; v1.25.12 gates it behind + // hydration (fail-closed pre-mount) so a disabled module can't flicker + // in — its presence is asserted in the mounted/e2e path, not here. + for (const href of ["/", "/medications"]) { expect(html).toContain(`href="${href}"`); } }); + it("keeps the module-gated Insights slot OUT of the first-paint markup (v1.25.12)", () => { + const html = render(); + // Pre-hydration the module map isn't settled; the Insights primary slot + // therefore fails closed (absent) rather than flickering in. + expect(html).not.toContain('href="/insights"'); + }); + it("renders the center capture action as a dialog-opening button, not a link", () => { const html = render(); expect(html).toContain('data-testid="bottom-nav-capture"'); @@ -100,11 +111,13 @@ describe(" iOS-parity layout", () => { it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => { const html = render(); - // The four flanking strip slots (Home, Meds, Insights, More) carry - // `min-h-11 min-w-11`. We don't hard-pin the count because a future - // entry would also satisfy the contract. + // The flanking strip slots carry `min-h-11 min-w-11`. On first paint the + // module-gated Insights slot is fail-closed (v1.25.12), so the strip + // shows Home, Meds and More — three slots. We don't hard-pin the count + // because the mounted render adds Insights back and a future entry would + // also satisfy the contract. const matches = html.match(/min-h-11 min-w-11/g) ?? []; - expect(matches.length).toBeGreaterThanOrEqual(4); + expect(matches.length).toBeGreaterThanOrEqual(3); }); it("the center capture FAB is a larger, elevated tap target", () => { @@ -164,10 +177,15 @@ describe(" iOS-parity layout", () => { mockUserRef.value = { id: "u1", modules: {} }; }); - it("keeps the Insights primary slot when the insights module is on", () => { + it("still fails the Insights slot closed on first paint even when the module is on (v1.25.12)", () => { + // Pre-hydration the slot is fail-closed regardless of the map, so the + // disabled-module flicker cannot occur. The mounted render restores the + // slot when the module is on (the resolved set is the pure + // `mobileMoreHubDestinations(..., mounted: true)` contract in + // nav-model.test.ts, plus the e2e mounted render). mockUserRef.value = { id: "u1", modules: { insights: true } }; const html = render(); - expect(html).toContain('href="/insights"'); + expect(html).not.toContain('href="/insights"'); mockUserRef.value = { id: "u1", modules: {} }; }); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 68ddc5248..f31a5088a 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -72,111 +72,96 @@ function render({ ); } -describe(" cycle entry gate (v1.15.0 / v1.18.0 module map)", () => { - it("renders the Cycle entry when the cycle module is enabled", () => { - const html = render({ modules: { cycle: true } }); - expect(html).toContain('href="/cycle"'); - expect(html).toContain("Cycle"); +// v1.25.12 — these suites render the FIRST-PAINT (pre-hydration) markup. +// `renderToStaticMarkup` drives `useMounted()` to its server snapshot +// (`false`), so the sidebar applies its fail-CLOSED gate: only CORE +// (non-module-gated) destinations render and EVERY module-gated entry is +// absent regardless of the module map. This is the fix for the disabled- +// module flicker — a gated entry can no longer paint for one frame before the +// `/api/auth/me` query resolves. The resolved, post-mount visible set +// ("cycle shows when its module is on", "a disabled module drops out") is the +// pure `visibleNavDestinations(modules, true)` contract verified in +// `nav-model.test.ts`; the mounted render is exercised in e2e. + +// CORE destinations carry no `requiresModule` and always render. +const CORE_HREFS = ["/", "/measurements", "/checkups"] as const; +// A representative slice of the module-gated entries that must stay out of +// the first-paint markup whatever the module map says. +const GATED_HREFS = [ + "/mood", + "/cycle", + "/medications", + "/labs", + "/illness", + "/insights", + "/coach", + "/achievements", +] as const; + +describe(" first-paint fail-closed module gate (v1.25.12)", () => { + it("renders core destinations on first paint regardless of the module map", () => { + const maps: Record[] = [ + {}, + { cycle: true, mood: true, labs: true, coach: true }, + { cycle: false, mood: false }, + ]; + for (const modules of maps) { + const html = render({ modules }); + for (const href of CORE_HREFS) { + expect(html).toContain(`href="${href}"`); + } + } }); - it("hides the Cycle entry when the cycle module is disabled", () => { - const html = render({ modules: { cycle: false } }); - expect(html).not.toContain('href="/cycle"'); - }); - - it("places the Cycle entry between Mood and Medications when enabled", () => { - const html = render({ modules: { cycle: true } }); - const mood = html.indexOf('href="/mood"'); - const cycle = html.indexOf('href="/cycle"'); - const med = html.indexOf('href="/medications"'); - expect(mood).toBeGreaterThan(-1); - expect(cycle).toBeGreaterThan(-1); - expect(med).toBeGreaterThan(-1); - // v1.19.1 (S4) — Cycle sits after Mood and before Medications in document - // order; Medications opens the fixed clinical/insight spine below it. - expect(mood).toBeLessThan(cycle); - expect(cycle).toBeLessThan(med); - }); -}); - -describe(" module gating (v1.18.0)", () => { - it("hides a disabled module's nav entry (mood / labs / coach / achievements)", () => { - const html = render({ - modules: { - mood: false, - labs: false, - coach: false, - achievements: false, - }, - }); - expect(html).not.toContain('href="/mood"'); - expect(html).not.toContain('href="/labs"'); - expect(html).not.toContain('href="/coach"'); - expect(html).not.toContain('href="/achievements"'); - // Core destinations stay regardless of the module map. - expect(html).toContain('href="/measurements"'); - expect(html).toContain('href="/medications"'); - expect(html).toContain('href="/insights"'); - expect(html).toContain('href="/checkups"'); - }); - - it("renders a module's nav entry when its module is enabled", () => { + it("keeps every module-gated entry OUT of the first-paint markup, even when its module is enabled", () => { + // The flicker bug: a gated entry painted before the module query + // resolved. Pre-mount it must be absent even with the module turned on. const html = render({ modules: { + cycle: true, mood: true, labs: true, coach: true, achievements: true, + insights: true, }, }); - expect(html).toContain('href="/mood"'); - expect(html).toContain('href="/labs"'); - expect(html).toContain('href="/coach"'); - expect(html).toContain('href="/achievements"'); + for (const href of GATED_HREFS) { + expect(html).not.toContain(`href="${href}"`); + } }); - it("fails open: an empty module map keeps every gated entry visible", () => { + it("keeps gated entries out when the module map is empty (no fail-open flicker)", () => { const html = render({ modules: {} }); - expect(html).toContain('href="/mood"'); - expect(html).toContain('href="/cycle"'); - expect(html).toContain('href="/labs"'); - expect(html).toContain('href="/coach"'); - expect(html).toContain('href="/achievements"'); + for (const href of GATED_HREFS) { + expect(html).not.toContain(`href="${href}"`); + } + }); + + it("keeps gated entries out when the module is explicitly disabled", () => { + const html = render({ + modules: { mood: false, labs: false, coach: false, achievements: false }, + }); + for (const href of GATED_HREFS) { + expect(html).not.toContain(`href="${href}"`); + } + }); + + it("does not render the deprecated /insights/workouts sub-link", () => { + const html = render(); + expect(html).not.toContain('href="/insights/workouts"'); }); }); describe(" targets deprecation (v1.8.6)", () => { it("no longer renders the deprecated /targets entry", () => { // The Targets (Zielwerte) page is deprecated; target editing moved - // inline into Insights, so the sidebar drops the entry. The main nav - // links render into the SSR markup, so asserting absence here is a - // real regression net (Insights, the sibling that stays, still shows). + // inline into Insights, so the sidebar drops the entry. Assert absence + // against a stable CORE sibling that renders on first paint + // (Measurements stays; Insights is module-gated and pre-mount absent). const html = render(); expect(html).not.toContain('href="/targets"'); - expect(html).toContain('href="/insights"'); - }); -}); - -describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { - it("surfaces the Coach as a first-class sidebar destination", () => { - // Pre-unify the sidebar had no Coach home while the mobile bar missed - // it too; both now render the one shared model so both carry it. - // (v1.18.0 — Workouts left the left nav for its Insights pill, so it is - // no longer a sidebar destination.) - const html = render(); - expect(html).toContain('href="/coach"'); - expect(html).toContain("Coach"); - expect(html).not.toContain('href="/insights/workouts"'); - }); - - it("marks Coach active without also marking Insights active", () => { - const html = render({ pathname: "/coach" }); - // The Coach link carries aria-current="page"; the standalone /coach - // route is not a sibling of /insights, so the Insights link must not. - const coach = html.match(/]*href="\/coach"[^>]*>/); - const insights = html.match(/]*href="\/insights"[^>]*>/); - expect(coach?.[0]).toMatch(/aria-current="page"/); - expect(insights?.[0]).not.toMatch(/aria-current="page"/); + expect(html).toContain('href="/measurements"'); }); }); diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 659559860..e607fad92 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -19,6 +19,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { Sheet, SheetContent, @@ -85,6 +86,13 @@ export function BottomNav() { const [moreOpen, setMoreOpen] = useState(false); const [captureOpen, setCaptureOpen] = useState(false); + // The module map rides the client-only `/api/auth/me` query, so it is not + // settled on SSR or the first client paint. Gating the module filter behind + // `useMounted()` keeps SSR and first paint identical and stops a disabled + // module from flickering into the hub / primary slot before the query + // resolves (the #418-class divergence); once mounted the real map applies. + const mounted = useMounted(); + // v1.17.1 (F-1) — the More hub is the model-computed hub: every visible // feature destination that isn't a primary slot, plus the shared utility // tail. Cycle is gated by the same flag the sidebar uses, so the two @@ -93,21 +101,24 @@ export function BottomNav() { () => mobileMoreHubDestinations({ modules: user?.modules, + mounted, }), - [user?.modules], + [user?.modules, mounted], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account - // has that module disabled. Fail-open: a missing key / unloaded map keeps - // the slot, mirroring the gate + More-hub default-on contract. + // has that module disabled. Fail-closed until mounted (so a disabled + // Insights never flickers in), then the real map applies: a missing key / + // unloaded map keeps the slot, mirroring the gate + More-hub default-on + // contract. const primaryRight = useMemo( () => PRIMARY_RIGHT.filter( (item) => !item.requiresModule || - user?.modules?.[item.requiresModule] !== false, + (mounted && user?.modules?.[item.requiresModule] !== false), ), - [user?.modules], + [user?.modules, mounted], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index c4cb8ba72..6f27b106a 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -242,12 +242,24 @@ export type ModuleVisibilityMap = Partial>; * Whether a destination is visible under the given module map. Core * destinations (no `requiresModule`) always pass; a gated entry passes * unless its module resolves to an explicit `false`. + * + * `mounted` (default `true`) is the hydration gate the nav bars thread in. + * The resolved module map rides the client-only `/api/auth/me` query, which + * can still be unresolved on SSR and the first client paint. Reading the map + * then would fail OPEN and flicker a disabled module's entry in for one frame + * before the query lands and filters it out (the #418-class SSR/client + * divergence). So before mount a gated entry is treated as hidden — + * fail-CLOSED — making SSR and first paint identical (core-only); once + * mounted the real map applies. The default keeps the pure helper fail-open + * for non-component callers that pass a settled map. */ function isNavDestinationVisible( d: NavDestination, modules: ModuleVisibilityMap | undefined, + mounted = true, ): boolean { if (!d.requiresModule) return true; + if (!mounted) return false; return modules?.[d.requiresModule] !== false; } @@ -260,8 +272,11 @@ function isNavDestinationVisible( */ export function visibleNavDestinations( modules: ModuleVisibilityMap | undefined, + mounted = true, ): NavDestination[] { - return NAV_DESTINATIONS.filter((d) => isNavDestinationVisible(d, modules)); + return NAV_DESTINATIONS.filter((d) => + isNavDestinationVisible(d, modules, mounted), + ); } /** @@ -293,8 +308,10 @@ export interface MobileMoreHubEntry { */ export function mobileMoreHubDestinations(opts: { modules: ModuleVisibilityMap | undefined; + /** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */ + mounted?: boolean; }): MobileMoreHubEntry[] { - const features = visibleNavDestinations(opts.modules) + const features = visibleNavDestinations(opts.modules, opts.mounted ?? true) .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); const tail = visibleUtilityDestinations().map((d) => ({ diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index d7a96237e..e99c069ee 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -241,9 +242,15 @@ export function SidebarNav() { // its "More" hub from. v1.18.0 — entries are filtered by the account's // resolved module map (mood, cycle, labs, coach, achievements …); cycle + // coach are delegated server-side and already reflected in that map. + // The module map rides the client-only `/api/auth/me` query, so it is not + // settled on SSR or the first client paint. Gating the filter behind + // `useMounted()` keeps SSR and first paint identical (core destinations + // only) and stops a disabled module's entry from flickering in for one + // frame before the query resolves; once mounted the real map applies. + const mounted = useMounted(); const visibleNavItems = useMemo( - () => visibleNavDestinations(user?.modules), - [user?.modules], + () => visibleNavDestinations(user?.modules, mounted), + [user?.modules, mounted], ); const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") return false; From 5bfba0ba604dc9b35c461b604eddf8811894549a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 23:41:06 +0200 Subject: [PATCH 3/5] feat(mental-health): open an instrument's trend from its card Clicking a PHQ-9 / GAD-7 card body now opens that instrument's history in a detail sheet, mirroring how a medication card opens its detail. The card body is the navigation target; the bottom-pinned Start button stays a separate action and keeps its canonical typography and spacing. The detail reuses the existing history chart and dated list, pinned to the chosen instrument: the PHQ-9 / GAD-7 toggle and the outer card chrome drop away since the sheet owns the title. The combined history card on the landing is unchanged. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/mental-wellbeing.test.tsx | 73 +++++++ .../mental-health/assessment-history.tsx | 197 +++++++++++------- .../mental-health/instrument-card.tsx | 66 +++--- .../mental-health/mental-wellbeing.tsx | 34 +++ 10 files changed, 280 insertions(+), 108 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4438719f7..6f5243478 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7716,7 +7716,8 @@ "review": { "title": "Überblick", "recap": "Du hast {answered} von {total} Fragen beantwortet." - } + }, + "openDetail": "Verlauf anzeigen" }, "records": { "disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.", diff --git a/messages/en.json b/messages/en.json index 11fdc9ee9..a1094539e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7716,7 +7716,8 @@ "review": { "title": "Review", "recap": "You answered {answered} of {total} questions." - } + }, + "openDetail": "Show trend" }, "records": { "disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.", diff --git a/messages/es.json b/messages/es.json index c498e22e8..5df7f651f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7716,7 +7716,8 @@ "review": { "title": "Resumen", "recap": "Has respondido {answered} de {total} preguntas." - } + }, + "openDetail": "Ver evolución" }, "records": { "disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.", diff --git a/messages/fr.json b/messages/fr.json index 8cba119f6..f0dbd75a7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7716,7 +7716,8 @@ "review": { "title": "Récapitulatif", "recap": "Vous avez répondu à {answered} questions sur {total}." - } + }, + "openDetail": "Voir l’évolution" }, "records": { "disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.", diff --git a/messages/it.json b/messages/it.json index 88a6e34fb..4785ce709 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7716,7 +7716,8 @@ "review": { "title": "Riepilogo", "recap": "Hai risposto a {answered} domande su {total}." - } + }, + "openDetail": "Mostra andamento" }, "records": { "disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.", diff --git a/messages/pl.json b/messages/pl.json index 3b16a27c3..4b6b91788 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7716,7 +7716,8 @@ "review": { "title": "Podsumowanie", "recap": "Odpowiedziano na {answered} z {total} pytań." - } + }, + "openDetail": "Pokaż przebieg" }, "records": { "disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.", diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index 125cc6a44..f983ed473 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -24,6 +24,7 @@ import { MentalWellbeing } from "../mental-wellbeing"; import { CheckInWizard } from "../check-in-wizard"; import { AssessmentResult } from "../assessment-result"; import { AssessmentHistory } from "../assessment-history"; +import { InstrumentCard } from "../instrument-card"; import type { AssessmentRow, CreateResponse } from "../types"; const mh = en.mentalHealth; @@ -194,3 +195,75 @@ describe("history rendering", () => { expect(empty).not.toContain('data-slot="history-list"'); }); }); + +describe("instrument card → per-instrument trend (v1.25.12)", () => { + it("exposes the card body as a detail target separate from Start", () => { + const html = withProviders( + {}} + onOpenDetail={() => {}} + />, + ); + // The title + last-result block is a button that opens the Verlauf… + expect(html).toContain('data-slot="instrument-card-open"'); + expect(html).toContain(mh.openDetail); + // …and the Start action is still its own button. + expect(html).toContain(mh.start); + }); + + it("pins the history to one instrument: no toggle, bare (no titled card)", () => { + const rows: AssessmentRow[] = [ + { + id: "p1", + instrument: "PHQ9", + locale: "en", + totalScore: 8, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-06-20T00:00:00.000Z", + }, + { + id: "g1", + instrument: "GAD7", + locale: "en", + totalScore: 12, + severityBand: "moderate", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-06-20T00:00:00.000Z", + }, + ]; + const html = withProviders( + , + ); + // Pinned: the PHQ-9 / GAD-7 toggle + the titled landing card are gone… + expect(html).toContain('data-pinned="PHQ9"'); + expect(html).not.toContain('role="tablist"'); + // …only the chosen instrument's row paints (its band), not the other's. + expect(html).toContain(mh.band.PHQ9.mild); + expect(html).not.toContain(mh.band.GAD7.moderate); + }); + + it("shows the per-instrument empty state when that instrument has no rows", () => { + const onlyGad: AssessmentRow[] = [ + { + id: "g2", + instrument: "GAD7", + locale: "en", + totalScore: 5, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: "2026-06-20T00:00:00.000Z", + }, + ]; + const html = withProviders( + , + ); + expect(html).toContain(mh.history.empty); + expect(html).not.toContain('data-slot="history-list"'); + }); +}); diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index ca3a8458e..d570fe7e9 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -68,44 +68,60 @@ function crisisFromRow(row: AssessmentRow): CrisisSet { }; } -export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) { +export function AssessmentHistory({ + rows, + instrument, +}: { + rows: AssessmentRow[]; + /** + * Pin the history to a single instrument. When set, the PHQ-9 / GAD-7 + * toggle + the outer Card chrome are dropped — the surface is a per- + * instrument detail (opened from an instrument card; the chrome + title + * come from the host sheet). Unset, it renders the combined landing card + * with the toggle, exactly as before. + */ + instrument?: InstrumentId; +}) { const { t } = useTranslations(); const { date: formatDate } = useFormatters(); - const [tab, setTab] = useState("PHQ9"); + const [tab, setTab] = useState(instrument ?? "PHQ9"); // Which flagged row's crisis card is currently expanded (re-surfaced). const [openCrisisRowId, setOpenCrisisRowId] = useState(null); + const pinned = instrument != null; + // When pinned, the tab is forced to the host instrument so re-opening the + // detail for the other instrument never paints a stale series. + const activeTab = pinned ? instrument : tab; + const forTab = useMemo( () => rows - .filter((r) => r.instrument === tab) + .filter((r) => r.instrument === activeTab) .sort( (a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), ), - [rows, tab], + [rows, activeTab], ); - const isEmpty = rows.length === 0; + // Pinned: emptiness is per-instrument (the host opened THIS instrument's + // detail). Combined: emptiness spans both instruments, as before. + const isEmpty = pinned ? forTab.length === 0 : rows.length === 0; - return ( - - - - {t("mentalHealth.history.title")} - - - - {isEmpty ? ( - } - title={t("mentalHealth.history.title")} - description={t("mentalHealth.history.empty")} - /> - ) : ( - <> - {/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an - axis, so the chart + list paint one instrument at a time. */} + const body = ( + <> + {isEmpty ? ( + } + title={t("mentalHealth.history.title")} + description={t("mentalHealth.history.empty")} + /> + ) : ( + <> + {/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an + axis, so the chart + list paint one instrument at a time. Hidden + when pinned: the host already chose the instrument. */} + {!pinned && (
{ setTab(id); @@ -129,63 +145,88 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) { ))}
+ )} - + - {forTab.length === 0 ? ( -

- {t("mentalHealth.history.empty")} -

- ) : ( -
    - {forTab.map((row) => { - const flagged = row.item9Flagged; - const open = openCrisisRowId === row.id; - return ( -
  • -
    - - {formatDate(row.takenAt)} - -
    - {flagged && ( - + {forTab.length === 0 ? ( +

    + {t("mentalHealth.history.empty")} +

    + ) : ( +
      + {forTab.map((row) => { + const flagged = row.item9Flagged; + const open = openCrisisRowId === row.id; + return ( +
    • +
      + + {formatDate(row.takenAt)} + +
      + {flagged && ( + + )} + + {t( + `mentalHealth.band.${row.instrument}.${row.severityBand}`, )} - - {t( - `mentalHealth.band.${row.instrument}.${row.severityBand}`, - )} - - - {row.totalScore} - -
      + + + {row.totalScore} +
      - {flagged && open && ( - - )} -
    • - ); - })} -
    - )} - - )} - +
    + {flagged && open && ( + + )} +
  • + ); + })} +
+ )} + + )} + + ); + + // Pinned: render bare (the host sheet owns the chrome + title). Combined: + // the landing's titled history card, unchanged. + if (pinned) { + return ( +
+ {body} +
+ ); + } + + return ( + + + + {t("mentalHealth.history.title")} + + + {body} ); } diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index 2b24cf69b..57c9875b6 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -24,45 +24,63 @@ export function InstrumentCard({ instrument, last, onStart, + onOpenDetail, }: { instrument: InstrumentId; /** Most-recent assessment for this instrument, or undefined when none yet. */ last: AssessmentRow | undefined; onStart: () => void; + /** + * Open this instrument's trend detail. The card BODY (title + last-result + * line) is the navigation target — mirroring how a medication card opens its + * detail — while the bottom-pinned Start button stays a separate action. + */ + onOpenDetail: () => void; }) { const { t } = useTranslations(); const { date: formatDate } = useFormatters(); const key = lower(instrument); + const title = t(`mentalHealth.instrument.${key}`); return ( -
- - {t(`mentalHealth.instrument.${key}`)} - - - {t(`mentalHealth.instrumentSub.${key}`)} - -
+ {/* Body is the detail target: clicking the title / last-result block + opens this instrument's Verlauf, like a medication card. The Start + button below is a separate, deliberately non-overlapping action. */} + -
+
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 5b0483624..454f333dc 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { AssessmentHistory } from "./assessment-history"; import { AssessmentResult } from "./assessment-result"; @@ -45,6 +46,11 @@ export function MentalWellbeing() { const [phase, setPhase] = useState("choose"); const [instrument, setInstrument] = useState("PHQ9"); const [result, setResult] = useState(null); + // Which instrument's trend detail is open (null = closed). Clicking a card + // body surfaces THAT instrument's Verlauf in a sheet; Start stays separate. + const [detailInstrument, setDetailInstrument] = useState( + null, + ); const { data: history } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), @@ -119,6 +125,7 @@ export function MentalWellbeing() { instrument={id} last={lastByInstrument.get(id)} onStart={() => begin(id)} + onOpenDetail={() => setDetailInstrument(id)} /> ))} @@ -126,6 +133,33 @@ export function MentalWellbeing() { + + {/* Per-instrument trend detail, opened from a card body. Reuses the + history's chart + dated list, pinned to the chosen instrument. */} + { + if (!open) setDetailInstrument(null); + }} + contentWidth="lg" + title={ + detailInstrument + ? t( + `mentalHealth.instrument.${ + detailInstrument === "PHQ9" ? "phq9" : "gad7" + }`, + ) + : t("mentalHealth.history.title") + } + description={t("mentalHealth.history.chartTitle")} + > + {detailInstrument && ( + + )} + )} From d73656685ae7b0529eb846e340c9d8f1e28ace7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 23:45:06 +0200 Subject: [PATCH 4/5] fix(settings): move pre-existing conditions into Anamnese The chronic / pre-existing conditions the Coach watches lived under the personal-context "About me" panel, but they belong with the rest of the medical history. Move the conditions and the "what should the Coach watch" focus into Settings -> Anamnese, next to allergies and family history, and make them editable inline there. The store is unchanged: both fields are still part of the /api/coach/about-me payload, read and written through the same query key, so the Coach and every other consumer keep reading the same data. The About-me panel keeps the free-text note and the allergies line and omits the two conditions fields on save, leaving them untouched. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- src/components/records/conditions-manager.tsx | 166 ++++++++++++++++++ .../records/conditions-overview.tsx | 98 ----------- src/components/settings/about-me-section.tsx | 108 ++++-------- src/components/settings/anamnesis-section.tsx | 17 +- 10 files changed, 233 insertions(+), 204 deletions(-) create mode 100644 src/components/records/conditions-manager.tsx delete mode 100644 src/components/records/conditions-overview.tsx diff --git a/messages/de.json b/messages/de.json index 6f5243478..38556d3ae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Vorerkrankungen", - "cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll — aus deinem persönlichen Kontext.", + "cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll.", "conditionsLabel": "Chronische Erkrankungen", "focusLabel": "Worauf der Coach achtet", - "empty": "Noch nichts hinterlegt.", - "editCta": "Im persönlichen Kontext bearbeiten", - "loadError": "Vorerkrankungen konnten nicht geladen werden." + "loadError": "Vorerkrankungen konnten nicht geladen werden.", + "savedToast": "Erkrankungen gespeichert.", + "saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut." } }, "environment": { diff --git a/messages/en.json b/messages/en.json index a1094539e..b2da6e5f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Pre-existing conditions", - "cardDescription": "Chronic conditions and what the Coach watches — from your personal context.", + "cardDescription": "Chronic conditions and what the Coach watches.", "conditionsLabel": "Chronic conditions", "focusLabel": "What the Coach watches", - "empty": "Nothing noted yet.", - "editCta": "Edit in personal context", - "loadError": "Couldn't load your conditions." + "loadError": "Couldn't load your conditions.", + "savedToast": "Conditions saved.", + "saveError": "Could not save. Please try again." } }, "environment": { diff --git a/messages/es.json b/messages/es.json index 5df7f651f..0389da016 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Enfermedades preexistentes", - "cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar, desde tu contexto personal.", + "cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar.", "conditionsLabel": "Enfermedades crónicas", "focusLabel": "Qué vigila el Coach", - "empty": "Aún no hay nada anotado.", - "editCta": "Editar en el contexto personal", - "loadError": "No se pudieron cargar tus enfermedades." + "loadError": "No se pudieron cargar tus enfermedades.", + "savedToast": "Enfermedades guardadas.", + "saveError": "No se pudo guardar. Inténtalo de nuevo." } }, "environment": { diff --git a/messages/fr.json b/messages/fr.json index f0dbd75a7..fa4add4df 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Affections préexistantes", - "cardDescription": "Affections chroniques et ce que le Coach doit surveiller, d'après votre contexte personnel.", + "cardDescription": "Affections chroniques et ce que le Coach doit surveiller.", "conditionsLabel": "Affections chroniques", "focusLabel": "Ce que le Coach surveille", - "empty": "Rien de noté pour l'instant.", - "editCta": "Modifier dans le contexte personnel", - "loadError": "Impossible de charger vos antécédents." + "loadError": "Impossible de charger vos antécédents.", + "savedToast": "Affections enregistrées.", + "saveError": "Impossible d'enregistrer. Veuillez réessayer." } }, "environment": { diff --git a/messages/it.json b/messages/it.json index 4785ce709..a888be6cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Patologie preesistenti", - "cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio, dal tuo contesto personale.", + "cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio.", "conditionsLabel": "Patologie croniche", "focusLabel": "Cosa tiene d'occhio il Coach", - "empty": "Ancora nessuna annotazione.", - "editCta": "Modifica nel contesto personale", - "loadError": "Impossibile caricare le tue patologie." + "loadError": "Impossibile caricare le tue patologie.", + "savedToast": "Patologie salvate.", + "saveError": "Impossibile salvare. Riprova." } }, "environment": { diff --git a/messages/pl.json b/messages/pl.json index 4b6b91788..be44a8a3c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7828,12 +7828,12 @@ }, "conditions": { "cardTitle": "Choroby przewlekłe", - "cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę — z Twojego kontekstu osobistego.", + "cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę.", "conditionsLabel": "Choroby przewlekłe", "focusLabel": "Na co zwraca uwagę Coach", - "empty": "Nic jeszcze nie zapisano.", - "editCta": "Edytuj w kontekście osobistym", - "loadError": "Nie udało się wczytać Twoich chorób." + "loadError": "Nie udało się wczytać Twoich chorób.", + "savedToast": "Choroby zapisane.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie." } }, "environment": { diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx new file mode 100644 index 000000000..686a5dfa1 --- /dev/null +++ b/src/components/records/conditions-manager.tsx @@ -0,0 +1,166 @@ +"use client"; + +/** + * v1.25.12 — pre-existing / chronic conditions editor, at home in the Anamnese + * (medical-history) section. + * + * The chronic-conditions note and the explicit "what should the Coach watch" + * focus are foundational medical history, so they are edited here next to + * allergies + family history — one coherent medical-history home. They used to + * be edited under Settings → Profile → "About me"; the editing moved here so the + * conditions the Coach watches sit with the rest of the medical record. + * + * Data path is unchanged: both fields are part of the self-context payload + * (`GET`/`PUT /api/coach/about-me`) and are read/written through the same + * `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app uses, + * so every consumer (the Coach above all) keeps reading the same store. The PUT + * echoes the current `aboutMe` free text (a required field that clears on empty) + * and omits `allergies` (optional, preserved server-side) so saving conditions + * never disturbs the other self-context fields. + * + * Plain text only — values render exclusively through inputs and React text + * children; no markdown renderer (XSS posture, see contributor notes). + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; + +const FALLBACK_FIELD_MAX_CHARS = 500; + +interface AboutMeData { + aboutMe: string | null; + conditions: string | null; + allergies: string | null; + coachFocus: string | null; + fieldMaxChars: number; +} + +const FIELD_CLASSES = + "border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none"; + +export function ConditionsManager() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [drafts, setDrafts] = useState<{ + conditions: string | null; + coachFocus: string | null; + }>({ conditions: null, coachFocus: null }); + + const query = useQuery({ + queryKey: queryKeys.coachAboutMe(), + queryFn: () => apiGet("/api/coach/about-me"), + }); + + const saved = { + conditions: query.data?.conditions ?? "", + coachFocus: query.data?.coachFocus ?? "", + }; + const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS; + + const value = { + conditions: drafts.conditions ?? saved.conditions, + coachFocus: drafts.coachFocus ?? saved.coachFocus, + }; + const dirty = + value.conditions !== saved.conditions || + value.coachFocus !== saved.coachFocus; + + const save = useMutation({ + mutationKey: queryKeys.coachAboutMe(), + mutationFn: async (input: { conditions: string; coachFocus: string }) => { + // Echo the current free-text `aboutMe` (required; empty clears it) and + // omit `allergies` (optional, preserved server-side) so this write only + // touches the two conditions fields. + return apiPut("/api/coach/about-me", { + aboutMe: query.data?.aboutMe ?? "", + conditions: input.conditions, + coachFocus: input.coachFocus, + }); + }, + onSuccess: () => { + toast.success(t("records.conditions.savedToast")); + setDrafts({ conditions: null, coachFocus: null }); + queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMeQuestions(), + }); + }, + onError: () => { + toast.error(t("records.conditions.saveError")); + }, + }); + + const disabled = query.isLoading || save.isPending; + + function field( + key: "conditions" | "coachFocus", + label: string, + placeholder: string, + ) { + return ( +
+ +