diff --git a/CHANGELOG.md b/CHANGELOG.md index be7b42350..050368bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.25.12] — 2026-06-30 — Follow-up fixes + +### Fixed + +- The left navigation no longer briefly flashes entries for modules you don't have enabled while a page loads. +- The daily briefing could time out before finishing on an account with a long history; it now has a longer window to generate. (If it still clips, raise the AI response timeout in Settings → AI and/or add a second AI provider so a slow one falls back.) +- Pre-existing and chronic conditions — the ones the Coach watches — now live in Medical history (Anamnese), alongside allergies and family history, instead of the personal-context panel. Where they are stored is unchanged. + +### Changed + +- Tapping a mental-wellbeing assessment card (PHQ-9, GAD-7) now opens that instrument's trend, the same way a medication card opens its detail. + ## [1.25.11] — 2026-06-30 — Consistent settings and cards ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 19f3154e8..cea382712 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.25.11 + version: 1.25.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 4438719f7..38556d3ae 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.", @@ -7827,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 11fdc9ee9..b2da6e5f8 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.", @@ -7827,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 c498e22e8..0389da016 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.", @@ -7827,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 8cba119f6..fa4add4df 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.", @@ -7827,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 88a6e34fb..a888be6cb 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.", @@ -7827,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 3b16a27c3..be44a8a3c 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.", @@ -7827,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/package.json b/package.json index e419dec47..f9dc4f089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.25.11", + "version": "1.25.12", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index ea3ee2d4d..751bc2d91 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.25.11"; + /* @sw-version-fallback */ "v1.25.12"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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; 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 && ( + + )} + )} 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 ( +
+ +