Merge pull request #394 from MBombeck/release/v1.25.12

release: v1.25.12 — follow-up fixes
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-30 23:57:26 +02:00
commit 103f218734
25 files changed
+689 -426

No files matched your search

+12
View File
@@ -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
+1 -1
View File
@@ -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.
+6 -5
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+6 -5
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -61,13 +61,24 @@ function render() {
}
describe("<BottomNav> 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("<BottomNav> 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("<BottomNav> 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: {} };
});
@@ -72,111 +72,96 @@ function render({
);
}
describe("<SidebarNav> 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("<SidebarNav> first-paint fail-closed module gate (v1.25.12)", () => {
it("renders core destinations on first paint regardless of the module map", () => {
const maps: Record<string, boolean>[] = [
{},
{ 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("<SidebarNav> 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("<SidebarNav> 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("<SidebarNav> 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(/<a[^>]*href="\/coach"[^>]*>/);
const insights = html.match(/<a[^>]*href="\/insights"[^>]*>/);
expect(coach?.[0]).toMatch(/aria-current="page"/);
expect(insights?.[0]).not.toMatch(/aria-current="page"/);
expect(html).toContain('href="/measurements"');
});
});
+16 -5
View File
@@ -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) {
+19 -2
View File
@@ -242,12 +242,24 @@ export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
* 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) => ({
+9 -2
View File
@@ -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;
@@ -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(
<InstrumentCard
instrument="PHQ9"
last={undefined}
onStart={() => {}}
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(
<AssessmentHistory rows={rows} instrument="PHQ9" />,
);
// 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(
<AssessmentHistory rows={onlyGad} instrument="PHQ9" />,
);
expect(html).toContain(mh.history.empty);
expect(html).not.toContain('data-slot="history-list"');
});
});
@@ -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<InstrumentId>("PHQ9");
const [tab, setTab] = useState<InstrumentId>(instrument ?? "PHQ9");
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(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 (
<Card data-slot="mental-health-history">
<CardHeader>
<CardTitle className="text-base">
{t("mentalHealth.history.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isEmpty ? (
<EmptyState
icon={<Activity className="size-6" />}
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 ? (
<EmptyState
icon={<Activity className="size-6" />}
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 && (
<div
className="flex gap-1"
role="tablist"
@@ -116,9 +132,9 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
key={id}
type="button"
role="tab"
aria-selected={tab === id}
aria-selected={activeTab === id}
size="sm"
variant={tab === id ? "secondary" : "ghost"}
variant={activeTab === id ? "secondary" : "ghost"}
className="h-8 px-3 text-xs"
onClick={() => {
setTab(id);
@@ -129,63 +145,88 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
</Button>
))}
</div>
)}
<AssessmentHistoryChart instrument={tab} rows={forTab} />
<AssessmentHistoryChart instrument={activeTab} rows={forTab} />
{forTab.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
) : (
<ul className="flex flex-col gap-1.5" data-slot="history-list">
{forTab.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{formatDate(row.takenAt)}
</span>
<div className="flex items-center gap-2">
{flagged && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0"
aria-expanded={open}
onClick={() =>
setOpenCrisisRowId(open ? null : row.id)
}
data-slot="history-flagged-marker"
>
<Badge variant="outline">
{t("mentalHealth.history.flaggedBadge")}
</Badge>
</Button>
{forTab.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
) : (
<ul className="flex flex-col gap-1.5" data-slot="history-list">
{forTab.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{formatDate(row.takenAt)}
</span>
<div className="flex items-center gap-2">
{flagged && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0"
aria-expanded={open}
onClick={() =>
setOpenCrisisRowId(open ? null : row.id)
}
data-slot="history-flagged-marker"
>
<Badge variant="outline">
{t("mentalHealth.history.flaggedBadge")}
</Badge>
</Button>
)}
<Badge variant="secondary">
{t(
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
)}
<Badge variant="secondary">
{t(
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
)}
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
{flagged && open && (
<CrisisCard crisis={crisisFromRow(row)} />
)}
</li>
);
})}
</ul>
)}
</>
)}
</CardContent>
</div>
{flagged && open && (
<CrisisCard crisis={crisisFromRow(row)} />
)}
</li>
);
})}
</ul>
)}
</>
)}
</>
);
// Pinned: render bare (the host sheet owns the chrome + title). Combined:
// the landing's titled history card, unchanged.
if (pinned) {
return (
<div
data-slot="mental-health-history"
data-pinned={instrument}
className="flex flex-col gap-4"
>
{body}
</div>
);
}
return (
<Card data-slot="mental-health-history">
<CardHeader>
<CardTitle className="text-base">
{t("mentalHealth.history.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{body}</CardContent>
</Card>
);
}
@@ -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 (
<Card className="h-full gap-3" data-slot="instrument-card">
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
<div className="flex flex-col gap-0.5">
<span className="text-lg leading-none font-semibold">
{t(`mentalHealth.instrument.${key}`)}
</span>
<span className="text-muted-foreground text-xs">
{t(`mentalHealth.instrumentSub.${key}`)}
</span>
</div>
{/* 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. */}
<button
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring/60 hover:bg-accent/40 -m-1 flex flex-1 cursor-pointer flex-col gap-3.5 rounded-md p-1 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-col gap-0.5">
<span className="text-lg leading-none font-semibold">{title}</span>
<span className="text-muted-foreground text-xs">
{t(`mentalHealth.instrumentSub.${key}`)}
</span>
</div>
{/* Last-test line in the medication-card grammar: label left, the
relative day (today / yesterday / date) right-aligned. The severity
band is intentionally NOT shown here — the trend below carries it. */}
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
{last ? (
<>
<span>{t("mentalHealth.lastResult")}</span>
<span className="shrink-0">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</>
) : (
<span>{t("mentalHealth.noResultYet")}</span>
)}
</div>
{/* Last-test line in the medication-card grammar: label left, the
relative day (today / yesterday / date) right-aligned. The severity
band is intentionally NOT shown here — the trend behind the card
carries it. */}
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
{last ? (
<>
<span>{t("mentalHealth.lastResult")}</span>
<span className="shrink-0">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</>
) : (
<span>{t("mentalHealth.noResultYet")}</span>
)}
</div>
</button>
<div className="mt-auto pt-0">
<div className="pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
@@ -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<Phase>("choose");
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
const [result, setResult] = useState<CreateResponse | null>(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<InstrumentId | null>(
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)}
/>
</li>
))}
@@ -126,6 +133,33 @@ export function MentalWellbeing() {
</section>
<AssessmentHistory rows={history?.assessments ?? []} />
{/* Per-instrument trend detail, opened from a card body. Reuses the
history's chart + dated list, pinned to the chosen instrument. */}
<ResponsiveSheet
open={detailInstrument !== null}
onOpenChange={(open) => {
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 && (
<AssessmentHistory
rows={history?.assessments ?? []}
instrument={detailInstrument}
/>
)}
</ResponsiveSheet>
</>
)}
@@ -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<AboutMeData>("/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<AboutMeData>("/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 (
<div className="space-y-1.5">
<Label htmlFor={`records-conditions-${key}`}>{label}</Label>
<textarea
id={`records-conditions-${key}`}
data-testid={`records-conditions-${key}`}
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
rows={2}
value={value[key]}
maxLength={fieldMaxChars}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => setDrafts((d) => ({ ...d, [key]: e.target.value }))}
/>
</div>
);
}
return (
<div className="space-y-4" data-slot="conditions-manager">
{query.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("records.conditions.loadError")}
</p>
) : (
<>
{field(
"conditions",
t("records.conditions.conditionsLabel"),
t("settings.ai.aboutMe.conditionsPlaceholder"),
)}
{field(
"coachFocus",
t("records.conditions.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="records-conditions-save"
disabled={disabled || !dirty}
onClick={() => save.mutate(value)}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("settings.ai.aboutMe.save")}
</Button>
</div>
</>
)}
</div>
);
}
@@ -1,98 +0,0 @@
"use client";
/**
* v1.25.1 — pre-existing / chronic conditions, surfaced in the Anamnese
* (medical-history) home.
*
* The chronic-conditions note and the explicit "what should the Coach watch"
* focus the user entered in Settings → Profile → "About me" are foundational
* medical history, so they are surfaced here next to allergies + family history
* to give one coherent medical-history overview.
*
* This is a READ-ONLY shared view of the existing self-context
* (`GET /api/coach/about-me`) — it reads through the same query-key the "About
* me" panel writes, so the two stay in lockstep. Editing keeps its single home
* in personal context (linked below); nothing is moved, no new storage, no
* schema change. Plain text only — values render as React text children, no
* markdown renderer (XSS posture, see contributor notes).
*/
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
interface SelfContext {
conditions: string | null;
coachFocus: string | null;
}
export function ConditionsOverview() {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.coachAboutMe(),
queryFn: () => apiGet<SelfContext>("/api/coach/about-me"),
});
const conditions = (data?.conditions ?? "").trim();
const coachFocus = (data?.coachFocus ?? "").trim();
const hasAny = conditions.length > 0 || coachFocus.length > 0;
return (
<div className="space-y-4" data-slot="conditions-overview">
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("records.conditions.loadError")}
</p>
)}
{!isLoading && !isError && (
<>
{hasAny ? (
<dl className="space-y-3">
{conditions.length > 0 && (
<div className="space-y-1">
<dt className="text-sm font-medium">
{t("records.conditions.conditionsLabel")}
</dt>
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
{conditions}
</dd>
</div>
)}
{coachFocus.length > 0 && (
<div className="space-y-1">
<dt className="text-sm font-medium">
{t("records.conditions.focusLabel")}
</dt>
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
{coachFocus}
</dd>
</div>
)}
</dl>
) : (
<p className="text-muted-foreground text-sm">
{t("records.conditions.empty")}
</p>
)}
<p className="text-xs">
<Link
href="/settings/account"
className="text-dracula-purple underline-offset-4 hover:underline"
>
{t("records.conditions.editCta")}
</Link>
</p>
</>
)}
</div>
);
}
+34 -74
View File
@@ -3,14 +3,21 @@
/**
* v1.15.20 — Settings → AI "About me" panel (free text only).
* v1.16.0 — structured self-context: alongside the free text the user
* answers three short questions (chronic conditions, allergies /
* intolerances, what the Coach should watch). All four fields are
* encrypted at rest; age/gender stay on the profile and are merged
* into the prompt server-side — the panel says so instead of asking
* again.
* notes allergies / intolerances. All fields are encrypted at rest;
* age/gender stay on the profile and are merged into the prompt
* server-side — the panel says so instead of asking again.
*
* v1.25.12 — the pre-existing / chronic conditions and the "what should the
* Coach watch" focus moved out of this panel into Settings → Anamnese, where
* they sit with allergies + family history as one medical history. Editing them
* has a single home there now; this panel keeps the free-text note and the
* allergies line. The store is unchanged — every field is still part of the
* `/api/coach/about-me` payload; the PUT here simply omits the two conditions
* fields, which are preserved server-side.
*
* GET /api/coach/about-me — full structured payload + pending questions
* PUT /api/coach/about-me — writes all four fields (empty string clears)
* PUT /api/coach/about-me — writes aboutMe (empty string clears) + allergies;
* conditions / coachFocus are omitted here and left untouched
*
* After a save the server derives up to 3 clarifying questions (AI
* when a provider + budget allow, deterministic hints otherwise); the
@@ -54,7 +61,7 @@ interface AboutMeData {
fieldMaxChars: number;
}
type StructuredKey = "conditions" | "allergies" | "coachFocus";
type StructuredKey = "allergies";
async function fetchAboutMe(): Promise<AboutMeData> {
return apiGet<AboutMeData>("/api/coach/about-me");
@@ -72,10 +79,8 @@ export function AboutMeSection({
const queryClient = useQueryClient();
const [drafts, setDrafts] = useState<{
aboutMe: string | null;
conditions: string | null;
allergies: string | null;
coachFocus: string | null;
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
}>({ aboutMe: null, allergies: null });
const query = useQuery({
queryKey: queryKeys.coachAboutMe(),
@@ -85,9 +90,7 @@ export function AboutMeSection({
const saved = {
aboutMe: query.data?.aboutMe ?? "",
conditions: query.data?.conditions ?? "",
allergies: query.data?.allergies ?? "",
coachFocus: query.data?.coachFocus ?? "",
};
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
@@ -95,57 +98,33 @@ export function AboutMeSection({
const value = {
aboutMe: drafts.aboutMe ?? saved.aboutMe,
conditions: drafts.conditions ?? saved.conditions,
allergies: drafts.allergies ?? saved.allergies,
coachFocus: drafts.coachFocus ?? saved.coachFocus,
};
const dirty =
value.aboutMe !== saved.aboutMe ||
value.conditions !== saved.conditions ||
value.allergies !== saved.allergies ||
value.coachFocus !== saved.coachFocus;
const hasAnyContent =
saved.aboutMe.length > 0 ||
saved.conditions.length > 0 ||
saved.allergies.length > 0 ||
saved.coachFocus.length > 0;
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
// Quiet completeness meter: one segment per answered field. Reads
// the SAVED state (not the draft) so it only moves on persistence.
const answered = [
saved.conditions,
saved.allergies,
saved.coachFocus,
saved.aboutMe,
].filter((v) => v.length > 0).length;
const answered = [saved.allergies, saved.aboutMe].filter(
(v) => v.length > 0,
).length;
const save = useMutation({
mutationKey: queryKeys.coachAboutMe(),
mutationFn: async (input: {
aboutMe: string;
conditions: string;
allergies: string;
coachFocus: string;
}) => {
// Send only the two fields this panel owns. `conditions` / `coachFocus`
// are omitted so the server preserves them (they are edited under Anamnese).
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
return apiPut<AboutMeData>("/api/coach/about-me", input);
},
onSuccess: (next) => {
const cleared =
!next.aboutMe &&
!next.conditions &&
!next.allergies &&
!next.coachFocus;
const cleared = !next.aboutMe && !next.allergies;
toast.success(
cleared
? t("settings.ai.aboutMe.clearedToast")
: t("settings.ai.aboutMe.savedToast"),
);
setDrafts({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
});
setDrafts({ aboutMe: null, allergies: null });
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMeQuestions(),
@@ -195,19 +174,19 @@ export function AboutMeSection({
titleId="settings-ai-about-me-title"
description={t("settings.ai.aboutMe.description")}
/>
{/* Completeness — four quiet segments, no percentage shouting. */}
{/* Completeness — two quiet segments, no percentage shouting. */}
<div
className="flex items-center gap-1.5"
aria-label={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
total: 2,
})}
title={t("settings.ai.aboutMe.completeness", {
answered,
total: 4,
total: 2,
})}
>
{[0, 1, 2, 3].map((i) => (
{[0, 1].map((i) => (
<span
key={i}
aria-hidden="true"
@@ -220,24 +199,12 @@ export function AboutMeSection({
</div>
</div>
<div className="grid gap-4 pl-7 sm:grid-cols-2">
{structuredField(
"conditions",
t("settings.ai.aboutMe.conditionsLabel"),
t("settings.ai.aboutMe.conditionsPlaceholder"),
)}
<div className="pl-7">
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
t("settings.ai.aboutMe.allergiesPlaceholder"),
)}
<div className="sm:col-span-2">
{structuredField(
"coachFocus",
t("settings.ai.aboutMe.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
</div>
</div>
<div className="space-y-1.5 pl-7">
@@ -277,14 +244,7 @@ export function AboutMeSection({
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() =>
save.mutate({
aboutMe: "",
conditions: "",
allergies: "",
coachFocus: "",
})
}
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.aboutMe.clear")}
@@ -351,9 +311,9 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.1 — cross-link to the Anamnese (medical-history) home. The
chronic conditions noted above plus allergies + family history read as
one coherent medical history there. */}
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
@@ -9,12 +9,13 @@
* signal and not a clinical diagnosis — surfaced alongside the existing
* tracking-domain settings sections (Labs / Illness / Vorsorge).
*
* v1.25.1 — the section also surfaces the pre-existing / chronic conditions the
* Coach watches (the self-context entered under Profile → "About me") as a
* read-only shared view, so conditions + allergies + family history read as one
* coherent medical history. The conditions card is coach-gated (the data only
* feeds the Coach) and links back to its single editing home in personal
* context.
* v1.25.12 — the section is the single home for the pre-existing / chronic
* conditions the Coach watches, edited inline here so conditions + allergies +
* family history read (and write) as one coherent medical history. The
* conditions card is coach-gated (the data only feeds the Coach); it reads and
* writes the same self-context store (`/api/coach/about-me`) the rest of the app
* uses — the placement simply moved out of personal context into the medical
* record where it belongs.
*/
import {
@@ -28,7 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { AllergyManager } from "@/components/records/allergy-manager";
import { ConditionsOverview } from "@/components/records/conditions-overview";
import { ConditionsManager } from "@/components/records/conditions-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
export function AnamnesisSection() {
@@ -45,7 +46,7 @@ export function AnamnesisSection() {
</CardDescription>
</CardHeader>
<CardContent>
<ConditionsOverview />
<ConditionsManager />
</CardContent>
</Card>
)}
+13 -1
View File
@@ -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
@@ -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.