From 38629d41ed134ebc93b5f508b0e59531f14245f4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 14:53:53 +0200
Subject: [PATCH 1/8] fix(insights): repair the score-anatomy layout and align
Coach affordances
The readiness/recovery score-anatomy detail card rendered broken: the
contributor factors were obscured and the method text overlapped the
title and ring on every breakpoint. The provenance explainer no longer
ships a collapsible info glyph; it renders its full method body inline,
but the anatomy view still wrapped it in an absolute top-right box, which
pulled the whole paragraph out of flow and painted it over the centred
title, ring and contributor rows. Render the method caption in normal
flow at the foot of the card so the factors read again and nothing
overlaps.
Move the weekly/monthly review Coach hand-off to the icon-only affordance
pinned top-right, matching the assessment cards instead of a trailing
text button.
Widen the Coach conversation-history page to the standard content width
so the conversation list no longer sits in an artificially narrow column.
---
src/app/coach/conversations/page.tsx | 2 +-
.../__tests__/score-anatomy-view.test.tsx | 6 ++
.../insights/derived/score-anatomy-view.tsx | 30 ++++----
.../insights/period-narrative-card.tsx | 77 ++++++++++---------
4 files changed, 60 insertions(+), 55 deletions(-)
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 3d68a8fbf..dfa955015 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -120,7 +120,7 @@ function CoachConversationsBody() {
}
return (
-
+
", () => {
expect(html).toContain('data-contributor="respiratory"');
expect(html).toContain('data-present="false"');
expect(html).toContain('data-slot="provenance-explainer-method"');
+ expect(html).toContain("A blend of deviation components.");
// v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed;
// the non-clinical framing now lives in the inline provenance method.
expect(html).not.toContain('data-slot="score-anatomy-disclaimer"');
+ // Regression guard: `ProvenanceExplainer` renders its full method body
+ // inline (the v1.22 refactor dropped the ⓘ glyph), so it must sit in
+ // normal flow — never pulled out via `absolute top-3 right-3`, which
+ // painted the whole paragraph over the title, ring and contributor rows.
+ expect(html).not.toContain("absolute top-3 right-3");
});
it("tints the card + leans the ring hue when a hue is passed (F1)", () => {
diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx
index 8bac63efb..bbc2b1dc5 100644
--- a/src/components/insights/derived/score-anatomy-view.tsx
+++ b/src/components/insights/derived/score-anatomy-view.tsx
@@ -126,24 +126,12 @@ export function ScoreAnatomyView({
: undefined
}
className={cn(
- "relative flex flex-col gap-5 rounded-xl border p-5",
+ "flex flex-col gap-5 rounded-xl border p-5",
hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
>
- {/* v1.15.12 F5 — the ⓘ explainer is pinned TOP-right at heading height
- (it used to trail the card at bottom-right). Absolute so it never
- shifts the centred hero title; the trigger keeps its 44px touch
- target + popover/sheet behaviour. */}
-
-
-
-
{/* Hero: title + ring + caption */}
)}
- {/* v1.15.12 F4 — the "Richtwert — keine klinische Bewertung" footer line
- is gone; the method + cited standard live behind the provenance
- explainer (now top-right, F5), which carries the non-clinical framing
- where it belongs. The composition (contributor rows above) stays. */}
+ {/* The plain-language method caption closes the card, in normal flow.
+ `ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it
+ renders its full method body inline as a muted caption, so it must
+ sit in the document flow at the foot of the surface. The earlier
+ `absolute top-right` wrapper yanked the whole paragraph out of flow
+ and painted it over the centred title, ring and contributor rows on
+ every breakpoint; rendering it here keeps the spine readable. */}
+
);
}
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 222401b94..ed5681a2f 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -11,7 +11,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
-import { AskCoachAction } from "@/components/insights/ask-coach-action";
+import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
@@ -142,29 +142,45 @@ export function PeriodNarrativeCard({
data-period={period}
className={SHELL}
>
- {/* Week / month toggle. Calm segmented control, no chart. */}
-
- {(["week", "month"] as const).map((p) => {
- const active = period === p;
- return (
-
- );
- })}
+ {/* Header row: week / month toggle on the left, the icon-only Coach
+ hand-off pinned top-right. Matches the assessment cards, which carry
+ the same single `` in their header's right slot
+ (no text label, tooltip + accessible name). The summary spans the
+ whole picture for the window, so no scope: the default snapshot
+ reads best. */}
+
+ {/* Calm segmented control, no chart. */}
+
+ {(["week", "month"] as const).map((p) => {
+ const active = period === p;
+ return (
+
+ );
+ })}
+
+
{/* v1.22 (W6) — real paragraphs via the shared ProseBlocks helper
@@ -184,19 +200,6 @@ export function PeriodNarrativeCard({
user?.timezone,
)}
-
- {/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans
- the whole picture for the window, so no scope: the default
- snapshot reads best. */}
-
-
-
);
From c6cbd5442f70d90126d67cf298aeec2c6a738d97 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 14:55:05 +0200
Subject: [PATCH 2/8] fix(labs): polish the biomarker-detail header and
description
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolve the catalog slug from both the current-locale display name and the
English canonical name. A marker stores the catalog name as it read at
creation time, so one minted under the English default and then viewed in
another locale never matched a map built only from the current locale's
names — its rich per-biomarker description silently fell back to the generic
line. The English bundle is always in the client chunk, so adding its names
makes the lookup independent of the locale the marker was created in.
Tidy the header control cluster:
- Drop the standalone "adjust target range" control and its sheet; the
reference bounds are already editable inside the full marker editor behind
the pencil. Remove the now-dead handler, mutation, and `targetRange.*`
strings across all locales.
- Give the show-all-values, edit, and delete controls one shared ghost-icon
treatment instead of the show-all control's separate muted variant.
- Add hover tooltips to edit and delete, matching the other icon controls.
- Order the controls left → right: delete, edit, show all values, add value.
---
messages/de.json | 9 +-
messages/en.json | 9 +-
messages/es.json | 9 +-
messages/fr.json | 9 +-
messages/it.json | 9 +-
messages/pl.json | 9 +-
src/components/data-list/delete-button.tsx | 4 +
.../__tests__/lab-biomarker-detail.test.tsx | 18 ++
src/components/labs/lab-biomarker-detail.tsx | 196 +++++-------------
9 files changed, 83 insertions(+), 189 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index c97fb115e..e7def3d80 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker wiederhergestellt",
"hideError": "Biomarker konnte nicht aktualisiert werden",
"allHidden": "Alle Biomarker sind ausgeblendet.",
- "hiddenSection": "Ausgeblendet",
- "targetRange": {
- "title": "Zielbereich anpassen",
- "description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.",
- "save": "Bereich speichern",
- "savedToast": "Zielbereich aktualisiert",
- "saveError": "Zielbereich konnte nicht aktualisiert werden"
- }
+ "hiddenSection": "Ausgeblendet"
},
"catalog": {
"total-cholesterol": "Gesamtcholesterin",
diff --git a/messages/en.json b/messages/en.json
index 3f2844f28..bd894e35d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker restored",
"hideError": "Could not update the biomarker",
"allHidden": "Every biomarker is hidden.",
- "hiddenSection": "Hidden",
- "targetRange": {
- "title": "Adjust target range",
- "description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.",
- "save": "Save range",
- "savedToast": "Target range updated",
- "saveError": "Could not update the target range"
- }
+ "hiddenSection": "Hidden"
},
"catalog": {
"total-cholesterol": "Total cholesterol",
diff --git a/messages/es.json b/messages/es.json
index 98aee4b3d..503038b73 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarcador restaurado",
"hideError": "No se pudo actualizar el biomarcador",
"allHidden": "Todos los biomarcadores están ocultos.",
- "hiddenSection": "Ocultos",
- "targetRange": {
- "title": "Ajustar el rango objetivo",
- "description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.",
- "save": "Guardar rango",
- "savedToast": "Rango objetivo actualizado",
- "saveError": "No se pudo actualizar el rango objetivo"
- }
+ "hiddenSection": "Ocultos"
},
"catalog": {
"total-cholesterol": "Colesterol total",
diff --git a/messages/fr.json b/messages/fr.json
index 80c0be5fe..905061214 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarqueur restauré",
"hideError": "Impossible de mettre à jour le biomarqueur",
"allHidden": "Tous les biomarqueurs sont masqués.",
- "hiddenSection": "Masqués",
- "targetRange": {
- "title": "Ajuster la plage cible",
- "description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.",
- "save": "Enregistrer la plage",
- "savedToast": "Plage cible mise à jour",
- "saveError": "Impossible de mettre à jour la plage cible"
- }
+ "hiddenSection": "Masqués"
},
"catalog": {
"total-cholesterol": "Cholestérol total",
diff --git a/messages/it.json b/messages/it.json
index 03a3fa8ed..e8f815b74 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarcatore ripristinato",
"hideError": "Impossibile aggiornare il biomarcatore",
"allHidden": "Tutti i biomarcatori sono nascosti.",
- "hiddenSection": "Nascosti",
- "targetRange": {
- "title": "Regola l'intervallo obiettivo",
- "description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.",
- "save": "Salva intervallo",
- "savedToast": "Intervallo obiettivo aggiornato",
- "saveError": "Impossibile aggiornare l'intervallo obiettivo"
- }
+ "hiddenSection": "Nascosti"
},
"catalog": {
"total-cholesterol": "Colesterolo totale",
diff --git a/messages/pl.json b/messages/pl.json
index 4b2ba446c..25388622c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -7129,14 +7129,7 @@
"unhiddenToast": "Biomarker przywrócony",
"hideError": "Nie udało się zaktualizować biomarkera",
"allHidden": "Wszystkie biomarkery są ukryte.",
- "hiddenSection": "Ukryte",
- "targetRange": {
- "title": "Dostosuj zakres docelowy",
- "description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.",
- "save": "Zapisz zakres",
- "savedToast": "Zaktualizowano zakres docelowy",
- "saveError": "Nie udało się zaktualizować zakresu docelowego"
- }
+ "hiddenSection": "Ukryte"
},
"catalog": {
"total-cholesterol": "Cholesterol całkowity",
diff --git a/src/components/data-list/delete-button.tsx b/src/components/data-list/delete-button.tsx
index 47ee7d5dd..c963e0026 100644
--- a/src/components/data-list/delete-button.tsx
+++ b/src/components/data-list/delete-button.tsx
@@ -32,6 +32,7 @@ export function DeleteButton({
description,
cancelLabel,
confirmLabel,
+ triggerTitle,
className = "",
iconClassName = "h-3.5 w-3.5",
}: {
@@ -40,6 +41,8 @@ export function DeleteButton({
description: string;
cancelLabel?: string;
confirmLabel?: string;
+ /** Native hover tooltip on the trigger; defaults to the accessible name. */
+ triggerTitle?: string;
className?: string;
iconClassName?: string;
}) {
@@ -52,6 +55,7 @@ export function DeleteButton({
size="icon-lg"
className={`text-destructive ${className}`}
aria-label={confirmLabel ?? t("common.delete")}
+ title={triggerTitle ?? confirmLabel ?? t("common.delete")}
>
diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
index 7da3247d5..bec4b97b6 100644
--- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
+++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
@@ -136,4 +136,22 @@ describe("", () => {
// skeleton geometry — proof the assessment card mounted on the page.
expect(html).toContain('data-testid="insight-status-card-loading"');
});
+
+ it("renders the rich catalog description for a catalogued marker", () => {
+ // "LDL Cholesterol" resolves to the `ldl` catalog slug (case-insensitive),
+ // so the page shows the per-biomarker explainer, not the generic fallback.
+ expect(html).toContain("low-density lipoprotein");
+ expect(html).not.toContain(
+ "A lab biomarker you track. Add readings to follow",
+ );
+ });
+
+ it("offers the edit + delete controls with hover tooltips", () => {
+ expect(html).toContain('title="Edit"');
+ expect(html).toContain('title="Delete"');
+ });
+
+ it("drops the standalone adjust-target-range control", () => {
+ expect(html).not.toContain("Adjust target range");
+ });
});
diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx
index 166586105..8f1c7ca47 100644
--- a/src/components/labs/lab-biomarker-detail.tsx
+++ b/src/components/labs/lab-biomarker-detail.tsx
@@ -9,32 +9,25 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
-import {
- FlaskConical,
- ListOrdered,
- Pencil,
- Plus,
- SlidersHorizontal,
-} from "lucide-react";
+import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
import { useMounted } from "@/hooks/use-mounted";
-import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
+import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import { useTranslations } from "@/lib/i18n/context";
-import { cn } from "@/lib/utils";
+import { fallbackMessages } from "@/lib/i18n/load-locale";
+import { resolveKey } from "@/lib/i18n/resolve-key";
import { queryKeys } from "@/lib/query-keys";
import dynamic from "next/dynamic";
@@ -89,13 +82,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
// It belongs on the detail page beneath the heading (mirroring the metric
- // pages' explainer caption). Resolve the catalog slug from the marker name
- // exactly as the overview did, then fall back to the user's own `context`.
+ // pages' explainer caption). Resolve the catalog slug from the marker name,
+ // then fall back to the user's own `context`.
+ //
+ // v1.25.3 — key the lookup on BOTH the current-locale display name and the
+ // English canonical name. A marker stores the catalog name as it read at
+ // creation time (`biomarker-form` writes `t("labs.catalog.")`), so a
+ // marker minted under the English default and then viewed in another locale
+ // never matched a map built only from the current locale's names — the rich
+ // description silently fell through to the generic line. The English bundle
+ // is always in the client chunk (`fallbackMessages`), so adding its names
+ // makes resolution independent of the locale the marker was created in.
const slugByName = useMemo(() => {
const map = new Map();
for (const seed of BIOMARKER_CATALOG) {
- const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
- if (norm) map.set(norm, seed.slug);
+ const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
+ if (localized) map.set(localized, seed.slug);
+ const canonical = resolveKey(
+ fallbackMessages,
+ `labs.catalog.${seed.slug}`,
+ )
+ ?.trim()
+ .toLowerCase();
+ if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
}
return map;
}, [t]);
@@ -104,12 +113,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const [addFooterEl, setAddFooterEl] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState(null);
- // v1.24 — focused "adjust target range" sheet. Edits only the reference
- // bounds (the full marker editor lives behind the pencil); seeded from the
- // marker each time it opens.
- const [rangeOpen, setRangeOpen] = useState(false);
- const [lowerInput, setLowerInput] = useState("");
- const [upperInput, setUpperInput] = useState("");
const {
data: marker,
@@ -217,33 +220,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
- function openRange() {
- setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : "");
- setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : "");
- setRangeOpen(true);
- }
-
- const saveRange = useMutation({
- mutationFn: () => {
- const lower = lowerInput.trim() === "" ? null : Number(lowerInput);
- const upper = upperInput.trim() === "" ? null : Number(upperInput);
- return apiPut(`/api/biomarkers/${biomarkerId}`, {
- lowerBound: lower,
- upperBound: upper,
- });
- },
- onSuccess: () => {
- toast.success(t("labs.biomarker.targetRange.savedToast"));
- setRangeOpen(false);
- queryClient.invalidateQueries({
- queryKey: queryKeys.biomarkerDetail(biomarkerId),
- });
- queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
- queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
- },
- onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
- });
-
// v1.25.1 — the per-marker description leads the page, mirroring the metric
// sub-pages' explainer caption. Resolve the catalog slug from the marker name
// and fall back to the user's own `context`, then to a generic explainer so a
@@ -306,45 +282,17 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
- {/* v1.25.1 — "show all readings" rides the header cluster as an icon
- button, mirroring the metric sub-pages' `` control.
- The full reading feed lives on `/labs/[biomarkerId]/values`; the
- detail page keeps the numbers-first spine. */}
- {readings.length > 0 ? (
-
- ) : null}
-
+ {/* v1.25.3 — header control cluster, left → right:
+ Delete · Edit · Show-all-values · Add. The icon controls share one
+ ghost-icon treatment (the target-range editing lives inside the
+ full marker editor behind the pencil, so the separate range
+ control was dropped). */}
deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
+ triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
@@ -352,12 +300,34 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
- onClick={openRange}
+ onClick={() => setEditOpen(true)}
disabled={!marker}
- aria-label={t("labs.biomarker.targetRange.title")}
+ aria-label={t("labs.biomarker.edit")}
+ title={t("labs.biomarker.edit")}
>
-
+
+ {/* "Show all readings" mirrors the metric sub-pages' ``
+ control. The full reading feed lives on
+ `/labs/[biomarkerId]/values`; the detail page keeps the
+ numbers-first spine. */}
+ {readings.length > 0 ? (
+
+ ) : null}
- }
- >
-
-
-
- setLowerInput(e.target.value)}
- />
-
-
-
- setUpperInput(e.target.value)}
- />
-
-
- {marker?.unit ? (
-
- {t("labs.biomarker.form.rangeHint")}
-
- ) : null}
-
);
}
From 7e83af570176edb309ce5e481292345491f3df7c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 14:58:30 +0200
Subject: [PATCH 3/8] fix(modules): switch the inbound-documents module off in
code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The inbound-documents surface is withdrawn from this release and returns
later with more depth. Park its module key (`inboundDocuments`) in a new
`CODE_DISABLED_MODULE_KEYS` list so it is hard-off for every account ahead
of both the operator-availability and the per-user opt-in layer.
The gate short-circuits a code-disabled key to disabled, so the resolved
module map reports it off everywhere: the nav entry stays born-gated and
hidden, the `/documents` page redirects, and every `/api/documents/inbound/*`
route returns the 403 module-disabled envelope. The Modules hub and the
operator-availability panel drop the row, and the per-user PATCH surface
refuses to write the key (a clean 422 instead of an inert preference).
The whole implementation stays intact — routes, the inbound-documents view,
the registry entry with its opt-in marker, the validation schemas, and the
migration are all preserved. Re-enabling the module is a one-line removal
from the list once the rebuild lands.
Regenerate the OpenAPI contract: the module-preferences PATCH body no
longer advertises the parked key.
---
docs/api/openapi.yaml | 2 -
.../admin/module-availability-section.tsx | 48 ++++++----
.../__tests__/modules-section.test.tsx | 14 ++-
src/components/settings/modules-section.tsx | 89 ++++++++++---------
src/lib/modules/__tests__/gate.test.ts | 19 ++--
src/lib/modules/gate.ts | 9 ++
src/lib/modules/registry.ts | 31 +++++++
src/lib/validations/modules.ts | 13 ++-
8 files changed, 152 insertions(+), 73 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eb8ad6004..938ac99ac 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -10290,8 +10290,6 @@ components:
type: boolean
mcp:
type: boolean
- inboundDocuments:
- type: boolean
mentalHealth:
type: boolean
additionalProperties: false
diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx
index c15c72723..c552c0245 100644
--- a/src/components/admin/module-availability-section.tsx
+++ b/src/components/admin/module-availability-section.tsx
@@ -7,7 +7,11 @@ import { toast } from "sonner";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ MODULE_REGISTRY,
+ isCodeDisabledModule,
+} from "@/lib/modules/registry";
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
@@ -90,24 +94,30 @@ export function ModuleAvailabilitySection() {
description={t("admin.modules.description")}
/>
- {MODULE_KEYS.map((key) => {
- const def = MODULE_REGISTRY[key];
- // Default-available until the read resolves; an explicit `false`
- // from the operator layer is the only thing that turns it off.
- const available = availability?.[key] ?? true;
- return (
-
- mutation.mutate({ [key]: checked } as Partial)
- }
- disabled={mutation.isPending}
- />
- );
- })}
+ {MODULE_KEYS
+ // A module switched off in code (pending a rebuild) is hard-off
+ // server-wide; drop its row so the operator toggle can't mislead.
+ .filter((key) => !isCodeDisabledModule(key))
+ .map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-available until the read resolves; an explicit `false`
+ // from the operator layer is the only thing that turns it off.
+ const available = availability?.[key] ?? true;
+ return (
+
+ mutation.mutate({
+ [key]: checked,
+ } as Partial)
+ }
+ disabled={mutation.isPending}
+ />
+ );
+ })}
);
diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx
index dbd2822da..db37c4881 100644
--- a/src/components/settings/__tests__/modules-section.test.tsx
+++ b/src/components/settings/__tests__/modules-section.test.tsx
@@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
-import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ isCodeDisabledModule,
+ moduleDelegatesTo,
+} from "@/lib/modules/registry";
// Capture the latest useMutation config + a shared mutate spy so we can
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
@@ -112,7 +116,13 @@ describe("", () => {
// only renders the toggleable modules.
const html = render();
for (const key of MODULE_KEYS) {
- if (moduleDelegatesTo(key) !== undefined) {
+ if (isCodeDisabledModule(key)) {
+ // v1.25.3 — a module switched off in code (pending a rebuild) drops
+ // out of the hub entirely: no Switch, no deep-link, no row.
+ expect(html, `code-disabled has no switch ${key}`).not.toContain(
+ `id="module-toggle-${key}"`,
+ );
+ } else if (moduleDelegatesTo(key) !== undefined) {
// Delegated modules (cycle/coach) are owned by their real control
// elsewhere — they render as a read-only "manage in X" deep-link,
// never a live Switch that would write an inert disabled-allowlist
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index e67da7e3f..d38419b5b 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -55,6 +55,7 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
MODULE_REGISTRY,
+ isCodeDisabledModule,
moduleDelegatesTo,
type ModuleKey,
} from "@/lib/modules/registry";
@@ -130,48 +131,52 @@ export function ModulesSection() {
{t("settings.sections.modules.toggleable.description")}
- {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
- const def = MODULE_REGISTRY[key];
- // Default-on: a module is enabled unless explicitly `false`.
- const enabled = modules[key] !== false;
- // Delegated modules (cycle/coach) are owned elsewhere — never a
- // live toggle here; deep-link to their real control instead.
- const delegated = moduleDelegatesTo(key) !== undefined;
- // Operator turned this module off server-wide: no per-user
- // toggle can re-enable it, so show a read-only note. Delegated
- // rows already deep-link, so this only matters for owned keys.
- const operatorDisabled =
- !delegated && moduleAvailability[key] === false;
- return (
- toggle.mutate({ key, enabled: next })}
- />
- );
- })}
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[])
+ // Modules switched off in code (pending a rebuild) carry no live
+ // toggle — drop the row entirely so a user can't turn one on.
+ .filter((key) => !isCodeDisabledModule(key))
+ .map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-on: a module is enabled unless explicitly `false`.
+ const enabled = modules[key] !== false;
+ // Delegated modules (cycle/coach) are owned elsewhere — never a
+ // live toggle here; deep-link to their real control instead.
+ const delegated = moduleDelegatesTo(key) !== undefined;
+ // Operator turned this module off server-wide: no per-user
+ // toggle can re-enable it, so show a read-only note. Delegated
+ // rows already deep-link, so this only matters for owned keys.
+ const operatorDisabled =
+ !delegated && moduleAvailability[key] === false;
+ return (
+ toggle.mutate({ key, enabled: next })}
+ />
+ );
+ })}
);
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 730bf8b87..8785629eb 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -20,6 +20,7 @@ import {
} from "../operator-availability";
import {
isCoreDomain,
+ isCodeDisabledModule,
isModuleKey,
isOptInModule,
moduleDelegatesTo,
@@ -373,14 +374,22 @@ describe("registry — opt-in marker", () => {
});
});
-describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)", () => {
+describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => {
+ // v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild
+ // (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator
+ // and the per-user layer: no stored preference or availability blob can
+ // re-surface it. The registry entry + `optIn` marker stay intact.
+ it("is flagged as code-disabled in the registry", () => {
+ expect(isCodeDisabledModule("inboundDocuments")).toBe(true);
+ });
+
it("is OFF when no preference is recorded", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
- it("turns ON only on an explicit true (opt-in)", () => {
+ it("stays OFF even when the user opted in (the in-code switch wins)", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
@@ -388,16 +397,16 @@ describe("resolveModuleEnabled — inboundDocuments module (opt-in, default-OFF)
false,
ALL_AVAILABLE,
),
- ).toBe(true);
+ ).toBe(false);
});
- it("operator-off short-circuits even when the user opted in", () => {
+ it("stays OFF when the user opted in and the operator left it available", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
- operator({ inboundDocuments: false }),
+ operator({ inboundDocuments: true }),
),
).toBe(false);
});
diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts
index ff32f383a..61bb9bf04 100644
--- a/src/lib/modules/gate.ts
+++ b/src/lib/modules/gate.ts
@@ -55,6 +55,7 @@ import {
} from "./operator-availability";
import {
MODULE_KEYS,
+ isCodeDisabledModule,
isOptInModule,
moduleDelegatesTo,
type ModuleKey,
@@ -126,6 +127,14 @@ export function resolveModuleEnabled(
assistantCoach: boolean,
operatorAvailability: OperatorModuleAvailability,
): boolean {
+ // LAYER 0 — switched off in code pending a rebuild. Hard-off for every
+ // account, ahead of both the operator and the per-user layer, so no
+ // stored preference or availability blob can re-surface it. See
+ // `CODE_DISABLED_MODULE_KEYS` in ./registry.
+ if (isCodeDisabledModule(key)) {
+ return false;
+ }
+
// LAYER 1 — operator server-wide availability. An operator-disabled
// module is off for everyone regardless of personal preference.
if (operatorAvailability[key] === false) {
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 2af5a9636..97d422b38 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -327,6 +327,37 @@ export function isModuleKey(key: string): key is ModuleKey {
return MODULE_KEY_SET.has(key);
}
+/**
+ * Modules switched OFF in code, pending a holistic rebuild.
+ *
+ * A key listed here is hard-off for every account regardless of the
+ * per-user opt-in or the operator-availability layer: the gate short-
+ * circuits it to disabled (`resolveModuleEnabled`), the Modules hub and the
+ * operator-availability panel drop its row, and the per-user PATCH surface
+ * refuses to write it. The whole implementation (routes, components, schema,
+ * migration) stays intact — re-enabling the module is a one-line removal
+ * from this list once the rebuild lands.
+ *
+ * v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
+ * is withdrawn from this release and returns later with more depth. Its
+ * registry entry, `optIn` marker, routes, view, and migration 0222 are all
+ * preserved; only the user-reachable switches are removed.
+ */
+export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
+
+const CODE_DISABLED_MODULE_SET: ReadonlySet = new Set(
+ CODE_DISABLED_MODULE_KEYS,
+);
+
+/**
+ * True for a module intentionally switched off in code pending a rebuild
+ * (see `CODE_DISABLED_MODULE_KEYS`). Such a module is hard-off everywhere:
+ * the gate never reports it enabled and the user-facing toggles drop it.
+ */
+export function isCodeDisabledModule(key: ModuleKey): boolean {
+ return CODE_DISABLED_MODULE_SET.has(key);
+}
+
/** The two delegated keys, resolved by their existing source of truth. */
export function moduleDelegatesTo(
key: ModuleKey,
diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts
index 05c041870..7b9189e29 100644
--- a/src/lib/validations/modules.ts
+++ b/src/lib/validations/modules.ts
@@ -25,14 +25,21 @@
*/
import { z } from "zod/v4";
-import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
+import {
+ MODULE_KEYS,
+ isCodeDisabledModule,
+ moduleDelegatesTo,
+} from "@/lib/modules/registry";
/**
* The directly-owned (non-delegated) toggleable keys — the only keys this
- * PATCH surface may write into `modulePreferencesJson`.
+ * PATCH surface may write into `modulePreferencesJson`. A module switched
+ * off in code (pending a rebuild) is also excluded, so a client trying to
+ * opt into it gets a clean 422 rather than persisting an inert `true` the
+ * gate would ignore anyway.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
- (k) => moduleDelegatesTo(k) === undefined,
+ (k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
);
/** Build `{ ?: boolean }` from the directly-owned key list. */
From 4ad4707be9ffc39f6aad13ad6ebded12bc2be9d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 15:08:04 +0200
Subject: [PATCH 4/8] fix(insights): derive briefing warm budget from the
response timeout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.
Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.
Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.
Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
---
docs/api/openapi.yaml | 4 +-
messages/de.json | 7 +-
messages/en.json | 7 +-
messages/es.json | 7 +-
messages/fr.json | 7 +-
messages/it.json | 7 +-
messages/pl.json | 7 +-
src/app/api/insights/generate/route.ts | 26 ++++-
src/app/insights/page.tsx | 3 +
src/components/insights/daily-briefing.tsx | 25 ++++-
.../insights/use-insights-advisor.ts | 19 +++-
.../settings/ai/response-timeout-card.tsx | 3 +-
.../__tests__/briefing-failure-marker.test.ts | 70 ++++++++++++
src/lib/insights/briefing-failure-marker.ts | 62 ++++++++++-
src/lib/insights/comprehensive-generate.ts | 7 +-
.../__tests__/insight-pregenerate.test.ts | 105 ++++++++++++++++--
src/lib/jobs/insight-pregenerate.ts | 99 +++++++++++++++--
src/lib/openapi/routes/profile.ts | 2 +-
18 files changed, 426 insertions(+), 41 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index eb8ad6004..0215b7388 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -16947,8 +16947,8 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
- description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous
- built-in default (~180 s). Mainly for slow local/self-hosted backends.
+ description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
+ comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
ocrEnabled:
type: boolean
description: v1.22 (#90) — whether a dedicated document-scan (Lab-OCR) provider is active. When false the OCR path uses
diff --git a/messages/de.json b/messages/de.json
index c97fb115e..65ae113a8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.",
"retryAction": "Erneut versuchen",
"failedTitle": "Briefing konnte nicht erstellt werden",
- "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell."
+ "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.",
+ "failedDescriptionTimeout": "Die KI-Anfrage hat das Zeitlimit überschritten, bevor das Briefing fertig war. Erhöhe für ein langsames lokales Modell in den Einstellungen das Antwort-Zeitlimit und versuche es dann erneut.",
+ "failedDescriptionProvider": "Der KI-Anbieter hat die Anfrage abgelehnt. Prüfe Anbieter und API-Schlüssel in den KI-Einstellungen und versuche es erneut.",
+ "failedDescriptionRateLimit": "Der KI-Anbieter drosselt derzeit die Anfragen. Versuche es in Kürze erneut."
},
"trendsRow": {
"title": "Trends",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Antwort-Timeout",
"responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.",
"responseTimeoutLabel": "Antwort-Timeout (Sekunden)",
- "responseTimeoutPlaceholder": "Standard (~180)",
+ "responseTimeoutPlaceholder": "Standard (~120)",
"responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.",
"ocrHeading": "Dokumentenscan (Laborberichte)",
"ocrBody": "Verwende einen eigenen Anbieter zum Auslesen hochgeladener Laborberichte. Ist die Option aus, nutzt der Scan deinen Hauptanbieter.",
diff --git a/messages/en.json b/messages/en.json
index 3f2844f28..d149fffea 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Couldn't refresh the briefing — showing the last one.",
"retryAction": "Retry",
"failedTitle": "Couldn't generate the briefing",
- "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model."
+ "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.",
+ "failedDescriptionTimeout": "The AI request ran out of time before the briefing was ready. For a slow local model, raise the AI response timeout in Settings, then try again.",
+ "failedDescriptionProvider": "The AI provider rejected the request. Re-check the provider and API key in AI settings, then try again.",
+ "failedDescriptionRateLimit": "The AI provider is rate-limiting requests right now. Try again in a little while."
},
"trendsRow": {
"title": "Trends",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Response timeout",
"responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.",
"responseTimeoutLabel": "Response timeout (seconds)",
- "responseTimeoutPlaceholder": "Default (~180)",
+ "responseTimeoutPlaceholder": "Default (~120)",
"responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.",
"ocrHeading": "Document scanning (lab reports)",
"ocrBody": "Use a dedicated provider for reading uploaded lab reports. When off, scanning uses your main provider.",
diff --git a/messages/es.json b/messages/es.json
index 98aee4b3d..f3090ccd7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.",
"retryAction": "Reintentar",
"failedTitle": "No se pudo generar el briefing",
- "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento."
+ "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento.",
+ "failedDescriptionTimeout": "La solicitud de IA agotó el tiempo de espera antes de que el resumen estuviera listo. Para un modelo local lento, aumenta el tiempo de espera de respuesta en Ajustes y vuelve a intentarlo.",
+ "failedDescriptionProvider": "El proveedor de IA rechazó la solicitud. Revisa el proveedor y la clave de API en los ajustes de IA y vuelve a intentarlo.",
+ "failedDescriptionRateLimit": "El proveedor de IA está limitando las solicitudes en este momento. Vuelve a intentarlo en un rato."
},
"trendsRow": {
"title": "Tendencias",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Tiempo de espera de la respuesta",
"responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.",
"responseTimeoutLabel": "Tiempo de espera de la respuesta (segundos)",
- "responseTimeoutPlaceholder": "Predeterminado (~180)",
+ "responseTimeoutPlaceholder": "Predeterminado (~120)",
"responseTimeoutInvalid": "Introduce un número entre 10 y 600 segundos.",
"ocrHeading": "Escaneo de documentos (informes de laboratorio)",
"ocrBody": "Usa un proveedor dedicado para leer los informes de laboratorio subidos. Si está desactivado, el escaneo usa tu proveedor principal.",
diff --git a/messages/fr.json b/messages/fr.json
index 80c0be5fe..2064f3c52 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.",
"retryAction": "Réessayer",
"failedTitle": "Impossible de générer le briefing",
- "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent."
+ "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.",
+ "failedDescriptionTimeout": "La requête IA a dépassé le délai avant que le briefing soit prêt. Pour un modèle local lent, augmentez le délai de réponse dans les Paramètres, puis réessayez.",
+ "failedDescriptionProvider": "Le fournisseur d'IA a rejeté la requête. Vérifiez le fournisseur et la clé API dans les paramètres d'IA, puis réessayez.",
+ "failedDescriptionRateLimit": "Le fournisseur d'IA limite les requêtes pour le moment. Réessayez dans un instant."
},
"trendsRow": {
"title": "Tendances",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Délai de réponse",
"responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.",
"responseTimeoutLabel": "Délai de réponse (secondes)",
- "responseTimeoutPlaceholder": "Par défaut (~180)",
+ "responseTimeoutPlaceholder": "Par défaut (~120)",
"responseTimeoutInvalid": "Saisissez un nombre entre 10 et 600 secondes.",
"ocrHeading": "Numérisation de documents (comptes rendus de laboratoire)",
"ocrBody": "Utilisez un fournisseur dédié pour lire les comptes rendus de laboratoire importés. Désactivé, la numérisation utilise votre fournisseur principal.",
diff --git a/messages/it.json b/messages/it.json
index 03a3fa8ed..3111edab1 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.",
"retryAction": "Riprova",
"failedTitle": "Impossibile generare il briefing",
- "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento."
+ "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento.",
+ "failedDescriptionTimeout": "La richiesta IA ha superato il tempo limite prima che il briefing fosse pronto. Per un modello locale lento, aumenta il timeout di risposta nelle Impostazioni e riprova.",
+ "failedDescriptionProvider": "Il provider IA ha rifiutato la richiesta. Controlla il provider e la chiave API nelle impostazioni IA e riprova.",
+ "failedDescriptionRateLimit": "Il provider IA sta limitando le richieste in questo momento. Riprova tra poco."
},
"trendsRow": {
"title": "Tendenze",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Timeout della risposta",
"responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.",
"responseTimeoutLabel": "Timeout della risposta (secondi)",
- "responseTimeoutPlaceholder": "Predefinito (~180)",
+ "responseTimeoutPlaceholder": "Predefinito (~120)",
"responseTimeoutInvalid": "Inserisci un numero tra 10 e 600 secondi.",
"ocrHeading": "Scansione documenti (referti di laboratorio)",
"ocrBody": "Usa un provider dedicato per leggere i referti di laboratorio caricati. Se disattivato, la scansione usa il tuo provider principale.",
diff --git a/messages/pl.json b/messages/pl.json
index 4b2ba446c..8ef6c060c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2066,7 +2066,10 @@
"refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.",
"retryAction": "Spróbuj ponownie",
"failedTitle": "Nie udało się wygenerować briefingu",
- "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego."
+ "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.",
+ "failedDescriptionTimeout": "Żądanie AI przekroczyło limit czasu, zanim briefing był gotowy. W przypadku wolnego modelu lokalnego zwiększ limit czasu odpowiedzi w Ustawieniach i spróbuj ponownie.",
+ "failedDescriptionProvider": "Dostawca AI odrzucił żądanie. Sprawdź dostawcę i klucz API w ustawieniach AI, a następnie spróbuj ponownie.",
+ "failedDescriptionRateLimit": "Dostawca AI ogranicza teraz liczbę żądań. Spróbuj ponownie za chwilę."
},
"trendsRow": {
"title": "Trendy",
@@ -4671,7 +4674,7 @@
"responseTimeoutHeading": "Limit czasu odpowiedzi",
"responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.",
"responseTimeoutLabel": "Limit czasu odpowiedzi (sekundy)",
- "responseTimeoutPlaceholder": "Domyślnie (~180)",
+ "responseTimeoutPlaceholder": "Domyślnie (~120)",
"responseTimeoutInvalid": "Podaj liczbę od 10 do 600 sekund.",
"ocrHeading": "Skanowanie dokumentów (wyniki badań)",
"ocrBody": "Użyj dedykowanego dostawcy do odczytu przesłanych wyników badań. Gdy wyłączone, skanowanie używa Twojego głównego dostawcy.",
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index 5dcf2081c..9a16bad10 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -319,8 +319,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
// briefing is held rather than freshly refreshed — and, when there is no
// last good text, that the empty state should read "couldn't generate" with
// a retry rather than the generic "no briefing yet".
- const generationFailed =
- (await readBriefingFailure({ userId, since: cachedAt })) !== null;
+ const briefingFailure = await readBriefingFailure({
+ userId,
+ since: cachedAt,
+ });
+ const generationFailed = briefingFailure !== null;
+ // v1.25.3 — the failure class lets the empty state point its hint at the
+ // right lever (raise the response timeout vs re-check the provider).
+ const generationFailureClass = briefingFailure?.failureClass ?? null;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -368,6 +374,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
hasProvider,
// v1.25 — true when the last generation attempt failed (held text).
generationFailed,
+ // v1.25.3 — coarse failure class for the empty-state hint (null when
+ // the last attempt succeeded).
+ generationFailureClass,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -390,6 +399,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.25 — no last-good text AND the last attempt failed: the UI shows a
// "couldn't generate" empty state with a retry instead of the generic one.
generationFailed,
+ // v1.25.3 — coarse failure class for the empty-state hint.
+ generationFailureClass,
});
});
@@ -760,6 +771,16 @@ export const POST = apiHandler((request: NextRequest) =>
// path, so the last good briefing stays intact). The read path pairs the
// preserved text with a discreet "couldn't refresh" hint, or shows a
// retry empty state when there is no last good text.
+ // v1.25.3 — carry the upstream status so the read-path hint can tell an
+ // auth / rate-limit failure apart from a plain timeout. For an
+ // all-providers-failed error the first hop's status is the most
+ // representative; a single provider error exposes its own `httpStatus`.
+ const failureHttpStatus =
+ e instanceof AllProvidersFailedError
+ ? (e.attempts[0]?.httpStatus ?? null)
+ : typeof (e as { httpStatus?: number }).httpStatus === "number"
+ ? (e as { httpStatus?: number }).httpStatus
+ : null;
void recordBriefingFailure({
userId,
reason:
@@ -767,6 +788,7 @@ export const POST = apiHandler((request: NextRequest) =>
? "all-providers-failed"
: "provider-error",
locale,
+ httpStatus: failureHttpStatus,
});
if (e instanceof AllProvidersFailedError) {
annotate({
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 48050141e..c65baad7b 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -480,6 +480,9 @@ export default function InsightsPage() {
// generate" one. Suppressed when no provider is configured (that hint
// owns the surface and a retry would be futile).
generationFailed={advisor.generationFailed && advisor.hasProvider}
+ // v1.25.3 — failure class points the empty-state hint at the right
+ // lever (raise the response timeout vs re-check the provider).
+ generationFailureClass={advisor.generationFailureClass}
// v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard
// snapshot. Both strings are already localised; null leaves the card's
// memory block unrendered.
diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx
index 311b761de..309da0fc7 100644
--- a/src/components/insights/daily-briefing.tsx
+++ b/src/components/insights/daily-briefing.tsx
@@ -36,6 +36,7 @@ import type {
DailyBriefingKeyFinding,
DailyBriefingSignal,
} from "@/lib/ai/schema";
+import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
* v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing
@@ -127,6 +128,13 @@ interface DailyBriefingProps {
* generic empty state for a "couldn't generate" one whose CTA retries.
*/
generationFailed?: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure. When the empty state is
+ * shown ("couldn't generate"), it lets the hint point at the right lever:
+ * `timeout` → raise the AI response timeout, `auth` → re-check the provider.
+ * Absent / null → the generic failed-description holds.
+ */
+ generationFailureClass?: BriefingFailureClass | null;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
@@ -358,6 +366,7 @@ export function DailyBriefing({
noProvider = false,
noProviderStale = false,
generationFailed = false,
+ generationFailureClass = null,
metaSlot,
memory = null,
}: DailyBriefingProps) {
@@ -365,6 +374,20 @@ export function DailyBriefing({
const fmt = useFormatters();
const { user } = useAuth();
+ // v1.25.3 — when there is no last good text and the last attempt failed,
+ // point the hint at the lever that matches the failure class. A timeout (the
+ // dominant failure on a slow self-hosted model) → raise the AI response
+ // timeout; an auth / config rejection → re-check the provider in Settings.
+ // Anything else keeps the calmer generic line. Non-alarming by design.
+ const failedDescriptionKey =
+ generationFailureClass === "timeout"
+ ? "insights.dailyBriefing.failedDescriptionTimeout"
+ : generationFailureClass === "auth"
+ ? "insights.dailyBriefing.failedDescriptionProvider"
+ : generationFailureClass === "rate-limit"
+ ? "insights.dailyBriefing.failedDescriptionRateLimit"
+ : "insights.dailyBriefing.failedDescription";
+
return (
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
// moves OUT of the card into an `
` above it (mirroring the
@@ -588,7 +611,7 @@ export function DailyBriefing({
}
description={
generationFailed
- ? t("insights.dailyBriefing.failedDescription")
+ ? t(failedDescriptionKey)
: t("insights.dailyBriefing.emptyDescription")
}
action={
diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts
index d271742df..83f0eaadc 100644
--- a/src/components/insights/use-insights-advisor.ts
+++ b/src/components/insights/use-insights-advisor.ts
@@ -16,6 +16,7 @@ import type {
} from "@/lib/ai/schema";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
+import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
/**
* v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared TanStack Query
@@ -80,6 +81,13 @@ export interface InsightAdvisorPayload {
* Absent on a pre-field cached payload → treated as "not failed" (no hint).
*/
generationFailed?: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure (`timeout`, `auth`,
+ * `rate-limit`, `provider`, `format`, `unknown`), so the empty state can
+ * point its hint at the right lever. Null / absent → no specific hint, the
+ * generic failed-description holds.
+ */
+ generationFailureClass?: BriefingFailureClass | null;
}
/**
@@ -99,8 +107,7 @@ const ADVISOR_TIMEOUT_MS = 8_000;
* discarded a slow-but-successful generation — the abort returned a null
* payload, the mutation only invalidated (re-reading the OLD cache), yet
* the server had often already written the fresh briefing. Give the force
- * branch the same 45 s budget the out-of-band warm job uses
- * (`COMPREHENSIVE_WARM_TIMEOUT_MS`) so a slow success is kept, not
+ * branch a generous 45 s client budget so a slow success is kept, not
* dropped. The READ path stays fast at 8 s — only the explicit user tap
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
*/
@@ -291,6 +298,12 @@ export interface UseInsightsAdvisorResult {
* when there is no last good text. Defaults false (no hint) when absent.
*/
generationFailed: boolean;
+ /**
+ * v1.25.3 — coarse class of the most recent failure, so the empty state can
+ * point its hint at the right lever. Null when the last attempt succeeded or
+ * the payload predates the field.
+ */
+ generationFailureClass: BriefingFailureClass | null;
}
/**
@@ -379,5 +392,7 @@ export function useInsightsAdvisorQuery(
// Absent (pre-field cached payload or unsettled query) → not failed, so a
// transient unknown never flashes a false "couldn't refresh" hint.
generationFailed: query.data?.payload?.generationFailed ?? false,
+ // Absent → no specific lever hint; the generic failed-description holds.
+ generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
};
}
diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx
index c87f64e2d..590d50c26 100644
--- a/src/components/settings/ai/response-timeout-card.tsx
+++ b/src/components/settings/ai/response-timeout-card.tsx
@@ -7,7 +7,8 @@
* call (`CompletionParams.timeoutMs`). Surfaced here mainly for local /
* self-hosted backends: an MLX/exo server can take >60 s on the first request
* while it loads the model, which the legacy 60 s default timed out before the
- * first token landed. Empty = the generous built-in default (~180 s).
+ * first token landed. Empty = the built-in comprehensive-briefing default
+ * (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`).
* ──────────────────────────────────────────────────────────────── */
import { useState } from "react";
diff --git a/src/lib/insights/__tests__/briefing-failure-marker.test.ts b/src/lib/insights/__tests__/briefing-failure-marker.test.ts
index e50ff58d3..3ea4e5ad0 100644
--- a/src/lib/insights/__tests__/briefing-failure-marker.test.ts
+++ b/src/lib/insights/__tests__/briefing-failure-marker.test.ts
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
import {
readBriefingFailure,
recordBriefingFailure,
+ classifyBriefingFailure,
BRIEFING_FAILURE_ACTION,
} from "../briefing-failure-marker";
@@ -45,6 +46,34 @@ describe("readBriefingFailure", () => {
);
});
+ it("derives a failure class from the recorded reason + httpStatus (v1.25.3)", async () => {
+ findFirst.mockResolvedValue({
+ createdAt: new Date("2026-06-28T10:00:00Z"),
+ details: JSON.stringify({ reason: "provider-error", httpStatus: 401 }),
+ });
+
+ const result = await readBriefingFailure({
+ userId: "u1",
+ since: new Date("2026-06-28T09:00:00Z"),
+ });
+
+ expect(result?.failureClass).toBe("auth");
+ });
+
+ it("classes a status-less failure as a timeout (the slow-backend case)", async () => {
+ findFirst.mockResolvedValue({
+ createdAt: new Date("2026-06-28T10:00:00Z"),
+ details: JSON.stringify({ reason: "provider-error" }),
+ });
+
+ const result = await readBriefingFailure({
+ userId: "u1",
+ since: new Date("2026-06-28T09:00:00Z"),
+ });
+
+ expect(result?.failureClass).toBe("timeout");
+ });
+
it("treats a marker older than the last success as superseded", async () => {
findFirst.mockResolvedValue({
createdAt: new Date("2026-06-28T08:00:00Z"),
@@ -89,4 +118,45 @@ describe("recordBriefingFailure", () => {
recordBriefingFailure({ userId: "u1", reason: "timeout" }),
).resolves.toBeUndefined();
});
+
+ it("persists the upstream httpStatus when one is supplied (v1.25.3)", async () => {
+ create.mockResolvedValue({});
+ await recordBriefingFailure({
+ userId: "u1",
+ reason: "provider-error",
+ httpStatus: 429,
+ });
+ const details = create.mock.calls[0][0].data.details as string;
+ expect(JSON.parse(details).httpStatus).toBe(429);
+ });
+});
+
+describe("classifyBriefingFailure", () => {
+ it("maps invalid-json to format regardless of status", () => {
+ expect(classifyBriefingFailure({ reason: "invalid-json" })).toBe("format");
+ });
+
+ it("maps 429 to rate-limit, other 4xx to auth, 5xx to provider", () => {
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 429 }),
+ ).toBe("rate-limit");
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 403 }),
+ ).toBe("auth");
+ expect(
+ classifyBriefingFailure({ reason: "provider-error", httpStatus: 503 }),
+ ).toBe("provider");
+ });
+
+ it("maps a missing / sentinel-zero status to timeout", () => {
+ expect(classifyBriefingFailure({ reason: "provider-error" })).toBe(
+ "timeout",
+ );
+ expect(
+ classifyBriefingFailure({
+ reason: "all-providers-failed",
+ httpStatus: 0,
+ }),
+ ).toBe("timeout");
+ });
});
diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts
index ad11c863d..71abc304a 100644
--- a/src/lib/insights/briefing-failure-marker.ts
+++ b/src/lib/insights/briefing-failure-marker.ts
@@ -19,6 +19,46 @@ import { prisma } from "@/lib/db";
*/
export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure";
+/**
+ * Coarse class of a briefing-generation failure, used by the read path to
+ * point the empty-state hint at the right lever:
+ * - `timeout` — the generation ran out of time (no upstream HTTP status,
+ * or a transport/abort error). Lever: raise the AI response
+ * timeout for a slow local model.
+ * - `auth` — the provider rejected the request (4xx that is not 429).
+ * Lever: re-check the provider / API key in Settings.
+ * - `rate-limit` — the provider returned 429.
+ * - `provider` — a 5xx / generic upstream failure.
+ * - `format` — the model returned unparseable output (invalid JSON).
+ * - `unknown` — anything that does not classify.
+ */
+export type BriefingFailureClass =
+ "timeout" | "auth" | "rate-limit" | "provider" | "format" | "unknown";
+
+/**
+ * Classify a failure from its recorded reason and (optional) upstream HTTP
+ * status. A reason of `invalid-json` is always a format miss; a 401/403 (or any
+ * other non-429 4xx) is an auth / configuration problem; 429 is a rate limit; a
+ * 5xx is a generic provider failure. With no status at all — the timeout /
+ * transport / abort path, which is the dominant briefing failure on a slow
+ * self-hosted backend — the most useful lever is the response-timeout, so it
+ * classifies as `timeout`.
+ */
+export function classifyBriefingFailure(args: {
+ reason: string;
+ httpStatus?: number | null;
+}): BriefingFailureClass {
+ if (args.reason === "invalid-json") return "format";
+ const status = args.httpStatus;
+ if (typeof status === "number" && status > 0) {
+ if (status === 429) return "rate-limit";
+ if (status >= 400 && status < 500) return "auth";
+ if (status >= 500) return "provider";
+ }
+ // No / sentinel-zero upstream status: a transport-level timeout or abort.
+ return "timeout";
+}
+
/** UTC YYYY-MM-DD calendar-day key for the marker payload. */
function dateKey(at: Date = new Date()): string {
return at.toISOString().slice(0, 10);
@@ -32,6 +72,12 @@ export async function recordBriefingFailure(args: {
userId: string;
reason: string;
locale?: string;
+ /**
+ * v1.25.3 — upstream HTTP status when the failure carried one, so the read
+ * path can tell an auth / rate-limit / provider failure apart from a plain
+ * timeout and point the hint at the right lever.
+ */
+ httpStatus?: number | null;
}): Promise {
try {
await prisma.auditLog.create({
@@ -41,6 +87,7 @@ export async function recordBriefingFailure(args: {
details: JSON.stringify({
dateKey: dateKey(),
reason: args.reason,
+ httpStatus: args.httpStatus ?? null,
locale: args.locale ?? null,
triedAt: new Date().toISOString(),
}),
@@ -54,6 +101,8 @@ export async function recordBriefingFailure(args: {
export interface BriefingFailureState {
triedAt: string;
reason: string;
+ /** v1.25.3 — coarse class for the empty-state hint (see classifyBriefingFailure). */
+ failureClass: BriefingFailureClass;
}
/**
@@ -77,13 +126,22 @@ export async function readBriefingFailure(args: {
if (args.since && latest.createdAt <= args.since) return null;
let reason = "unknown";
+ let httpStatus: number | null = null;
if (latest.details) {
try {
- const parsed = JSON.parse(latest.details) as { reason?: unknown };
+ const parsed = JSON.parse(latest.details) as {
+ reason?: unknown;
+ httpStatus?: unknown;
+ };
if (typeof parsed.reason === "string") reason = parsed.reason;
+ if (typeof parsed.httpStatus === "number") httpStatus = parsed.httpStatus;
} catch {
// Malformed marker payload — still report the failure, generic reason.
}
}
- return { triedAt: latest.createdAt.toISOString(), reason };
+ return {
+ triedAt: latest.createdAt.toISOString(),
+ reason,
+ failureClass: classifyBriefingFailure({ reason, httpStatus }),
+ };
}
diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts
index 5d754e993..be4ab8ca4 100644
--- a/src/lib/insights/comprehensive-generate.ts
+++ b/src/lib/insights/comprehensive-generate.ts
@@ -1049,7 +1049,12 @@ export async function generateComprehensiveInsight(
// discreet "couldn't refresh" hint alongside the preserved last-good
// briefing. No cache row is written here, so `insightsCachedText` (the
// last good payload) stays intact and the surface never blanks.
- void recordBriefingFailure({ userId, reason, locale });
+ void recordBriefingFailure({
+ userId,
+ reason,
+ locale,
+ httpStatus: typeof err.httpStatus === "number" ? err.httpStatus : null,
+ });
return { status: "failed", reason };
}
diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts
index 1dc34196b..5dea6830d 100644
--- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts
+++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts
@@ -82,13 +82,21 @@ import {
runInsightPregenerate,
forceWarmUser,
findPregenerateCandidates,
+ comprehensiveWarmBudgetMs,
PREGENERATE_STALE_MS,
FORCE_WARM_DAILY_LIMIT,
INSIGHT_PREGENERATE_QUEUE,
INSIGHT_PREGENERATE_CRON,
} from "../insight-pregenerate";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
-function makePrisma(users: Array<{ id: string; locale: string | null }>) {
+function makePrisma(
+ users: Array<{
+ id: string;
+ locale: string | null;
+ aiResponseTimeoutSeconds?: number | null;
+ }>,
+) {
const findMany = vi.fn().mockResolvedValue(users);
// forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job
// start and maintains the failure marker; default: never warmed, never
@@ -296,8 +304,9 @@ describe("runInsightPregenerate — outcome tally", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
- // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -314,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => {
statusGenerators,
warmGenericMetrics,
});
- await vi.advanceTimersByTimeAsync(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired, aborted the still-running generation (so its
@@ -789,8 +798,9 @@ 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.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -808,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(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
for (const g of statusGenerators) {
@@ -906,8 +916,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
observedSignal = opts.signal;
setTimeout(
() => resolve({ status: "generated", providerType: "x" }),
- // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the
- // bounded budget fires before this "slow" generation settles.
+ // v1.25.3 — above the default warm budget (~150 s = the 120 s
+ // comprehensive provider budget + headroom) so the bounded budget
+ // fires before this "slow" generation settles.
200_000,
);
}),
@@ -924,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
statusGenerators,
warmGenericMetrics,
});
- await vi.advanceTimersByTimeAsync(140_000);
+ await vi.advanceTimersByTimeAsync(160_000);
const result = await promise;
// The timeout fired and aborted the still-running generation.
@@ -975,3 +986,77 @@ describe("queue registration", () => {
expect(INSIGHT_PREGENERATE_CRON).toMatch(/^\d+ \d+ \* \* \*$/);
});
});
+
+describe("comprehensiveWarmBudgetMs — warm budget scales with the response timeout (v1.25.3)", () => {
+ const HEADROOM_MS = 30_000;
+ const DEFAULT_MS = AI_BUDGETS.comprehensive.timeoutMs;
+
+ it("falls back to the comprehensive surface budget + headroom for an unset value", () => {
+ expect(comprehensiveWarmBudgetMs(null)).toBe(DEFAULT_MS + HEADROOM_MS);
+ expect(comprehensiveWarmBudgetMs(undefined)).toBe(DEFAULT_MS + HEADROOM_MS);
+ // A non-positive stored value is treated as unset.
+ expect(comprehensiveWarmBudgetMs(0)).toBe(DEFAULT_MS + HEADROOM_MS);
+ });
+
+ it("scales the budget with a raised setting and stays above the old fixed 130 s cap", () => {
+ // The regression: any account whose response timeout was >= ~130 s had its
+ // comprehensive warm clipped by the old flat 130 s cap, never landing.
+ const budget = comprehensiveWarmBudgetMs(300);
+ expect(budget).toBe(300_000 + HEADROOM_MS);
+ expect(budget).toBeGreaterThan(130_000);
+ });
+
+ it("clamps to an absolute ceiling that still sits above the 600 s write-time maximum", () => {
+ // The 600 s max a user can configure is honoured (not clipped below it);
+ // the ceiling adds the headroom so a legitimate 600 s setting fits.
+ expect(comprehensiveWarmBudgetMs(600)).toBe(600_000 + HEADROOM_MS);
+ // A value beyond the write-time bound can never reach here, but the clamp
+ // is the structural backstop against a wedged call regardless.
+ expect(comprehensiveWarmBudgetMs(100_000)).toBe(600_000 + HEADROOM_MS);
+ });
+});
+
+describe("runInsightPregenerate — warm budget honours the response timeout (v1.25.3)", () => {
+ it("does NOT clip a slow comprehensive for an account whose response timeout is raised past the old cap", async () => {
+ const { prisma } = makePrisma([
+ { id: "u1", locale: "de", aiResponseTimeoutSeconds: 300 },
+ ]);
+ let observedSignal: AbortSignal | undefined;
+ const generate = vi.fn().mockImplementation(
+ (_userId: string, opts: { signal?: AbortSignal }) =>
+ new Promise((resolve) => {
+ observedSignal = opts.signal;
+ // 200 s: above the OLD fixed 130 s cap (which would have aborted it)
+ // but well within the 330 s budget a 300 s setting now yields.
+ setTimeout(
+ () => resolve({ status: "generated", providerType: "x" }),
+ 200_000,
+ );
+ }),
+ );
+ const statusGenerators = Array.from({ length: 7 }, () =>
+ vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
+ );
+ const warmGenericMetrics = vi.fn().mockResolvedValue(0);
+
+ vi.useFakeTimers();
+ try {
+ const promise = runInsightPregenerate(prisma as never, {
+ generate,
+ statusGenerators,
+ warmGenericMetrics,
+ });
+ // Advance past the slow generation but below the 330 s warm budget.
+ await vi.advanceTimersByTimeAsync(210_000);
+ const result = await promise;
+
+ // The generation landed instead of being clipped: counted as generated,
+ // not failed, and the abort never fired.
+ expect(result.generated).toBe(1);
+ expect(result.failed).toBe(0);
+ expect(observedSignal?.aborted).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts
index 0c8af42ea..080707a2c 100644
--- a/src/lib/jobs/insight-pregenerate.ts
+++ b/src/lib/jobs/insight-pregenerate.ts
@@ -67,6 +67,8 @@ import {
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import { withTimeout } from "@/lib/insights/with-timeout";
+import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
+import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import {
INSIGHT_PREGENERATE_QUEUE,
enqueueForceWarm,
@@ -164,15 +166,72 @@ const WARM_PASS_CONCURRENCY = 3;
* the provider's own abort governs a genuine completion while this stays the
* backstop against a wedged call. The per-user budget gate + the WARM_PASS
* concurrency cap still bound total nightly cost.
+ *
+ * v1.25.3 — the cap is no longer a fixed constant. The per-user
+ * `User.aiResponseTimeoutSeconds` setting (bounded 10–600 s at write-time) is
+ * the operator's lever for slow self-hosted / local backends, and the AI
+ * settings UI tells users to RAISE it for a model that loads slowly. The old
+ * fixed 130 s cap sat below the upper end of that range, so any account whose
+ * response timeout was ≥ ~130 s had every comprehensive warm clipped by the
+ * warm bound — not the provider — before the generation could land, leaving the
+ * briefing on its hard empty state. The warm budget now derives from the
+ * resolved effective per-request timeout plus a fixed headroom for feature
+ * extraction / prompt assembly / IO, so raising the response timeout actually
+ * extends the warm/cron budget. A sane absolute ceiling (the 600 s write-time
+ * maximum plus the same headroom) keeps a single wedged call from pinning the
+ * worker forever, while staying ABOVE the largest value a user can configure.
*/
-const COMPREHENSIVE_WARM_TIMEOUT_MS = 130_000;
+
+/**
+ * Fixed slack added on top of the resolved upstream provider timeout when
+ * sizing the warm/cron outer abort: bounded-feature extraction, prompt
+ * assembly, and the cache write all run inside the warm wrapper but outside the
+ * single provider round-trip the response-timeout governs.
+ */
+const COMPREHENSIVE_WARM_HEADROOM_MS = 30_000;
+
+/**
+ * Largest value `User.aiResponseTimeoutSeconds` accepts (see the 10–600 s
+ * write-time bound in `POST /api/user/ai-provider`), in ms. The warm ceiling is
+ * derived from it so the ceiling is always ABOVE the maximum a user can set.
+ */
+const AI_RESPONSE_TIMEOUT_MAX_MS = 600_000;
+
+/**
+ * Absolute backstop on the warm outer abort. A misconfigured 600 s response
+ * timeout still cannot pin the worker beyond this; the ceiling sits above the
+ * write-time maximum (by exactly the headroom) so a legitimate 600 s setting is
+ * honoured rather than silently clipped.
+ */
+const COMPREHENSIVE_WARM_CEILING_MS =
+ AI_RESPONSE_TIMEOUT_MAX_MS + COMPREHENSIVE_WARM_HEADROOM_MS;
+
+/**
+ * Outer-abort budget (ms) for one comprehensive warm, derived from the user's
+ * resolved effective upstream timeout. `effectiveTimeoutMs + headroom`, clamped
+ * to the absolute ceiling. A null/unset setting falls back to the comprehensive
+ * surface budget (`AI_BUDGETS.comprehensive.timeoutMs`), so a default account
+ * gets `120 s + headroom` rather than the old flat 130 s.
+ */
+export function comprehensiveWarmBudgetMs(
+ aiResponseTimeoutSeconds: number | null | undefined,
+): number {
+ const effectiveTimeoutMs = resolveEffectiveTimeoutMs(
+ aiResponseTimeoutSeconds,
+ AI_BUDGETS.comprehensive.timeoutMs,
+ );
+ return Math.min(
+ effectiveTimeoutMs + COMPREHENSIVE_WARM_HEADROOM_MS,
+ COMPREHENSIVE_WARM_CEILING_MS,
+ );
+}
/** Per-user result of one forced full warm. */
export interface ForceWarmResult {
/**
* Outcome of the comprehensive-insight generation. `"timeout"` is
* distinct from `"failed"`: the comprehensive step exceeded its own
- * bounded budget (see `COMPREHENSIVE_WARM_TIMEOUT_MS`) and was
+ * bounded budget (see `comprehensiveWarmBudgetMs`) and was
* abandoned so the per-status + generic-metric passes could still run.
*
* v1.16.8 — three pre-flight outcomes that never reach the generator:
@@ -384,6 +443,12 @@ async function warmGenericMetricCaches(
interface PregenerateCandidate {
id: string;
locale: string | null;
+ /**
+ * v1.25.3 — per-user response-timeout (seconds), feeds the warm outer
+ * abort so a raised setting is not clipped by a fixed cap. Null = the
+ * comprehensive surface default.
+ */
+ aiResponseTimeoutSeconds: number | null;
}
/**
@@ -410,7 +475,9 @@ export async function findPregenerateCandidates(
// never-generated users sort ahead of merely-stale ones.
orderBy: { insightsCachedAt: "asc" },
take: cap,
- select: { id: true, locale: true },
+ // v1.25.3 — carry the per-user response-timeout so the warm outer abort
+ // scales with the operator's setting instead of a fixed cap.
+ select: { id: true, locale: true, aiResponseTimeoutSeconds: true },
});
}
@@ -531,6 +598,12 @@ export async function runInsightPregenerate(
// late resolve would still write a cache row + timestamp after the
// loop moved on (the same race the forced single-user warm closes).
const controller = new AbortController();
+ // v1.25.3 — size the outer abort from the candidate's resolved
+ // response-timeout so a raised setting extends the warm budget
+ // instead of being clipped by a fixed cap.
+ const warmBudgetMs = comprehensiveWarmBudgetMs(
+ candidate.aiResponseTimeoutSeconds,
+ );
const bounded = await withTimeout(
() =>
generate(candidate.id, {
@@ -538,7 +611,7 @@ export async function runInsightPregenerate(
force: true,
signal: controller.signal,
}),
- COMPREHENSIVE_WARM_TIMEOUT_MS,
+ warmBudgetMs,
null,
() => controller.abort(),
);
@@ -657,7 +730,7 @@ export async function runInsightPregenerate(
* actually reads warms lazily through the read-path enqueue.
*
* The three sections are decoupled: the comprehensive step runs under its
- * own bounded budget (`COMPREHENSIVE_WARM_TIMEOUT_MS`) and its
+ * own bounded budget (`comprehensiveWarmBudgetMs`) and its
* failure / timeout / skip is non-fatal — the per-status (7) and
* generic-metric (~30) warm passes ALWAYS run afterwards. The earlier
* design short-circuited both passes on a non-`generated`/`cached`
@@ -760,11 +833,17 @@ export async function forceWarmUser(
let freshness: {
insightsCachedAt: Date | null;
insightsWarmFailedAt: Date | null;
+ // v1.25.3 — feeds the warm outer abort (see comprehensiveWarmBudgetMs).
+ aiResponseTimeoutSeconds: number | null;
} | null = null;
try {
freshness = await prisma.user.findUnique({
where: { id: userId },
- select: { insightsCachedAt: true, insightsWarmFailedAt: true },
+ select: {
+ insightsCachedAt: true,
+ insightsWarmFailedAt: true,
+ aiResponseTimeoutSeconds: true,
+ },
});
} catch {
// Best-effort pre-flight — on a read failure fall through to the
@@ -807,6 +886,12 @@ export async function forceWarmUser(
});
} else {
const controller = new AbortController();
+ // v1.25.3 — size the outer abort from the user's resolved
+ // response-timeout (read with the freshness gate above) so a raised
+ // setting extends the warm budget instead of being clipped.
+ const warmBudgetMs = comprehensiveWarmBudgetMs(
+ freshness?.aiResponseTimeoutSeconds,
+ );
const comprehensive = await withTimeout(
() =>
generate(userId, {
@@ -814,7 +899,7 @@ export async function forceWarmUser(
force: true,
signal: controller.signal,
}),
- COMPREHENSIVE_WARM_TIMEOUT_MS,
+ warmBudgetMs,
null,
() => controller.abort(),
);
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index d987f1fc7..e6f43ee11 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -339,7 +339,7 @@ const aiProviderResponse = z
.int()
.nullable()
.describe(
- "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the generous built-in default (~180 s). Mainly for slow local/self-hosted backends.",
+ "v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
),
ocrEnabled: z
.boolean()
From d4272b13c4afa036f1a32a9a2828f193885e7d3f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 15:09:28 +0200
Subject: [PATCH 5/8] feat(mental-health): one-question check-in + card layout
+ trend history
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rework the PHQ-9 / GAD-7 screener surface to the card + detail-page grammar
the rest of the app uses, and replace the all-questions-at-once form with a
one-question-at-a-time check-in. Presentational + client-flow only — the API,
encryption at rest, the opt-in module gate, and the item-9 crisis-safety
behaviour are untouched, and the surface stays off the AI Coach by construction.
- Split the single 387-line component into a focused folder: an orchestrator +
landing, instrument card, check-in wizard, result block, history card + its
lazy chart, a shared crisis card, shared types, and a pure step-grammar module.
- Check-in reuses the medication wizard's stepper + Back/Next footer grammar,
rendered inline on the page: one question per step, auto-advance on select,
backward navigation to change an answer, never skip ahead to an unanswered
question, and a review step that folds in the optional functional-difficulty
follow-up before submit. Progress is announced and the steps are keyboard
navigable.
- Header carries the instrument title + one standardized explanation line; the
"voluntary self-test, not a diagnosis" disclaimer renders only on the landing
and behind the info tooltip, never while taking the test.
- Landing follows the Vorsorge / medication card layout: neutral instrument
cards with a last-result line + Start, and a history card.
- History gains a lazy Recharts trend chart with muted severity-band reference
areas (never an alarm colour) and a PHQ-9 / GAD-7 toggle, plus a dated list
with band badges. A flagged row keeps a discreet "support shown" marker that
re-surfaces the static crisis-resource card without decrypting item content.
- Add the new mentalHealth.* keys across all six locales; drop the orphaned
stem key the redesign no longer renders. New tests cover the step grammar
(advance / back / reachability), the disclaimer staying hidden during the
test, the item-9 crisis surfacing, and the history rendering.
---
messages/de.json | 29 +-
messages/en.json | 29 +-
messages/es.json | 29 +-
messages/fr.json | 29 +-
messages/it.json | 29 +-
messages/pl.json | 29 +-
.../__tests__/check-in-nav.test.ts | 98 +++++
.../__tests__/mental-wellbeing.test.tsx | 190 +++++++++
.../assessment-history-chart.tsx | 157 +++++++
.../mental-health/assessment-history.tsx | 191 +++++++++
.../mental-health/assessment-result.tsx | 129 ++++++
src/components/mental-health/check-in-nav.ts | 71 ++++
.../mental-health/check-in-wizard.tsx | 281 +++++++++++++
src/components/mental-health/crisis-card.tsx | 61 +++
.../mental-health/instrument-card.tsx | 71 ++++
.../mental-health/mental-wellbeing.tsx | 392 ++++--------------
src/components/mental-health/types.ts | 37 ++
17 files changed, 1532 insertions(+), 320 deletions(-)
create mode 100644 src/components/mental-health/__tests__/check-in-nav.test.ts
create mode 100644 src/components/mental-health/__tests__/mental-wellbeing.test.tsx
create mode 100644 src/components/mental-health/assessment-history-chart.tsx
create mode 100644 src/components/mental-health/assessment-history.tsx
create mode 100644 src/components/mental-health/assessment-result.tsx
create mode 100644 src/components/mental-health/check-in-nav.ts
create mode 100644 src/components/mental-health/check-in-wizard.tsx
create mode 100644 src/components/mental-health/crisis-card.tsx
create mode 100644 src/components/mental-health/instrument-card.tsx
create mode 100644 src/components/mental-health/types.ts
diff --git a/messages/de.json b/messages/de.json
index c97fb115e..189b80a1b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Check-in auswählen",
"start": "Starten",
"back": "Zurück",
- "stem": "Wie oft fühltest du dich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?",
"submit": "Ergebnis ansehen",
"submitting": "Wird gespeichert…",
"saved": "Gespeichert",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "Dein Verlauf",
- "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast."
+ "empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
+ "chartTitle": "Punkteverlauf",
+ "flaggedBadge": "Hilfe angezeigt",
+ "totalLabel": "Punkte",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Du musst das nicht allein durchstehen",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Depressions-Screening mit 9 Fragen. Beziehe dich auf die letzten zwei Wochen.",
+ "gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Frage {current} von {total}",
+ "next": "Weiter",
+ "lastResult": "Letztes Ergebnis",
+ "noResultYet": "Noch kein Check-in",
+ "backToList": "Übersicht",
+ "jumpFirst": "Zur ersten Frage springen",
+ "jumpLast": "Zur letzten Frage springen",
+ "review": {
+ "title": "Überblick",
+ "recap": "Du hast {answered} von {total} Fragen beantwortet."
+ },
+ "landing": {
+ "disclaimer": "Dies sind freiwillige Selbsttests, keine Diagnose. Sie unterstützen das Gespräch mit einer Fachperson, ersetzen es aber nicht."
}
},
"records": {
diff --git a/messages/en.json b/messages/en.json
index 3f2844f28..2aef6e48a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Choose a check-in",
"start": "Start",
"back": "Back",
- "stem": "Over the last 2 weeks, how often have you been bothered by the following problems?",
"submit": "See my result",
"submitting": "Saving…",
"saved": "Saved",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "Your history",
- "empty": "No check-ins yet. Your trend will appear here once you complete one."
+ "empty": "No check-ins yet. Your trend will appear here once you complete one.",
+ "chartTitle": "Score trend",
+ "flaggedBadge": "Support shown",
+ "totalLabel": "Score",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "You don't have to face this alone",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "9-question depression screen. Answer for the last two weeks.",
+ "gad7": "7-question anxiety screen. Answer for the last two weeks."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Question {current} of {total}",
+ "next": "Next",
+ "lastResult": "Last result",
+ "noResultYet": "No check-in yet",
+ "backToList": "Overview",
+ "jumpFirst": "Jump to first question",
+ "jumpLast": "Jump to last question",
+ "review": {
+ "title": "Review",
+ "recap": "You answered {answered} of {total} questions."
+ },
+ "landing": {
+ "disclaimer": "These are voluntary self-tests, not a diagnosis. They support a conversation with a professional; they don't replace one."
}
},
"records": {
diff --git a/messages/es.json b/messages/es.json
index 98aee4b3d..c29d73b90 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Elige una revisión",
"start": "Empezar",
"back": "Atrás",
- "stem": "Durante las últimas 2 semanas, ¿con qué frecuencia te han molestado los siguientes problemas?",
"submit": "Ver mi resultado",
"submitting": "Guardando…",
"saved": "Guardado",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "Tu historial",
- "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una."
+ "empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
+ "chartTitle": "Evolución de la puntuación",
+ "flaggedBadge": "Apoyo mostrado",
+ "totalLabel": "Puntuación",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Cribado de depresión de 9 preguntas. Responde pensando en las últimas dos semanas.",
+ "gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Pregunta {current} de {total}",
+ "next": "Siguiente",
+ "lastResult": "Último resultado",
+ "noResultYet": "Aún sin check-in",
+ "backToList": "Vista general",
+ "jumpFirst": "Ir a la primera pregunta",
+ "jumpLast": "Ir a la última pregunta",
+ "review": {
+ "title": "Resumen",
+ "recap": "Has respondido {answered} de {total} preguntas."
+ },
+ "landing": {
+ "disclaimer": "Son autoevaluaciones voluntarias, no un diagnóstico. Sirven de apoyo para hablar con un profesional, no lo sustituyen."
}
},
"records": {
diff --git a/messages/fr.json b/messages/fr.json
index 80c0be5fe..70725ed59 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Choisis un bilan",
"start": "Commencer",
"back": "Retour",
- "stem": "Au cours des 2 dernières semaines, à quelle fréquence as-tu été gêné(e) par les problèmes suivants ?",
"submit": "Voir mon résultat",
"submitting": "Enregistrement…",
"saved": "Enregistré",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "Ton historique",
- "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un."
+ "empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
+ "chartTitle": "Évolution du score",
+ "flaggedBadge": "Aide affichée",
+ "totalLabel": "Score",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Dépistage de la dépression en 9 questions. Répondez en pensant aux deux dernières semaines.",
+ "gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Question {current} sur {total}",
+ "next": "Suivant",
+ "lastResult": "Dernier résultat",
+ "noResultYet": "Pas encore de check-in",
+ "backToList": "Vue d'ensemble",
+ "jumpFirst": "Aller à la première question",
+ "jumpLast": "Aller à la dernière question",
+ "review": {
+ "title": "Récapitulatif",
+ "recap": "Vous avez répondu à {answered} questions sur {total}."
+ },
+ "landing": {
+ "disclaimer": "Ce sont des auto-évaluations volontaires, pas un diagnostic. Elles aident à préparer un échange avec un professionnel, sans le remplacer."
}
},
"records": {
diff --git a/messages/it.json b/messages/it.json
index 03a3fa8ed..786ce24dc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Scegli un check-in",
"start": "Inizia",
"back": "Indietro",
- "stem": "Nelle ultime 2 settimane, con quale frequenza ti hanno disturbato i seguenti problemi?",
"submit": "Vedi il mio risultato",
"submitting": "Salvataggio…",
"saved": "Salvato",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "La tua cronologia",
- "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno."
+ "empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
+ "chartTitle": "Andamento del punteggio",
+ "flaggedBadge": "Supporto mostrato",
+ "totalLabel": "Punteggio",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Screening della depressione in 9 domande. Rispondi pensando alle ultime due settimane.",
+ "gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Domanda {current} di {total}",
+ "next": "Avanti",
+ "lastResult": "Ultimo risultato",
+ "noResultYet": "Ancora nessun check-in",
+ "backToList": "Panoramica",
+ "jumpFirst": "Vai alla prima domanda",
+ "jumpLast": "Vai all'ultima domanda",
+ "review": {
+ "title": "Riepilogo",
+ "recap": "Hai risposto a {answered} domande su {total}."
+ },
+ "landing": {
+ "disclaimer": "Sono autovalutazioni volontarie, non una diagnosi. Sostengono il confronto con un professionista, non lo sostituiscono."
}
},
"records": {
diff --git a/messages/pl.json b/messages/pl.json
index 4b2ba446c..2c4512556 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -7569,7 +7569,6 @@
"choosePrompt": "Wybierz sprawdzenie",
"start": "Rozpocznij",
"back": "Wstecz",
- "stem": "Jak często w ciągu ostatnich 2 tygodni dokuczały Ci następujące problemy?",
"submit": "Zobacz mój wynik",
"submitting": "Zapisywanie…",
"saved": "Zapisano",
@@ -7619,7 +7618,14 @@
},
"history": {
"title": "Twoja historia",
- "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego."
+ "empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
+ "chartTitle": "Przebieg wyniku",
+ "flaggedBadge": "Pokazano wsparcie",
+ "totalLabel": "Wynik",
+ "tab": {
+ "phq9": "PHQ-9",
+ "gad7": "GAD-7"
+ }
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -7671,6 +7677,25 @@
"crisisTextLine": {
"name": "Crisis Text Line"
}
+ },
+ "instrumentDescription": {
+ "phq9": "Przesiew w kierunku depresji, 9 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach.",
+ "gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach."
+ },
+ "progress": "{current} / {total}",
+ "questionAria": "Pytanie {current} z {total}",
+ "next": "Dalej",
+ "lastResult": "Ostatni wynik",
+ "noResultYet": "Brak check-inów",
+ "backToList": "Przegląd",
+ "jumpFirst": "Przejdź do pierwszego pytania",
+ "jumpLast": "Przejdź do ostatniego pytania",
+ "review": {
+ "title": "Podsumowanie",
+ "recap": "Odpowiedziano na {answered} z {total} pytań."
+ },
+ "landing": {
+ "disclaimer": "To dobrowolne testy przesiewowe, a nie diagnoza. Wspierają rozmowę ze specjalistą, ale jej nie zastępują."
}
},
"records": {
diff --git a/src/components/mental-health/__tests__/check-in-nav.test.ts b/src/components/mental-health/__tests__/check-in-nav.test.ts
new file mode 100644
index 000000000..9e0d4cdd1
--- /dev/null
+++ b/src/components/mental-health/__tests__/check-in-nav.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ UNANSWERED,
+ answeredCount,
+ buildStepList,
+ isComplete,
+ isReviewStep,
+ nextStep,
+ prevStep,
+ reachableUntilIndex,
+ reviewStep,
+} from "../check-in-nav";
+
+/**
+ * The one-question-at-a-time check-in is LINEAR: the step list is
+ * `[1..itemCount, review]`, backward navigation is always allowed, and the
+ * forward ceiling never lets a user skip ahead onto an unanswered question.
+ * These are the contracts the wizard's advance / back / reachability behaviour
+ * rests on (the SSR-only test convention can't drive the clicks, so the pure
+ * grammar is pinned here — the medication wizard's `wizard-payload` precedent).
+ */
+
+const PHQ9 = 9;
+const GAD7 = 7;
+
+function blank(count: number): number[] {
+ return Array(count).fill(UNANSWERED);
+}
+
+describe("check-in step grammar", () => {
+ it("builds questions + a single review dot (itemCount + 1)", () => {
+ expect(buildStepList(PHQ9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+ expect(buildStepList(GAD7)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(reviewStep(PHQ9)).toBe(10);
+ expect(reviewStep(GAD7)).toBe(8);
+ });
+
+ it("advances forward on a question and into review from the last question", () => {
+ expect(nextStep(1, PHQ9)).toBe(2);
+ expect(nextStep(8, PHQ9)).toBe(9);
+ // last question (9) → review (10)
+ expect(nextStep(9, PHQ9)).toBe(10);
+ // review stays put (submit, not advance)
+ expect(nextStep(10, PHQ9)).toBe(10);
+ });
+
+ it("steps back, clamped at the first question", () => {
+ expect(prevStep(10)).toBe(9);
+ expect(prevStep(2)).toBe(1);
+ expect(prevStep(1)).toBe(1);
+ });
+
+ it("identifies the review step", () => {
+ expect(isReviewStep(9, PHQ9)).toBe(false);
+ expect(isReviewStep(10, PHQ9)).toBe(true);
+ expect(isReviewStep(7, GAD7)).toBe(false);
+ expect(isReviewStep(8, GAD7)).toBe(true);
+ });
+});
+
+describe("reachability ceiling (never skip ahead to an unanswered question)", () => {
+ it("with nothing answered, only the first question is reachable", () => {
+ expect(reachableUntilIndex(blank(PHQ9))).toBe(0);
+ });
+
+ it("the frontier follows the first unanswered item", () => {
+ const items = blank(PHQ9);
+ items[0] = 2; // Q1 answered → can reach Q2 (index 1)
+ expect(reachableUntilIndex(items)).toBe(1);
+ items[1] = 0;
+ items[2] = 3; // Q3 answered too → frontier at the first gap (index 3)
+ expect(reachableUntilIndex(items)).toBe(3);
+ });
+
+ it("once every item is answered the review slot (last index) is reachable", () => {
+ const items = blank(GAD7).map(() => 1);
+ // step list length is itemCount + 1; review index == itemCount
+ expect(reachableUntilIndex(items)).toBe(GAD7);
+ expect(buildStepList(GAD7).length - 1).toBe(GAD7);
+ });
+});
+
+describe("completeness + recap", () => {
+ it("isComplete requires every item answered", () => {
+ expect(isComplete(blank(PHQ9))).toBe(false);
+ const partial = blank(PHQ9).map((_, i) => (i < 5 ? 1 : UNANSWERED));
+ expect(isComplete(partial)).toBe(false);
+ expect(isComplete(blank(PHQ9).map(() => 0))).toBe(true);
+ });
+
+ it("answeredCount counts only real (0–3) answers", () => {
+ const items = blank(PHQ9);
+ items[0] = 0;
+ items[1] = 3;
+ expect(answeredCount(items)).toBe(2);
+ });
+});
diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
new file mode 100644
index 000000000..64069472e
--- /dev/null
+++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import en from "../../../../messages/en.json";
+
+/**
+ * Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention
+ * (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`)
+ * means clicks can't be driven here — the wizard's advance/back grammar is
+ * pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
+ * contracts: the disclaimer renders ONLY on the landing (never while testing),
+ * a positive item-9 surfaces the crisis card, and the history paints its chart
+ * shell + dated list with severity + "support shown" badges.
+ */
+
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiGet: () => new Promise(() => {}),
+ apiPost: () => new Promise(() => {}),
+}));
+
+import { MentalWellbeing } from "../mental-wellbeing";
+import { CheckInWizard } from "../check-in-wizard";
+import { AssessmentResult } from "../assessment-result";
+import { AssessmentHistory } from "../assessment-history";
+import type { AssessmentRow, CreateResponse } from "../types";
+
+const mh = en.mentalHealth;
+
+function withProviders(node: React.ReactNode): string {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+describe("disclaimer placement (§2 — never while testing)", () => {
+ // `renderToStaticMarkup` HTML-escapes apostrophes (don't → don't), so
+ // assert on an apostrophe-free, distinctive slice of the disclaimer copy.
+ const DISCLAIMER_FRAGMENT = "voluntary self-tests, not a diagnosis";
+
+ it("renders the disclaimer on the landing", () => {
+ const html = withProviders();
+ expect(html).toContain('data-slot="mental-health-disclaimer"');
+ expect(html).toContain(DISCLAIMER_FRAGMENT);
+ });
+
+ it("does NOT render the disclaimer inside the check-in wizard", () => {
+ const html = withProviders(
+ {}}
+ onBack={() => {}}
+ isPending={false}
+ isError={false}
+ />,
+ );
+ expect(html).not.toContain('data-slot="mental-health-disclaimer"');
+ expect(html).not.toContain(DISCLAIMER_FRAGMENT);
+ // …but the standardized instrument explanation IS shown.
+ expect(html).toContain(mh.instrumentDescription.phq9);
+ });
+});
+
+describe("check-in wizard scaffolding", () => {
+ const html = withProviders(
+ {}}
+ onBack={() => {}}
+ isPending={false}
+ isError={false}
+ />,
+ );
+
+ it("reuses the medication wizard stepper", () => {
+ expect(html).toContain('data-slot="wizard-stepper"');
+ });
+
+ it("opens on the first question with the Back/Next footer", () => {
+ expect(html).toContain(mh.items.phq9["1"]);
+ expect(html).toContain('data-slot="check-in-back"');
+ expect(html).toContain('data-slot="check-in-next"');
+ // Back is disabled on the first step.
+ expect(html).toMatch(
+ /data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/,
+ );
+ });
+});
+
+describe("item-9 crisis surfacing on the result", () => {
+ function result(crisis: CreateResponse["crisis"]): CreateResponse {
+ return {
+ assessment: {
+ id: "a1",
+ instrument: "PHQ9",
+ locale: "en",
+ totalScore: 18,
+ severityBand: "modSevere",
+ item9Flagged: crisis !== null,
+ crisisShownAt: crisis !== null ? "2026-06-20T00:00:00.000Z" : null,
+ takenAt: "2026-06-20T00:00:00.000Z",
+ },
+ actionThreshold: 10,
+ crisis,
+ };
+ }
+
+ it("renders the crisis card when item 9 is flagged", () => {
+ const html = withProviders(
+ {}}
+ onBack={() => {}}
+ />,
+ );
+ expect(html).toContain('data-slot="mental-health-crisis-card"');
+ // crisis.title carries an apostrophe (escaped by SSR) — assert a slice.
+ expect(html).toContain("have to face this alone");
+ });
+
+ it("omits the crisis card when item 9 is not flagged", () => {
+ const html = withProviders(
+ {}}
+ onBack={() => {}}
+ />,
+ );
+ expect(html).not.toContain('data-slot="mental-health-crisis-card"');
+ // The result still paints.
+ expect(html).toContain(mh.result.title);
+ });
+});
+
+describe("history rendering", () => {
+ const rows: AssessmentRow[] = [
+ {
+ id: "h1",
+ instrument: "PHQ9",
+ locale: "en",
+ totalScore: 16,
+ severityBand: "modSevere",
+ item9Flagged: true,
+ crisisShownAt: "2026-06-20T00:00:00.000Z",
+ takenAt: "2026-06-20T00:00:00.000Z",
+ },
+ {
+ id: "h2",
+ instrument: "PHQ9",
+ locale: "en",
+ totalScore: 4,
+ severityBand: "minimal",
+ item9Flagged: false,
+ crisisShownAt: null,
+ takenAt: "2026-05-20T00:00:00.000Z",
+ },
+ ];
+
+ const html = withProviders();
+
+ it("paints the lazy chart shell + the dated list", () => {
+ expect(html).toContain('data-slot="skeleton"');
+ expect(html).toContain('data-slot="history-list"');
+ });
+
+ it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => {
+ expect(html).toContain('role="tablist"');
+ expect(html).toContain(mh.band.PHQ9.modSevere);
+ });
+
+ it("marks a flagged row with the discreet support marker", () => {
+ expect(html).toContain('data-slot="history-flagged-marker"');
+ expect(html).toContain(mh.history.flaggedBadge);
+ });
+
+ it("renders the empty state with no rows", () => {
+ const empty = withProviders();
+ expect(empty).toContain(mh.history.empty);
+ expect(empty).not.toContain('data-slot="history-list"');
+ });
+});
diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx
new file mode 100644
index 000000000..d986365b3
--- /dev/null
+++ b/src/components/mental-health/assessment-history-chart.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+/**
+ * v1.25.3 — per-instrument screener trend chart, in the dashboard's visual
+ * language (mirrors `labs/lab-biomarker-chart.tsx`). A single line of the total
+ * score over `takenAt`, with the instrument's severity bands painted as stacked
+ * MUTED `ReferenceArea`s (increasing opacity toward the top, all in the
+ * primary/muted family) so the reader sees "where this score sits" WITHOUT a
+ * danger palette — never an alarm colour (the no-alarming-colour rule the labs
+ * chart documents).
+ *
+ * One instrument at a time: PHQ-9 (0–27) and GAD-7 (0–21) have different ranges
+ * and bands, so the parent toggles which series + band set this paints.
+ */
+import { useMemo } from "react";
+import {
+ CartesianGrid,
+ ComposedChart,
+ Line,
+ ReferenceArea,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
+import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
+import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
+import { formatDateShort } from "@/lib/format";
+import { useTranslations } from "@/lib/i18n/context";
+import { INSTRUMENTS } from "@/lib/mental-health/instruments";
+
+import type { AssessmentRow, InstrumentId } from "./types";
+
+interface ChartPoint {
+ timestamp: number;
+ value: number;
+}
+
+export function AssessmentHistoryChart({
+ instrument,
+ rows,
+}: {
+ instrument: InstrumentId;
+ /** Rows for THIS instrument, any order (sorted internally, ascending time). */
+ rows: AssessmentRow[];
+}) {
+ const { t } = useTranslations();
+ const def = INSTRUMENTS[instrument];
+
+ const points = useMemo(() => {
+ return [...rows]
+ .sort(
+ (a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
+ )
+ .map((r) => ({
+ timestamp: new Date(r.takenAt).getTime(),
+ value: r.totalScore,
+ }));
+ }, [rows]);
+
+ const animate = !prefersReducedMotion();
+ const primary = "var(--primary)";
+
+ if (points.length === 0) {
+ return (
+
+ {t("mentalHealth.history.empty")}
+
+ );
+ }
+
+ // Severity bands as stacked muted areas — opacity climbs with severity so the
+ // higher bands read "heavier" without ever painting red. The whole y-range is
+ // the instrument's 0..maxScore so the bands tile the plot.
+ const bandCount = def.bands.length;
+
+ return (
+
+
+
+ {def.bands.map((band, i) => (
+
+ ))}
+ formatDateShort(new Date(ts))}
+ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
+ stroke="var(--border)"
+ minTickGap={32}
+ />
+
+ {
+ const active = props.active ?? false;
+ const payload = props.payload as
+ ReadonlyArray<{ payload?: ChartPoint }> | undefined;
+ const point = payload?.[0]?.payload;
+ if (!active || !point) {
+ return ;
+ }
+ const rowsOut: RichTooltipRow[] = [
+ {
+ name: t("mentalHealth.history.totalLabel"),
+ value: String(point.value),
+ color: primary,
+ },
+ ];
+ return (
+
+ );
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx
new file mode 100644
index 000000000..ca3a8458e
--- /dev/null
+++ b/src/components/mental-health/assessment-history.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+/**
+ * v1.25.3 — the history card: a per-instrument trend chart over a band-shaded
+ * plot, then the dated list with severity badges. Mirrors the labs detail spine
+ * (chart → list) and lazy-loads Recharts through `next/dynamic` so it stays off
+ * the surface's first-load JS, with a `ChartSkeleton` shell.
+ *
+ * PHQ-9 / GAD-7 toggle: the two instruments have different score ranges + bands,
+ * so the chart paints one at a time.
+ *
+ * CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown" marker;
+ * tapping it re-surfaces the STATIC crisis-resource card derived from the row's
+ * stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the
+ * item-9 answer, and surfacing it triggers no third-party alert. No new data
+ * leaves the safety boundary.
+ */
+import { useMemo, useState } from "react";
+import dynamic from "next/dynamic";
+import type { ComponentProps } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { EmptyState } from "@/components/ui/empty-state";
+import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
+import { ChartSkeleton } from "@/components/charts/chart-skeleton";
+import { importWithRetry } from "@/lib/retry-import";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
+import { Activity } from "lucide-react";
+
+import { CrisisCard } from "./crisis-card";
+import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
+
+// Defer the recharts trend chart so recharts stays off the surface's first-load
+// JS (the labs detail precedent). The `` shell holds the layout.
+const AssessmentHistoryChartLazy = dynamic(
+ () =>
+ importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
+ default: mod.AssessmentHistoryChart,
+ })),
+ { ssr: false, loading: () => },
+);
+function AssessmentHistoryChart(
+ props: ComponentProps,
+) {
+ return (
+
+
+
+ );
+}
+
+function lower(id: InstrumentId): "phq9" | "gad7" {
+ return id === "PHQ9" ? "phq9" : "gad7";
+}
+
+/** Re-derive the STATIC crisis set from a row's locale — no decryption. */
+function crisisFromRow(row: AssessmentRow): CrisisSet {
+ const set = crisisResourcesForLocale(row.locale);
+ return {
+ emergencyNumber: set.emergencyNumber,
+ resources: set.resources.map((r) => ({
+ id: r.id,
+ contacts: [...r.contacts],
+ })),
+ };
+}
+
+export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
+ const { t } = useTranslations();
+ const { date: formatDate } = useFormatters();
+ const [tab, setTab] = useState("PHQ9");
+ // Which flagged row's crisis card is currently expanded (re-surfaced).
+ const [openCrisisRowId, setOpenCrisisRowId] = useState(null);
+
+ const forTab = useMemo(
+ () =>
+ rows
+ .filter((r) => r.instrument === tab)
+ .sort(
+ (a, b) =>
+ new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
+ ),
+ [rows, tab],
+ );
+
+ const isEmpty = 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. */}
+
+
+
+ {t("mentalHealth.result.takeAnother")}
+
+
+ );
+}
diff --git a/src/components/mental-health/check-in-nav.ts b/src/components/mental-health/check-in-nav.ts
new file mode 100644
index 000000000..125d04a6b
--- /dev/null
+++ b/src/components/mental-health/check-in-nav.ts
@@ -0,0 +1,71 @@
+/**
+ * v1.25.3 — pure step grammar for the one-question-at-a-time check-in.
+ *
+ * The PHQ-9 / GAD-7 screeners are LINEAR (no mode/cadence branching like the
+ * medication wizard), so the step list is a fixed `[1..itemCount, review]` and
+ * the reachability ceiling is just "the question you are currently working on"
+ * — you can always step backward to change an answer, never skip ahead onto an
+ * unanswered question. Keeping the navigation here (mirroring the medication
+ * wizard's `wizard-payload.ts`) makes the advance/back/reachability contract
+ * unit-testable without driving a DOM.
+ *
+ * Step numbers are 1-based and run `1..itemCount` for the questions; the review
+ * step is `itemCount + 1`. The optional functional-difficulty control folds
+ * into the review step (it is unscored), so the dot count stays at
+ * `itemCount + 1` rather than gaining its own dot.
+ *
+ * An unanswered item carries the sentinel `-1`.
+ */
+
+export const UNANSWERED = -1;
+
+/** The 1-based review step number for an instrument of `itemCount` items. */
+export function reviewStep(itemCount: number): number {
+ return itemCount + 1;
+}
+
+/** Ordered raw step numbers fed straight to `WizardStepper`: questions + review. */
+export function buildStepList(itemCount: number): number[] {
+ return Array.from({ length: itemCount + 1 }, (_, i) => i + 1);
+}
+
+/** Index (into the step list) of the highest forward-reachable slot. */
+export function reachableUntilIndex(items: readonly number[]): number {
+ const firstUnanswered = items.findIndex((v) => v < 0);
+ // All answered → the review slot (last index) is reachable.
+ if (firstUnanswered === -1) return items.length;
+ // Otherwise the frontier is the question currently being answered; its index
+ // in the step list equals its 0-based item index.
+ return firstUnanswered;
+}
+
+/** Every item answered (0–3) — gates the review step + submit. */
+export function isComplete(items: readonly number[]): boolean {
+ return items.length > 0 && items.every((v) => v >= 0);
+}
+
+/** How many items carry a real answer (for the review recap). */
+export function answeredCount(items: readonly number[]): number {
+ return items.filter((v) => v >= 0).length;
+}
+
+/**
+ * The next step after `step`. A question advances to the next question, the
+ * last question advances to the review step, and the review step stays put
+ * (submit, not advance).
+ */
+export function nextStep(step: number, itemCount: number): number {
+ const review = reviewStep(itemCount);
+ if (step >= review) return review;
+ return step + 1;
+}
+
+/** The previous step, clamped at the first question. */
+export function prevStep(step: number): number {
+ return step > 1 ? step - 1 : 1;
+}
+
+/** Whether `step` is the review step for this instrument. */
+export function isReviewStep(step: number, itemCount: number): boolean {
+ return step >= reviewStep(itemCount);
+}
diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx
new file mode 100644
index 000000000..8d337b859
--- /dev/null
+++ b/src/components/mental-health/check-in-wizard.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+/**
+ * v1.25.3 — one-question-at-a-time check-in.
+ *
+ * Replaces the all-questions-at-once `` wall with a linear stepper that
+ * reuses the medication wizard's presentational `WizardStepper` (the dot row +
+ * "Question 3 of 9" caption) and its Back/Next footer grammar — rendered INLINE
+ * on the page (not a modal), because here the page *is* the surface. The step
+ * navigation lives in `check-in-nav.ts` (pure, linear: backward always allowed,
+ * never skip ahead to an unanswered question).
+ *
+ * Header: instrument title + ONE standardized explanation line, NO disclaimer
+ * (§2 — the "voluntary self-test, not a diagnosis" disclaimer renders only on
+ * the landing + behind the InfoHint, never while taking the test). The optional
+ * functional-difficulty control folds into the review step.
+ */
+import { useEffect, useRef, useState } from "react";
+import { ArrowLeft } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { WizardStepper } from "@/components/medications/wizard/wizard-stepper";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { INSTRUMENTS } from "@/lib/mental-health/instruments";
+
+import {
+ UNANSWERED,
+ answeredCount,
+ buildStepList,
+ isComplete,
+ isReviewStep,
+ nextStep,
+ prevStep,
+ reachableUntilIndex,
+} from "./check-in-nav";
+import type { InstrumentId } from "./types";
+
+const SCALE = [0, 1, 2, 3] as const;
+const FUNCTIONAL = [0, 1, 2, 3] as const;
+
+function lower(id: InstrumentId): "phq9" | "gad7" {
+ return id === "PHQ9" ? "phq9" : "gad7";
+}
+
+export function CheckInWizard({
+ instrument,
+ onSubmit,
+ onBack,
+ isPending,
+ isError,
+}: {
+ instrument: InstrumentId;
+ /** Submit the completed check-in. `functional` is null when unanswered. */
+ onSubmit: (items: number[], functional: number | null) => void;
+ /** Return to the landing (abandons the in-progress check-in). */
+ onBack: () => void;
+ isPending: boolean;
+ isError: boolean;
+}) {
+ const { t } = useTranslations();
+ const key = lower(instrument);
+ const itemCount = INSTRUMENTS[instrument].itemCount;
+
+ const [step, setStep] = useState(1);
+ const [items, setItems] = useState(() =>
+ Array(itemCount).fill(UNANSWERED),
+ );
+ const [functional, setFunctional] = useState(null);
+
+ const stepList = buildStepList(itemCount);
+ const onReview = isReviewStep(step, itemCount);
+ const questionIndex = step - 1; // 0-based item index for a question step
+ const complete = isComplete(items);
+ const reachableUntil = reachableUntilIndex(items);
+
+ // a11y: announce each step by moving focus to its heading (the question text
+ // / the review title). Mirrors the medication dialog's per-step title focus
+ // so a keyboard / screen-reader user hears the new question after an
+ // auto-advance, not silence.
+ const headingRef = useRef(null);
+ useEffect(() => {
+ headingRef.current?.focus({ preventScroll: false });
+ }, [step]);
+
+ function selectAnswer(value: number) {
+ setItems((prev) => {
+ const next = [...prev];
+ next[questionIndex] = value;
+ return next;
+ });
+ // Forward-on-select: advance to the next question, or onto the review step
+ // from the last question. The footer Next is the redundant keyboard path.
+ setStep((s) => nextStep(s, itemCount));
+ }
+
+ const srLabel = onReview
+ ? t("mentalHealth.review.title")
+ : t("mentalHealth.questionAria", { current: step, total: itemCount });
+
+ return (
+
+
+
+ {t("mentalHealth.backToList")}
+
+
+ {/* In-test header — instrument title + ONE standardized explanation line.
+ No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
+
+
+
+
+ );
+}
diff --git a/src/components/mental-health/crisis-card.tsx b/src/components/mental-health/crisis-card.tsx
new file mode 100644
index 000000000..d10aaee09
--- /dev/null
+++ b/src/components/mental-health/crisis-card.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+/**
+ * v1.25.3 — the calm, non-alarmist crisis-resource card.
+ *
+ * Shared between the fresh-result view (the POST response carries the crisis
+ * set on any non-zero PHQ-9 item 9) and the history list, where tapping a
+ * flagged row re-surfaces this card from the STATIC, locale-derived resource
+ * config (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the
+ * item-9 answer, and showing it never triggers any third-party alert.
+ *
+ * Tone follows the v1.25.1 decision: a calm `border-destructive/40` border,
+ * never an amber/red fill; the `role="alert"` + `aria-live` carry the urgency.
+ */
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+
+import type { CrisisSet } from "./types";
+
+export function CrisisCard({ crisis }: { crisis: CrisisSet }) {
+ const { t } = useTranslations();
+ return (
+
+
+
+ {t("mentalHealth.crisis.title")}
+
+
+
+
);
}
diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts
new file mode 100644
index 000000000..7b66ca1ff
--- /dev/null
+++ b/src/components/mental-health/types.ts
@@ -0,0 +1,37 @@
+/**
+ * v1.25.3 — shared types for the opt-in mental-health screener surface.
+ *
+ * The 387-line monolith split into a folder of focused components; these are
+ * the seams they plug into. The wire shapes mirror the assessment route's
+ * `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`)
+ * — totals + bands + flags only, NEVER item content (the item answers stay
+ * encrypted at rest and never ride this surface).
+ */
+import type { InstrumentId } from "@/lib/mental-health/instruments";
+
+export type { InstrumentId };
+
+export type Phase = "choose" | "form" | "result";
+
+export interface AssessmentRow {
+ id: string;
+ instrument: InstrumentId;
+ /** Locale of the validated wording presented — re-derives crisis resources. */
+ locale: string;
+ totalScore: number;
+ severityBand: string;
+ item9Flagged: boolean;
+ crisisShownAt: string | null;
+ takenAt: string;
+}
+
+export interface CrisisSet {
+ emergencyNumber: string;
+ resources: { id: string; contacts: string[] }[];
+}
+
+export interface CreateResponse {
+ assessment: AssessmentRow;
+ actionThreshold: number;
+ crisis: CrisisSet | null;
+}
From 537b31f4ac38de9e3825e2332e73880b96e7defd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 15:47:39 +0200
Subject: [PATCH 6/8] feat(settings): fold delivery channels into
Notifications, rename the Dashboard hub to Appearance
Two surgical, additive, reversible changes to the flat settings navigation.
Notifications becomes one surface with two sequenced, labelled groups:
Reminders ("what you receive") first, then Delivery channels ("where it
lands") = the existing channels panel, unchanged. The standalone Channels
sidebar entry and slug are dropped; /settings/channels 301-redirects to
/settings/notifications#channels (mirroring the reminders redirect). The
per-channel anchors keep resolving.
The mis-titled "Dashboard" area (slug kept as layout, so no route churn) is
renamed Appearance / Darstellung and widened from two links to six: dashboard
arrangement, insights overview and pills, plus deep-links to the view/sort/
order cards of medications, labs, the illness journal, and checkups. Each
module keeps its view cards on its own page; the hub only indexes them. The
checkups view card gains a vorsorge-view anchor so the deep-link lands.
i18n updated across all six locales; settings and notifications tests track
the new structure.
---
messages/de.json | 39 ++++++---
messages/en.json | 39 ++++++---
messages/es.json | 39 ++++++---
messages/fr.json | 39 ++++++---
messages/it.json | 39 ++++++---
messages/pl.json | 39 ++++++---
next.config.ts | 11 +++
src/app/settings/[section]/page.tsx | 6 +-
.../vorsorge-settings.tsx | 5 +-
.../__tests__/layout-section.test.tsx | 37 ++++++---
.../settings/__tests__/sections.test.tsx | 12 ++-
.../__tests__/settings-shell.test.tsx | 34 ++++----
src/components/settings/channels-section.tsx | 20 -----
src/components/settings/layout-section.tsx | 60 +++++++++++---
.../settings/notifications-section.tsx | 80 ++++++++++++++-----
.../settings/section-placeholder.tsx | 2 -
src/components/settings/section-slugs.ts | 7 +-
src/components/settings/settings-shell.tsx | 27 +++----
18 files changed, 347 insertions(+), 188 deletions(-)
delete mode 100644 src/components/settings/channels-section.tsx
diff --git a/messages/de.json b/messages/de.json
index c97fb115e..012ec7cdf 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Benachrichtigungen",
- "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
- "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
- "channelsHintLink": "Integrationen → Kanäle",
- "subtitle": "Wähle, welche Erinnerungen du erhältst."
+ "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
+ "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
+ "remindersHeading": "Erinnerungen",
+ "remindersDescription": "Was du erhältst.",
+ "channelsHeading": "Zustellkanäle",
+ "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
},
"layout": {
- "title": "Dashboard",
- "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
- "backToHub": "Zurück zu Layout & Personalisierung",
+ "title": "Darstellung",
+ "description": "Lege fest, wie Dashboard, Auswertungen und deine Tracking-Module dargestellt und angeordnet sind.",
+ "backToHub": "Zurück zu Darstellung",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -4050,7 +4052,23 @@
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
- "subtitle": "Passe an, wie Dashboard und Insights angeordnet sind."
+ "subtitle": "Lege fest, wie Dashboard, Auswertungen und Tracking-Module dargestellt werden.",
+ "medications": {
+ "title": "Medikamente",
+ "description": "Listenansicht und manuelle Reihenfolge."
+ },
+ "labs": {
+ "title": "Labor",
+ "description": "Ansicht, Sortierung und manuelle Reihenfolge."
+ },
+ "illness": {
+ "title": "Krankheitstagebuch",
+ "description": "Listenansicht und Reihenfolge der Episoden."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Listenansicht und Reihenfolge der Erinnerungen."
+ }
},
"dashboard": {
"title": "Dashboard",
@@ -4308,11 +4326,6 @@
"description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.",
"subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein."
},
- "channels": {
- "title": "Kanäle",
- "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
- "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
- },
"security": {
"title": "Sicherheit",
"subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys."
diff --git a/messages/en.json b/messages/en.json
index 3f2844f28..d9d3f6f76 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Notifications",
- "description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
- "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
- "channelsHintLink": "Integrations → Channels",
- "subtitle": "Choose which reminders you receive."
+ "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
+ "subtitle": "Choose which reminders you receive and where they're delivered.",
+ "remindersHeading": "Reminders",
+ "remindersDescription": "What you receive.",
+ "channelsHeading": "Delivery channels",
+ "channelsDescription": "Where reminders and alerts are delivered."
},
"layout": {
- "title": "Dashboard",
- "description": "Arrange how the dashboard and insights are laid out.",
- "backToHub": "Back to Layout & Personalization",
+ "title": "Appearance",
+ "description": "Choose how the dashboard, insights, and your tracking modules look and are arranged.",
+ "backToHub": "Back to Appearance",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -4050,7 +4052,23 @@
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
- "subtitle": "Personalize how your dashboard and insights are arranged."
+ "subtitle": "Personalize how your dashboard, insights, and tracking modules look.",
+ "medications": {
+ "title": "Medications",
+ "description": "List view and manual order."
+ },
+ "labs": {
+ "title": "Labs",
+ "description": "View, sort, and manual order."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "description": "List view and episode order."
+ },
+ "vorsorge": {
+ "title": "Checkups",
+ "description": "List view and reminder order."
+ }
},
"dashboard": {
"title": "Dashboard",
@@ -4308,11 +4326,6 @@
"description": "Enable the Coach, tone and scope, stored memories.",
"subtitle": "Tune the Coach, its proactive nudges, and its memory."
},
- "channels": {
- "title": "Channels",
- "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
- "subtitle": "Where reminders and alerts are delivered."
- },
"security": {
"title": "Security",
"subtitle": "Two-factor authentication, recovery codes, and passkeys."
diff --git a/messages/es.json b/messages/es.json
index 98aee4b3d..58da7252e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Notificaciones",
- "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
- "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
- "channelsHintLink": "Integraciones → Canales",
- "subtitle": "Elige qué recordatorios recibes."
+ "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
+ "subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
+ "remindersHeading": "Recordatorios",
+ "remindersDescription": "Lo que recibes.",
+ "channelsHeading": "Canales de entrega",
+ "channelsDescription": "Dónde se entregan los recordatorios y avisos."
},
"layout": {
- "title": "Panel",
- "description": "Organiza cómo están dispuestos el panel y los análisis.",
- "backToHub": "Volver a Diseño y personalización",
+ "title": "Apariencia",
+ "description": "Elige cómo se ven y se organizan el panel, los análisis y tus módulos de seguimiento.",
+ "backToHub": "Volver a Apariencia",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -4050,7 +4052,23 @@
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
- "subtitle": "Personaliza cómo se organizan tu panel e insights."
+ "subtitle": "Personaliza cómo se ven tu panel, los análisis y los módulos de seguimiento.",
+ "medications": {
+ "title": "Medicamentos",
+ "description": "Vista de lista y orden manual."
+ },
+ "labs": {
+ "title": "Laboratorio",
+ "description": "Vista, orden y orden manual."
+ },
+ "illness": {
+ "title": "Diario de enfermedades",
+ "description": "Vista de lista y orden de episodios."
+ },
+ "vorsorge": {
+ "title": "Revisiones",
+ "description": "Vista de lista y orden de recordatorios."
+ }
},
"dashboard": {
"title": "Panel",
@@ -4308,11 +4326,6 @@
"description": "Activa el Coach, tono y alcance, recuerdos guardados.",
"subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria."
},
- "channels": {
- "title": "Canales",
- "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
- "subtitle": "Dónde se entregan los recordatorios y avisos."
- },
"security": {
"title": "Seguridad",
"subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso."
diff --git a/messages/fr.json b/messages/fr.json
index 80c0be5fe..54a2ddd4d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Notifications",
- "description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
- "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
- "channelsHintLink": "Intégrations → Canaux",
- "subtitle": "Choisissez les rappels que vous recevez."
+ "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
+ "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
+ "remindersHeading": "Rappels",
+ "remindersDescription": "Ce que vous recevez.",
+ "channelsHeading": "Canaux de diffusion",
+ "channelsDescription": "Où sont envoyés les rappels et alertes."
},
"layout": {
- "title": "Tableau de bord",
- "description": "Organisez la disposition du tableau de bord et des analyses.",
- "backToHub": "Retour à Disposition et personnalisation",
+ "title": "Apparence",
+ "description": "Choisissez l'apparence et l'agencement du tableau de bord, des analyses et de vos modules de suivi.",
+ "backToHub": "Retour à Apparence",
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
@@ -4050,7 +4052,23 @@
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
- "subtitle": "Personnalisez l'agencement de votre tableau de bord et de vos insights."
+ "subtitle": "Personnalisez l'apparence du tableau de bord, des analyses et des modules de suivi.",
+ "medications": {
+ "title": "Médicaments",
+ "description": "Vue en liste et ordre manuel."
+ },
+ "labs": {
+ "title": "Analyses de labo",
+ "description": "Vue, tri et ordre manuel."
+ },
+ "illness": {
+ "title": "Journal des maladies",
+ "description": "Vue en liste et ordre des épisodes."
+ },
+ "vorsorge": {
+ "title": "Examens",
+ "description": "Vue en liste et ordre des rappels."
+ }
},
"dashboard": {
"title": "Tableau de bord",
@@ -4308,11 +4326,6 @@
"description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.",
"subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire."
},
- "channels": {
- "title": "Canaux",
- "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
- "subtitle": "Où sont envoyés les rappels et alertes."
- },
"security": {
"title": "Sécurité",
"subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès."
diff --git a/messages/it.json b/messages/it.json
index 03a3fa8ed..81dbc2a7a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Notifiche",
- "description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
- "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
- "channelsHintLink": "Integrazioni → Canali",
- "subtitle": "Scegli quali promemoria ricevere."
+ "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
+ "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
+ "remindersHeading": "Promemoria",
+ "remindersDescription": "Ciò che ricevi.",
+ "channelsHeading": "Canali di recapito",
+ "channelsDescription": "Dove vengono recapitati promemoria e avvisi."
},
"layout": {
- "title": "Cruscotto",
- "description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
- "backToHub": "Torna a Layout e personalizzazione",
+ "title": "Aspetto",
+ "description": "Scegli l'aspetto e la disposizione di dashboard, approfondimenti e dei tuoi moduli di monitoraggio.",
+ "backToHub": "Torna ad Aspetto",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -4050,7 +4052,23 @@
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
- "subtitle": "Personalizza la disposizione di dashboard e insights."
+ "subtitle": "Personalizza l'aspetto di dashboard, approfondimenti e moduli di monitoraggio.",
+ "medications": {
+ "title": "Farmaci",
+ "description": "Vista elenco e ordine manuale."
+ },
+ "labs": {
+ "title": "Analisi di laboratorio",
+ "description": "Vista, ordinamento e ordine manuale."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "description": "Vista elenco e ordine degli episodi."
+ },
+ "vorsorge": {
+ "title": "Controlli",
+ "description": "Vista elenco e ordine dei promemoria."
+ }
},
"dashboard": {
"title": "Cruscotto",
@@ -4308,11 +4326,6 @@
"description": "Attiva il Coach, tono e ambito, ricordi salvati.",
"subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria."
},
- "channels": {
- "title": "Canali",
- "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
- "subtitle": "Dove vengono recapitati promemoria e avvisi."
- },
"security": {
"title": "Sicurezza",
"subtitle": "Autenticazione a due fattori, codici di recupero e passkey."
diff --git a/messages/pl.json b/messages/pl.json
index 4b2ba446c..664cc2d9e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4033,15 +4033,17 @@
},
"notifications": {
"title": "Powiadomienia",
- "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
- "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
- "channelsHintLink": "Integracje → Kanały",
- "subtitle": "Wybierz, które przypomnienia otrzymujesz."
+ "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
+ "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
+ "remindersHeading": "Przypomnienia",
+ "remindersDescription": "Co otrzymujesz.",
+ "channelsHeading": "Kanały dostarczania",
+ "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
},
"layout": {
- "title": "Pulpit",
- "description": "Ustal, jak rozłożone są pulpit i analizy.",
- "backToHub": "Powrót do Układ i personalizacja",
+ "title": "Wygląd",
+ "description": "Ustal, jak wyglądają i są rozłożone pulpit, analizy i Twoje moduły śledzenia.",
+ "backToHub": "Powrót do Wygląd",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -4050,7 +4052,23 @@
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
- "subtitle": "Dostosuj układ pulpitu i analiz."
+ "subtitle": "Dostosuj wygląd pulpitu, analiz i modułów śledzenia.",
+ "medications": {
+ "title": "Leki",
+ "description": "Widok listy i ręczna kolejność."
+ },
+ "labs": {
+ "title": "Laboratorium",
+ "description": "Widok, sortowanie i ręczna kolejność."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "description": "Widok listy i kolejność epizodów."
+ },
+ "vorsorge": {
+ "title": "Badania kontrolne",
+ "description": "Widok listy i kolejność przypomnień."
+ }
},
"dashboard": {
"title": "Pulpit",
@@ -4308,11 +4326,6 @@
"description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.",
"subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć."
},
- "channels": {
- "title": "Kanały",
- "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
- "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
- },
"security": {
"title": "Bezpieczeństwo",
"subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu."
diff --git a/next.config.ts b/next.config.ts
index 2c6e0c4ed..219619fea 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -134,6 +134,17 @@ const nextConfig: NextConfig = {
destination: "/settings/notifications",
permanent: true,
},
+ // v1.25.3 — the standalone delivery-channels page folded into
+ // Notifications as an in-page group. The channels content now lives
+ // under the `#channels` anchor on the Notifications surface, so the old
+ // URL 301-redirects there (mirroring the `/settings/reminders` precedent
+ // above). The per-channel anchors (`#telegram`, `#ntfy`, …) inside the
+ // panel keep resolving.
+ {
+ source: "/settings/channels",
+ destination: "/settings/notifications#channels",
+ permanent: true,
+ },
// v1.22.0 — the preventive-care surface moved from the German slug
// `/vorsorge` to the English `/checkups` (the feature reads "Checkups"
// in every non-German locale now). 301-redirect so bookmarks, the PWA's
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 3b8d0b82a..9042a54b6 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -26,9 +26,8 @@ import { NotificationsSection } from "@/components/settings/notifications-sectio
import { PrivacySection } from "@/components/settings/privacy-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { VorsorgeSection } from "@/components/settings/vorsorge-section";
-// v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries
-// again (split out of the Integrations sub-tabs).
-import { ChannelsSection } from "@/components/settings/channels-section";
+// v1.18.1 (D4) — `sources` is a standalone left-side entry (split out of the
+// Integrations sub-tabs). v1.25.3 — `channels` folded into Notifications.
import { SharingSection } from "@/components/settings/sharing-section";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
@@ -66,7 +65,6 @@ const SECTION_COMPONENTS: Record<
ai: AiSection,
coach: CoachSection,
integrations: IntegrationsSection,
- channels: ChannelsSection,
sources: SourcesSection,
notifications: NotificationsSection,
layout: LayoutSection,
diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx
index 979394b31..b87a9d9ca 100644
--- a/src/components/measurement-reminders/vorsorge-settings.tsx
+++ b/src/components/measurement-reminders/vorsorge-settings.tsx
@@ -63,7 +63,10 @@ export function VorsorgeSettings() {
// `` blocks with `text-sm` headings.
return (
- {/* Mood check-in reminder — only when the mood module is enabled. */}
- {moodEnabled ? (
-
-
-
+
+ {/* Group 1 — Reminders ("what you receive"). Rendered only when at
+ least one reminder-type card is visible, so a fully-disabled
+ account never shows an empty heading. */}
+ {anyReminder ? (
+
+
+
+ {/* Mood check-in reminder — only when the mood module is enabled. */}
+ {moodEnabled ? (
+
+
+
+ ) : null}
+
+ {/* Medication low-stock runway — only when the medications module
+ is enabled (toggleable, fail-open, since D3). */}
+ {medsEnabled ? (
+
+
+
+ ) : null}
+
) : null}
- {/* Medication low-stock runway — only when the medications module is
- enabled (toggleable, fail-open, since D3). */}
- {medsEnabled ? (
-
-
+ {/* Group 2 — Delivery channels ("where it's delivered"). The existing
+ channels panel, unchanged. `id="channels"` anchors the group for the
+ `/settings/channels` redirect. */}
+
+
))}
diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx
index 24d969551..d02b41f81 100644
--- a/src/components/settings/security-sessions-card.tsx
+++ b/src/components/settings/security-sessions-card.tsx
@@ -15,7 +15,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
-import { useTranslations } from "@/lib/i18n/context";
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
@@ -36,6 +36,7 @@ export function SecuritySessionsCard({
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
+ const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState(null);
// Collapsed by default — the list opens only when the user asks for it, so
@@ -158,7 +159,7 @@ export function SecuritySessionsCard({
{s.lastActiveAt && (
{t("settings.security.lastActive", {
- time: new Date(s.lastActiveAt).toLocaleString(),
+ time: fmt.dateTime(s.lastActiveAt),
})}
)}
diff --git a/src/components/ui/date-time-field.tsx b/src/components/ui/date-time-field.tsx
index 09ab7fc22..d0b86c19a 100644
--- a/src/components/ui/date-time-field.tsx
+++ b/src/components/ui/date-time-field.tsx
@@ -10,6 +10,7 @@ import {
useTimeFormatPreference,
} from "@/lib/i18n/context";
import { formatDate, resolveDateLocale } from "@/lib/date-format";
+import { hourCycleOptions } from "@/lib/format-locale";
/**
* Dependency-free datetime input that DISPLAYS the value in the user's
@@ -221,20 +222,6 @@ function formatLocalDateTime(
return `${datePart} ${timePart}`;
}
-/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */
-function hourCycleOptions(
- timeFormat: ReturnType,
-): Intl.DateTimeFormatOptions {
- switch (timeFormat) {
- case "H12":
- return { hour12: true };
- case "H24":
- return { hourCycle: "h23" };
- default:
- return {};
- }
-}
-
/** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */
function parseLocalDateTime(value: string): {
date: string;
diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts
index e8f727b0c..fef7c9a44 100644
--- a/src/lib/__tests__/format-locale.test.ts
+++ b/src/lib/__tests__/format-locale.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
+ hourCycleOptions,
makeFormatters,
parseLocaleFromAcceptLanguage,
resolveIntlLocale,
@@ -137,3 +138,49 @@ describe("makeFormatters", () => {
expect(tokyo.date(lateUtc)).toMatch(/11/);
});
});
+
+// v1.25.3 — the time-format preference must reach every hour/minute renderer,
+// not only the `makeFormatters().time` path. Some surfaces build their own
+// `Intl.DateTimeFormat` because they render in the browser timezone (chart
+// axes) or combine weekday + time in one label (workout list/detail). They now
+// spread `hourCycleOptions(preference)` into the options. These tests pin both
+// the helper contract and the exact call-site pattern, so a future renderer
+// that drops the spread (re-introducing AM/PM under H24) fails here.
+describe("hourCycleOptions", () => {
+ it("AUTO contributes nothing (locale default applies)", () => {
+ expect(hourCycleOptions("AUTO")).toEqual({});
+ });
+
+ it("H12 forces a 12-hour clock", () => {
+ expect(hourCycleOptions("H12")).toEqual({ hour12: true });
+ });
+
+ it("H24 forces the 24-hour h23 cycle", () => {
+ expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" });
+ });
+
+ // The shared call-site shape used by sleep-hypnogram, workout-list and
+ // workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute.
+ const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
+ const buildLabel = (preference: Parameters[0]) =>
+ new Intl.DateTimeFormat("en-US", {
+ timeZone: "Europe/Berlin",
+ hour: "2-digit",
+ minute: "2-digit",
+ ...hourCycleOptions(preference),
+ }).format(sample);
+
+ it("an en-US hour/minute formatter drops AM/PM under H24", () => {
+ const label = buildLabel("H24");
+ expect(label).not.toMatch(/[AP]M/i);
+ expect(label).toBe("16:30");
+ });
+
+ it("an en-US hour/minute formatter keeps AM/PM under H12", () => {
+ expect(buildLabel("H12")).toMatch(/PM/);
+ });
+
+ it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => {
+ expect(buildLabel("AUTO")).toMatch(/PM/);
+ });
+});
diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts
index cbe3f1ffe..5ddf52d65 100644
--- a/src/lib/format-locale.ts
+++ b/src/lib/format-locale.ts
@@ -61,8 +61,17 @@ export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD";
/**
* Intl options for the requested hour cycle. AUTO contributes nothing so
* `Intl.DateTimeFormat` falls back to the locale default.
+ *
+ * Exported so call sites that build their own `Intl.DateTimeFormat` (chart
+ * axes, list rows that deliberately render in the browser timezone rather
+ * than the profile zone) can still honour the user's H12 / H24 preference by
+ * spreading these options. Pair it with `useTimeFormatPreference()` on the
+ * client. Any new hour/minute renderer must route through this rather than
+ * re-deciding the cycle locally — that is the regression this single source
+ * prevents (an `en`-locale or AUTO user got AM/PM even with H24 selected
+ * wherever a formatter omitted the cycle).
*/
-function hourCycleOptions(
+export function hourCycleOptions(
timeFormat: TimeFormatPreference,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
diff --git a/src/lib/i18n/__tests__/relative-time-updated.test.ts b/src/lib/i18n/__tests__/relative-time-updated.test.ts
index 9603730ec..5198411d6 100644
--- a/src/lib/i18n/__tests__/relative-time-updated.test.ts
+++ b/src/lib/i18n/__tests__/relative-time-updated.test.ts
@@ -5,6 +5,7 @@
import { describe, expect, it } from "vitest";
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
+import { makeFormatters } from "@/lib/format-locale";
const t = (key: string, params?: Record): string => {
if (key === "insights.updatedTodayAt")
@@ -54,4 +55,38 @@ describe("formatUpdatedLabel", () => {
"",
);
});
+
+ // v1.25.3 — the briefing freshness label (and the per-metric / narrative
+ // cards) feed `formatUpdatedLabel` the preference-aware `fmt.time`. With H24
+ // selected the "today" caption must never carry AM/PM, even for an en user
+ // whose locale default is 12-hour.
+ it("renders today's time without AM/PM when fed an H24 formatter", () => {
+ const fmt = makeFormatters("en", "UTC", "H24");
+ const now = new Date();
+ const out = formatUpdatedLabel(
+ now.toISOString(),
+ t,
+ fmt.dateShort,
+ fmt.time,
+ "UTC",
+ );
+ expect(out.startsWith("Updated today, ")).toBe(true);
+ expect(out).not.toMatch(/[AP]M/i);
+ });
+
+ it("renders today's time with AM/PM when fed an H12 formatter", () => {
+ const fmt = makeFormatters("en", "UTC", "H12");
+ // A fixed afternoon instant so the assertion is deterministic regardless
+ // of when the suite runs; bucketed as "today" via the UTC day boundary.
+ const today = new Date();
+ today.setUTCHours(14, 30, 0, 0);
+ const out = formatUpdatedLabel(
+ today.toISOString(),
+ t,
+ fmt.dateShort,
+ fmt.time,
+ "UTC",
+ );
+ expect(out).toMatch(/PM/);
+ });
});
From 00ea61ee9f1c4fa828daa08bdb29a5d044c286ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 29 Jun 2026 15:53:41 +0200
Subject: [PATCH 8/8] chore(release): v1.25.3
---
CHANGELOG.md | 25 +++++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
3 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72cd6e4d4..033305bc0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,31 @@
## [Unreleased]
+## [1.25.3] — 2026-06-29 — Mental wellbeing, labs depth, and fixes
+
+A patch release: a clearer mental-wellbeing check-in, more useful lab pages, two visible bug fixes, and a couple of settings tidy-ups. No schema change.
+
+### Added
+
+- Each biomarker now reads with a short explanation of what it measures and why it matters, in every language — not just a generic line.
+
+### Changed
+
+- **Mental wellbeing** is reworked: the PHQ-9 / GAD-7 check-in is now a calm step-by-step flow (one question at a time, with progress) instead of a single long form; the screen reads as cards with a clear title and a short explanation, the voluntary-self-test note stays on the overview rather than interrupting the test, and past results show as a proper trend with severity context. Crisis-support always surfaces for an at-risk answer.
+- **Labs** biomarker pages: the redundant "adjust target range" control is gone (it already lives in Edit); the controls now read Delete · Edit · Show all values · Add value, with tooltips, and the "show all values" control matches the others.
+- **Settings**: delivery channels now live inside Notifications (one place for "what you receive" and "where it lands"); the former "Dashboard" settings area is renamed **Appearance** and gathers links to the view settings of the dashboard, insights, medications, labs, illness journal, and checkups.
+- The daily briefing now scales its generation budget to your configured AI response timeout, so raising the timeout for a slower model actually takes effect instead of being capped — and when a refresh genuinely fails, the message points at the right lever (raise the timeout / re-check the provider) instead of a generic error.
+
+### Fixed
+
+- **The 24-hour time preference is honored everywhere again** — several screens (sleep timeline, workout times, sign-in activity and sessions) were showing AM/PM even with 24-hour selected. Time now follows your preference across the app.
+- The readiness / recovery insight tile no longer renders broken — the method note was overlapping the score and contributing factors; the card now reads top to bottom with the factors visible, on every screen size.
+- The weekly and monthly review now use the same top-right "ask the Coach" control as the other insights, and the Coach conversations list spans the normal content width instead of a narrow column.
+
+### Removed
+
+- The Documents feature is temporarily switched off pending a deeper rebuild; the existing data and code are untouched.
+
## [1.25.2] — 2026-06-29 — Flat settings navigation
### Changed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1f55b0934..737849280 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.2
+ version: 1.25.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index 78bb0421f..22e4dead2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.25.2",
+ "version": "1.25.3",
"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",