mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #394 from MBombeck/release/v1.25.12
release: v1.25.12 — follow-up fixes
This commit is contained in:
25 files changed
+689
-426
No files matched your search
@@ -2,6 +2,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.25.12] — 2026-06-30 — Follow-up fixes
|
||||
|
||||
### Fixed
|
||||
|
||||
- The left navigation no longer briefly flashes entries for modules you don't have enabled while a page loads.
|
||||
- The daily briefing could time out before finishing on an account with a long history; it now has a longer window to generate. (If it still clips, raise the AI response timeout in Settings → AI and/or add a second AI provider so a slow one falls back.)
|
||||
- Pre-existing and chronic conditions — the ones the Coach watches — now live in Medical history (Anamnese), alongside allergies and family history, instead of the personal-context panel. Where they are stored is unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tapping a mental-wellbeing assessment card (PHQ-9, GAD-7) now opens that instrument's trend, the same way a medication card opens its detail.
|
||||
|
||||
## [1.25.11] — 2026-06-30 — Consistent settings and cards
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.25.11
|
||||
version: 1.25.12
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Überblick",
|
||||
"recap": "Du hast {answered} von {total} Fragen beantwortet."
|
||||
}
|
||||
},
|
||||
"openDetail": "Verlauf anzeigen"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Vorerkrankungen",
|
||||
"cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll — aus deinem persönlichen Kontext.",
|
||||
"cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll.",
|
||||
"conditionsLabel": "Chronische Erkrankungen",
|
||||
"focusLabel": "Worauf der Coach achtet",
|
||||
"empty": "Noch nichts hinterlegt.",
|
||||
"editCta": "Im persönlichen Kontext bearbeiten",
|
||||
"loadError": "Vorerkrankungen konnten nicht geladen werden."
|
||||
"loadError": "Vorerkrankungen konnten nicht geladen werden.",
|
||||
"savedToast": "Erkrankungen gespeichert.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Review",
|
||||
"recap": "You answered {answered} of {total} questions."
|
||||
}
|
||||
},
|
||||
"openDetail": "Show trend"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Pre-existing conditions",
|
||||
"cardDescription": "Chronic conditions and what the Coach watches — from your personal context.",
|
||||
"cardDescription": "Chronic conditions and what the Coach watches.",
|
||||
"conditionsLabel": "Chronic conditions",
|
||||
"focusLabel": "What the Coach watches",
|
||||
"empty": "Nothing noted yet.",
|
||||
"editCta": "Edit in personal context",
|
||||
"loadError": "Couldn't load your conditions."
|
||||
"loadError": "Couldn't load your conditions.",
|
||||
"savedToast": "Conditions saved.",
|
||||
"saveError": "Could not save. Please try again."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Resumen",
|
||||
"recap": "Has respondido {answered} de {total} preguntas."
|
||||
}
|
||||
},
|
||||
"openDetail": "Ver evolución"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Enfermedades preexistentes",
|
||||
"cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar, desde tu contexto personal.",
|
||||
"cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar.",
|
||||
"conditionsLabel": "Enfermedades crónicas",
|
||||
"focusLabel": "Qué vigila el Coach",
|
||||
"empty": "Aún no hay nada anotado.",
|
||||
"editCta": "Editar en el contexto personal",
|
||||
"loadError": "No se pudieron cargar tus enfermedades."
|
||||
"loadError": "No se pudieron cargar tus enfermedades.",
|
||||
"savedToast": "Enfermedades guardadas.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Récapitulatif",
|
||||
"recap": "Vous avez répondu à {answered} questions sur {total}."
|
||||
}
|
||||
},
|
||||
"openDetail": "Voir l’évolution"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Affections préexistantes",
|
||||
"cardDescription": "Affections chroniques et ce que le Coach doit surveiller, d'après votre contexte personnel.",
|
||||
"cardDescription": "Affections chroniques et ce que le Coach doit surveiller.",
|
||||
"conditionsLabel": "Affections chroniques",
|
||||
"focusLabel": "Ce que le Coach surveille",
|
||||
"empty": "Rien de noté pour l'instant.",
|
||||
"editCta": "Modifier dans le contexte personnel",
|
||||
"loadError": "Impossible de charger vos antécédents."
|
||||
"loadError": "Impossible de charger vos antécédents.",
|
||||
"savedToast": "Affections enregistrées.",
|
||||
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Riepilogo",
|
||||
"recap": "Hai risposto a {answered} domande su {total}."
|
||||
}
|
||||
},
|
||||
"openDetail": "Mostra andamento"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Patologie preesistenti",
|
||||
"cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio, dal tuo contesto personale.",
|
||||
"cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio.",
|
||||
"conditionsLabel": "Patologie croniche",
|
||||
"focusLabel": "Cosa tiene d'occhio il Coach",
|
||||
"empty": "Ancora nessuna annotazione.",
|
||||
"editCta": "Modifica nel contesto personale",
|
||||
"loadError": "Impossibile caricare le tue patologie."
|
||||
"loadError": "Impossibile caricare le tue patologie.",
|
||||
"savedToast": "Patologie salvate.",
|
||||
"saveError": "Impossibile salvare. Riprova."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+6
-5
@@ -7716,7 +7716,8 @@
|
||||
"review": {
|
||||
"title": "Podsumowanie",
|
||||
"recap": "Odpowiedziano na {answered} z {total} pytań."
|
||||
}
|
||||
},
|
||||
"openDetail": "Pokaż przebieg"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
|
||||
@@ -7827,12 +7828,12 @@
|
||||
},
|
||||
"conditions": {
|
||||
"cardTitle": "Choroby przewlekłe",
|
||||
"cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę — z Twojego kontekstu osobistego.",
|
||||
"cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę.",
|
||||
"conditionsLabel": "Choroby przewlekłe",
|
||||
"focusLabel": "Na co zwraca uwagę Coach",
|
||||
"empty": "Nic jeszcze nie zapisano.",
|
||||
"editCta": "Edytuj w kontekście osobistym",
|
||||
"loadError": "Nie udało się wczytać Twoich chorób."
|
||||
"loadError": "Nie udało się wczytać Twoich chorób.",
|
||||
"savedToast": "Choroby zapisane.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.25.11",
|
||||
"version": "1.25.12",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.25.11";
|
||||
/* @sw-version-fallback */ "v1.25.12";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -61,13 +61,24 @@ function render() {
|
||||
}
|
||||
|
||||
describe("<BottomNav> iOS-parity layout", () => {
|
||||
it("renders the flanking primary anchors (Home, Meds, Insights)", () => {
|
||||
it("renders the always-on flanking primary anchors (Home, Meds)", () => {
|
||||
const html = render();
|
||||
for (const href of ["/", "/medications", "/insights"]) {
|
||||
// Home + Meds are not module-gated, so they anchor the strip on first
|
||||
// paint. The Insights slot IS module-gated; v1.25.12 gates it behind
|
||||
// hydration (fail-closed pre-mount) so a disabled module can't flicker
|
||||
// in — its presence is asserted in the mounted/e2e path, not here.
|
||||
for (const href of ["/", "/medications"]) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the module-gated Insights slot OUT of the first-paint markup (v1.25.12)", () => {
|
||||
const html = render();
|
||||
// Pre-hydration the module map isn't settled; the Insights primary slot
|
||||
// therefore fails closed (absent) rather than flickering in.
|
||||
expect(html).not.toContain('href="/insights"');
|
||||
});
|
||||
|
||||
it("renders the center capture action as a dialog-opening button, not a link", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="bottom-nav-capture"');
|
||||
@@ -100,11 +111,13 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
|
||||
it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => {
|
||||
const html = render();
|
||||
// The four flanking strip slots (Home, Meds, Insights, More) carry
|
||||
// `min-h-11 min-w-11`. We don't hard-pin the count because a future
|
||||
// entry would also satisfy the contract.
|
||||
// The flanking strip slots carry `min-h-11 min-w-11`. On first paint the
|
||||
// module-gated Insights slot is fail-closed (v1.25.12), so the strip
|
||||
// shows Home, Meds and More — three slots. We don't hard-pin the count
|
||||
// because the mounted render adds Insights back and a future entry would
|
||||
// also satisfy the contract.
|
||||
const matches = html.match(/min-h-11 min-w-11/g) ?? [];
|
||||
expect(matches.length).toBeGreaterThanOrEqual(4);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("the center capture FAB is a larger, elevated tap target", () => {
|
||||
@@ -164,10 +177,15 @@ describe("<BottomNav> iOS-parity layout", () => {
|
||||
mockUserRef.value = { id: "u1", modules: {} };
|
||||
});
|
||||
|
||||
it("keeps the Insights primary slot when the insights module is on", () => {
|
||||
it("still fails the Insights slot closed on first paint even when the module is on (v1.25.12)", () => {
|
||||
// Pre-hydration the slot is fail-closed regardless of the map, so the
|
||||
// disabled-module flicker cannot occur. The mounted render restores the
|
||||
// slot when the module is on (the resolved set is the pure
|
||||
// `mobileMoreHubDestinations(..., mounted: true)` contract in
|
||||
// nav-model.test.ts, plus the e2e mounted render).
|
||||
mockUserRef.value = { id: "u1", modules: { insights: true } };
|
||||
const html = render();
|
||||
expect(html).toContain('href="/insights"');
|
||||
expect(html).not.toContain('href="/insights"');
|
||||
mockUserRef.value = { id: "u1", modules: {} };
|
||||
});
|
||||
|
||||
|
||||
@@ -72,111 +72,96 @@ function render({
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SidebarNav> cycle entry gate (v1.15.0 / v1.18.0 module map)", () => {
|
||||
it("renders the Cycle entry when the cycle module is enabled", () => {
|
||||
const html = render({ modules: { cycle: true } });
|
||||
expect(html).toContain('href="/cycle"');
|
||||
expect(html).toContain("Cycle");
|
||||
// v1.25.12 — these suites render the FIRST-PAINT (pre-hydration) markup.
|
||||
// `renderToStaticMarkup` drives `useMounted()` to its server snapshot
|
||||
// (`false`), so the sidebar applies its fail-CLOSED gate: only CORE
|
||||
// (non-module-gated) destinations render and EVERY module-gated entry is
|
||||
// absent regardless of the module map. This is the fix for the disabled-
|
||||
// module flicker — a gated entry can no longer paint for one frame before the
|
||||
// `/api/auth/me` query resolves. The resolved, post-mount visible set
|
||||
// ("cycle shows when its module is on", "a disabled module drops out") is the
|
||||
// pure `visibleNavDestinations(modules, true)` contract verified in
|
||||
// `nav-model.test.ts`; the mounted render is exercised in e2e.
|
||||
|
||||
// CORE destinations carry no `requiresModule` and always render.
|
||||
const CORE_HREFS = ["/", "/measurements", "/checkups"] as const;
|
||||
// A representative slice of the module-gated entries that must stay out of
|
||||
// the first-paint markup whatever the module map says.
|
||||
const GATED_HREFS = [
|
||||
"/mood",
|
||||
"/cycle",
|
||||
"/medications",
|
||||
"/labs",
|
||||
"/illness",
|
||||
"/insights",
|
||||
"/coach",
|
||||
"/achievements",
|
||||
] as const;
|
||||
|
||||
describe("<SidebarNav> first-paint fail-closed module gate (v1.25.12)", () => {
|
||||
it("renders core destinations on first paint regardless of the module map", () => {
|
||||
const maps: Record<string, boolean>[] = [
|
||||
{},
|
||||
{ cycle: true, mood: true, labs: true, coach: true },
|
||||
{ cycle: false, mood: false },
|
||||
];
|
||||
for (const modules of maps) {
|
||||
const html = render({ modules });
|
||||
for (const href of CORE_HREFS) {
|
||||
expect(html).toContain(`href="${href}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the Cycle entry when the cycle module is disabled", () => {
|
||||
const html = render({ modules: { cycle: false } });
|
||||
expect(html).not.toContain('href="/cycle"');
|
||||
});
|
||||
|
||||
it("places the Cycle entry between Mood and Medications when enabled", () => {
|
||||
const html = render({ modules: { cycle: true } });
|
||||
const mood = html.indexOf('href="/mood"');
|
||||
const cycle = html.indexOf('href="/cycle"');
|
||||
const med = html.indexOf('href="/medications"');
|
||||
expect(mood).toBeGreaterThan(-1);
|
||||
expect(cycle).toBeGreaterThan(-1);
|
||||
expect(med).toBeGreaterThan(-1);
|
||||
// v1.19.1 (S4) — Cycle sits after Mood and before Medications in document
|
||||
// order; Medications opens the fixed clinical/insight spine below it.
|
||||
expect(mood).toBeLessThan(cycle);
|
||||
expect(cycle).toBeLessThan(med);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> module gating (v1.18.0)", () => {
|
||||
it("hides a disabled module's nav entry (mood / labs / coach / achievements)", () => {
|
||||
const html = render({
|
||||
modules: {
|
||||
mood: false,
|
||||
labs: false,
|
||||
coach: false,
|
||||
achievements: false,
|
||||
},
|
||||
});
|
||||
expect(html).not.toContain('href="/mood"');
|
||||
expect(html).not.toContain('href="/labs"');
|
||||
expect(html).not.toContain('href="/coach"');
|
||||
expect(html).not.toContain('href="/achievements"');
|
||||
// Core destinations stay regardless of the module map.
|
||||
expect(html).toContain('href="/measurements"');
|
||||
expect(html).toContain('href="/medications"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
expect(html).toContain('href="/checkups"');
|
||||
});
|
||||
|
||||
it("renders a module's nav entry when its module is enabled", () => {
|
||||
it("keeps every module-gated entry OUT of the first-paint markup, even when its module is enabled", () => {
|
||||
// The flicker bug: a gated entry painted before the module query
|
||||
// resolved. Pre-mount it must be absent even with the module turned on.
|
||||
const html = render({
|
||||
modules: {
|
||||
cycle: true,
|
||||
mood: true,
|
||||
labs: true,
|
||||
coach: true,
|
||||
achievements: true,
|
||||
insights: true,
|
||||
},
|
||||
});
|
||||
expect(html).toContain('href="/mood"');
|
||||
expect(html).toContain('href="/labs"');
|
||||
expect(html).toContain('href="/coach"');
|
||||
expect(html).toContain('href="/achievements"');
|
||||
for (const href of GATED_HREFS) {
|
||||
expect(html).not.toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails open: an empty module map keeps every gated entry visible", () => {
|
||||
it("keeps gated entries out when the module map is empty (no fail-open flicker)", () => {
|
||||
const html = render({ modules: {} });
|
||||
expect(html).toContain('href="/mood"');
|
||||
expect(html).toContain('href="/cycle"');
|
||||
expect(html).toContain('href="/labs"');
|
||||
expect(html).toContain('href="/coach"');
|
||||
expect(html).toContain('href="/achievements"');
|
||||
for (const href of GATED_HREFS) {
|
||||
expect(html).not.toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps gated entries out when the module is explicitly disabled", () => {
|
||||
const html = render({
|
||||
modules: { mood: false, labs: false, coach: false, achievements: false },
|
||||
});
|
||||
for (const href of GATED_HREFS) {
|
||||
expect(html).not.toContain(`href="${href}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render the deprecated /insights/workouts sub-link", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/insights/workouts"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
it("no longer renders the deprecated /targets entry", () => {
|
||||
// The Targets (Zielwerte) page is deprecated; target editing moved
|
||||
// inline into Insights, so the sidebar drops the entry. The main nav
|
||||
// links render into the SSR markup, so asserting absence here is a
|
||||
// real regression net (Insights, the sibling that stays, still shows).
|
||||
// inline into Insights, so the sidebar drops the entry. Assert absence
|
||||
// against a stable CORE sibling that renders on first paint
|
||||
// (Measurements stays; Insights is module-gated and pre-mount absent).
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/targets"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> unified destination model (v1.17.1 F-1 / F-3)", () => {
|
||||
it("surfaces the Coach as a first-class sidebar destination", () => {
|
||||
// Pre-unify the sidebar had no Coach home while the mobile bar missed
|
||||
// it too; both now render the one shared model so both carry it.
|
||||
// (v1.18.0 — Workouts left the left nav for its Insights pill, so it is
|
||||
// no longer a sidebar destination.)
|
||||
const html = render();
|
||||
expect(html).toContain('href="/coach"');
|
||||
expect(html).toContain("Coach");
|
||||
expect(html).not.toContain('href="/insights/workouts"');
|
||||
});
|
||||
|
||||
it("marks Coach active without also marking Insights active", () => {
|
||||
const html = render({ pathname: "/coach" });
|
||||
// The Coach link carries aria-current="page"; the standalone /coach
|
||||
// route is not a sibling of /insights, so the Insights link must not.
|
||||
const coach = html.match(/<a[^>]*href="\/coach"[^>]*>/);
|
||||
const insights = html.match(/<a[^>]*href="\/insights"[^>]*>/);
|
||||
expect(coach?.[0]).toMatch(/aria-current="page"/);
|
||||
expect(insights?.[0]).not.toMatch(/aria-current="page"/);
|
||||
expect(html).toContain('href="/measurements"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -85,6 +86,13 @@ export function BottomNav() {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// The module map rides the client-only `/api/auth/me` query, so it is not
|
||||
// settled on SSR or the first client paint. Gating the module filter behind
|
||||
// `useMounted()` keeps SSR and first paint identical and stops a disabled
|
||||
// module from flickering into the hub / primary slot before the query
|
||||
// resolves (the #418-class divergence); once mounted the real map applies.
|
||||
const mounted = useMounted();
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
// tail. Cycle is gated by the same flag the sidebar uses, so the two
|
||||
@@ -93,21 +101,24 @@ export function BottomNav() {
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
modules: user?.modules,
|
||||
mounted,
|
||||
}),
|
||||
[user?.modules],
|
||||
[user?.modules, mounted],
|
||||
);
|
||||
|
||||
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
|
||||
// has that module disabled. Fail-open: a missing key / unloaded map keeps
|
||||
// the slot, mirroring the gate + More-hub default-on contract.
|
||||
// has that module disabled. Fail-closed until mounted (so a disabled
|
||||
// Insights never flickers in), then the real map applies: a missing key /
|
||||
// unloaded map keeps the slot, mirroring the gate + More-hub default-on
|
||||
// contract.
|
||||
const primaryRight = useMemo(
|
||||
() =>
|
||||
PRIMARY_RIGHT.filter(
|
||||
(item) =>
|
||||
!item.requiresModule ||
|
||||
user?.modules?.[item.requiresModule] !== false,
|
||||
(mounted && user?.modules?.[item.requiresModule] !== false),
|
||||
),
|
||||
[user?.modules],
|
||||
[user?.modules, mounted],
|
||||
);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
|
||||
@@ -242,12 +242,24 @@ export type ModuleVisibilityMap = Partial<Record<ModuleKey, boolean>>;
|
||||
* Whether a destination is visible under the given module map. Core
|
||||
* destinations (no `requiresModule`) always pass; a gated entry passes
|
||||
* unless its module resolves to an explicit `false`.
|
||||
*
|
||||
* `mounted` (default `true`) is the hydration gate the nav bars thread in.
|
||||
* The resolved module map rides the client-only `/api/auth/me` query, which
|
||||
* can still be unresolved on SSR and the first client paint. Reading the map
|
||||
* then would fail OPEN and flicker a disabled module's entry in for one frame
|
||||
* before the query lands and filters it out (the #418-class SSR/client
|
||||
* divergence). So before mount a gated entry is treated as hidden —
|
||||
* fail-CLOSED — making SSR and first paint identical (core-only); once
|
||||
* mounted the real map applies. The default keeps the pure helper fail-open
|
||||
* for non-component callers that pass a settled map.
|
||||
*/
|
||||
function isNavDestinationVisible(
|
||||
d: NavDestination,
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
): boolean {
|
||||
if (!d.requiresModule) return true;
|
||||
if (!mounted) return false;
|
||||
return modules?.[d.requiresModule] !== false;
|
||||
}
|
||||
|
||||
@@ -260,8 +272,11 @@ function isNavDestinationVisible(
|
||||
*/
|
||||
export function visibleNavDestinations(
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
): NavDestination[] {
|
||||
return NAV_DESTINATIONS.filter((d) => isNavDestinationVisible(d, modules));
|
||||
return NAV_DESTINATIONS.filter((d) =>
|
||||
isNavDestinationVisible(d, modules, mounted),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,8 +308,10 @@ export interface MobileMoreHubEntry {
|
||||
*/
|
||||
export function mobileMoreHubDestinations(opts: {
|
||||
modules: ModuleVisibilityMap | undefined;
|
||||
/** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */
|
||||
mounted?: boolean;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const features = visibleNavDestinations(opts.modules)
|
||||
const features = visibleNavDestinations(opts.modules, opts.mounted ?? true)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail = visibleUtilityDestinations().map((d) => ({
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -241,9 +242,15 @@ export function SidebarNav() {
|
||||
// its "More" hub from. v1.18.0 — entries are filtered by the account's
|
||||
// resolved module map (mood, cycle, labs, coach, achievements …); cycle +
|
||||
// coach are delegated server-side and already reflected in that map.
|
||||
// The module map rides the client-only `/api/auth/me` query, so it is not
|
||||
// settled on SSR or the first client paint. Gating the filter behind
|
||||
// `useMounted()` keeps SSR and first paint identical (core destinations
|
||||
// only) and stops a disabled module's entry from flickering in for one
|
||||
// frame before the query resolves; once mounted the real map applies.
|
||||
const mounted = useMounted();
|
||||
const visibleNavItems = useMemo(
|
||||
() => visibleNavDestinations(user?.modules),
|
||||
[user?.modules],
|
||||
() => visibleNavDestinations(user?.modules, mounted),
|
||||
[user?.modules, mounted],
|
||||
);
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MentalWellbeing } from "../mental-wellbeing";
|
||||
import { CheckInWizard } from "../check-in-wizard";
|
||||
import { AssessmentResult } from "../assessment-result";
|
||||
import { AssessmentHistory } from "../assessment-history";
|
||||
import { InstrumentCard } from "../instrument-card";
|
||||
import type { AssessmentRow, CreateResponse } from "../types";
|
||||
|
||||
const mh = en.mentalHealth;
|
||||
@@ -194,3 +195,75 @@ describe("history rendering", () => {
|
||||
expect(empty).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument card → per-instrument trend (v1.25.12)", () => {
|
||||
it("exposes the card body as a detail target separate from Start", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentCard
|
||||
instrument="PHQ9"
|
||||
last={undefined}
|
||||
onStart={() => {}}
|
||||
onOpenDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The title + last-result block is a button that opens the Verlauf…
|
||||
expect(html).toContain('data-slot="instrument-card-open"');
|
||||
expect(html).toContain(mh.openDetail);
|
||||
// …and the Start action is still its own button.
|
||||
expect(html).toContain(mh.start);
|
||||
});
|
||||
|
||||
it("pins the history to one instrument: no toggle, bare (no titled card)", () => {
|
||||
const rows: AssessmentRow[] = [
|
||||
{
|
||||
id: "p1",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
totalScore: 8,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "g1",
|
||||
instrument: "GAD7",
|
||||
locale: "en",
|
||||
totalScore: 12,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={rows} instrument="PHQ9" />,
|
||||
);
|
||||
// Pinned: the PHQ-9 / GAD-7 toggle + the titled landing card are gone…
|
||||
expect(html).toContain('data-pinned="PHQ9"');
|
||||
expect(html).not.toContain('role="tablist"');
|
||||
// …only the chosen instrument's row paints (its band), not the other's.
|
||||
expect(html).toContain(mh.band.PHQ9.mild);
|
||||
expect(html).not.toContain(mh.band.GAD7.moderate);
|
||||
});
|
||||
|
||||
it("shows the per-instrument empty state when that instrument has no rows", () => {
|
||||
const onlyGad: AssessmentRow[] = [
|
||||
{
|
||||
id: "g2",
|
||||
instrument: "GAD7",
|
||||
locale: "en",
|
||||
totalScore: 5,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={onlyGad} instrument="PHQ9" />,
|
||||
);
|
||||
expect(html).toContain(mh.history.empty);
|
||||
expect(html).not.toContain('data-slot="history-list"');
|
||||
});
|
||||
});
|
||||
@@ -68,44 +68,60 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
|
||||
};
|
||||
}
|
||||
|
||||
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
export function AssessmentHistory({
|
||||
rows,
|
||||
instrument,
|
||||
}: {
|
||||
rows: AssessmentRow[];
|
||||
/**
|
||||
* Pin the history to a single instrument. When set, the PHQ-9 / GAD-7
|
||||
* toggle + the outer Card chrome are dropped — the surface is a per-
|
||||
* instrument detail (opened from an instrument card; the chrome + title
|
||||
* come from the host sheet). Unset, it renders the combined landing card
|
||||
* with the toggle, exactly as before.
|
||||
*/
|
||||
instrument?: InstrumentId;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const [tab, setTab] = useState<InstrumentId>("PHQ9");
|
||||
const [tab, setTab] = useState<InstrumentId>(instrument ?? "PHQ9");
|
||||
// Which flagged row's crisis card is currently expanded (re-surfaced).
|
||||
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
|
||||
|
||||
const pinned = instrument != null;
|
||||
// When pinned, the tab is forced to the host instrument so re-opening the
|
||||
// detail for the other instrument never paints a stale series.
|
||||
const activeTab = pinned ? instrument : tab;
|
||||
|
||||
const forTab = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((r) => r.instrument === tab)
|
||||
.filter((r) => r.instrument === activeTab)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
),
|
||||
[rows, tab],
|
||||
[rows, activeTab],
|
||||
);
|
||||
|
||||
const isEmpty = rows.length === 0;
|
||||
// Pinned: emptiness is per-instrument (the host opened THIS instrument's
|
||||
// detail). Combined: emptiness spans both instruments, as before.
|
||||
const isEmpty = pinned ? forTab.length === 0 : rows.length === 0;
|
||||
|
||||
return (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("mentalHealth.history.title")}
|
||||
description={t("mentalHealth.history.empty")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an
|
||||
axis, so the chart + list paint one instrument at a time. */}
|
||||
const body = (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("mentalHealth.history.title")}
|
||||
description={t("mentalHealth.history.empty")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an
|
||||
axis, so the chart + list paint one instrument at a time. Hidden
|
||||
when pinned: the host already chose the instrument. */}
|
||||
{!pinned && (
|
||||
<div
|
||||
className="flex gap-1"
|
||||
role="tablist"
|
||||
@@ -116,9 +132,9 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
aria-selected={activeTab === id}
|
||||
size="sm"
|
||||
variant={tab === id ? "secondary" : "ghost"}
|
||||
variant={activeTab === id ? "secondary" : "ghost"}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
@@ -129,63 +145,88 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssessmentHistoryChart instrument={tab} rows={forTab} />
|
||||
<AssessmentHistoryChart instrument={activeTab} rows={forTab} />
|
||||
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Pinned: render bare (the host sheet owns the chrome + title). Combined:
|
||||
// the landing's titled history card, unchanged.
|
||||
if (pinned) {
|
||||
return (
|
||||
<div
|
||||
data-slot="mental-health-history"
|
||||
data-pinned={instrument}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{body}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -24,45 +24,63 @@ export function InstrumentCard({
|
||||
instrument,
|
||||
last,
|
||||
onStart,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** Most-recent assessment for this instrument, or undefined when none yet. */
|
||||
last: AssessmentRow | undefined;
|
||||
onStart: () => void;
|
||||
/**
|
||||
* Open this instrument's trend detail. The card BODY (title + last-result
|
||||
* line) is the navigation target — mirroring how a medication card opens its
|
||||
* detail — while the bottom-pinned Start button stays a separate action.
|
||||
*/
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const key = lower(instrument);
|
||||
const title = t(`mentalHealth.instrument.${key}`);
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-lg leading-none font-semibold">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${key}`)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Body is the detail target: clicking the title / last-result block
|
||||
opens this instrument's Verlauf, like a medication card. The Start
|
||||
button below is a separate, deliberately non-overlapping action. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
data-slot="instrument-card-open"
|
||||
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
|
||||
className="focus-visible:ring-ring/60 hover:bg-accent/40 -m-1 flex flex-1 cursor-pointer flex-col gap-3.5 rounded-md p-1 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-lg leading-none font-semibold">{title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${key}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Last-test line in the medication-card grammar: label left, the
|
||||
relative day (today / yesterday / date) right-aligned. The severity
|
||||
band is intentionally NOT shown here — the trend below carries it. */}
|
||||
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
|
||||
{last ? (
|
||||
<>
|
||||
<span>{t("mentalHealth.lastResult")}</span>
|
||||
<span className="shrink-0">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Last-test line in the medication-card grammar: label left, the
|
||||
relative day (today / yesterday / date) right-aligned. The severity
|
||||
band is intentionally NOT shown here — the trend behind the card
|
||||
carries it. */}
|
||||
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
|
||||
{last ? (
|
||||
<>
|
||||
<span>{t("mentalHealth.lastResult")}</span>
|
||||
<span className="shrink-0">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-auto pt-0">
|
||||
<div className="pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import { AssessmentResult } from "./assessment-result";
|
||||
@@ -45,6 +46,11 @@ export function MentalWellbeing() {
|
||||
const [phase, setPhase] = useState<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [result, setResult] = useState<CreateResponse | null>(null);
|
||||
// Which instrument's trend detail is open (null = closed). Clicking a card
|
||||
// body surfaces THAT instrument's Verlauf in a sheet; Start stays separate.
|
||||
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
@@ -119,6 +125,7 @@ export function MentalWellbeing() {
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -126,6 +133,33 @@ export function MentalWellbeing() {
|
||||
</section>
|
||||
|
||||
<AssessmentHistory rows={history?.assessments ?? []} />
|
||||
|
||||
{/* Per-instrument trend detail, opened from a card body. Reuses the
|
||||
history's chart + dated list, pinned to the chosen instrument. */}
|
||||
<ResponsiveSheet
|
||||
open={detailInstrument !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailInstrument(null);
|
||||
}}
|
||||
contentWidth="lg"
|
||||
title={
|
||||
detailInstrument
|
||||
? t(
|
||||
`mentalHealth.instrument.${
|
||||
detailInstrument === "PHQ9" ? "phq9" : "gad7"
|
||||
}`,
|
||||
)
|
||||
: t("mentalHealth.history.title")
|
||||
}
|
||||
description={t("mentalHealth.history.chartTitle")}
|
||||
>
|
||||
{detailInstrument && (
|
||||
<AssessmentHistory
|
||||
rows={history?.assessments ?? []}
|
||||
instrument={detailInstrument}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.12 — pre-existing / chronic conditions editor, at home in the Anamnese
|
||||
* (medical-history) section.
|
||||
*
|
||||
* The chronic-conditions note and the explicit "what should the Coach watch"
|
||||
* focus are foundational medical history, so they are edited here next to
|
||||
* allergies + family history — one coherent medical-history home. They used to
|
||||
* be edited under Settings → Profile → "About me"; the editing moved here so the
|
||||
* conditions the Coach watches sit with the rest of the medical record.
|
||||
*
|
||||
* Data path is unchanged: both fields are part of the self-context payload
|
||||
* (`GET`/`PUT /api/coach/about-me`) and are read/written through the same
|
||||
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app uses,
|
||||
* so every consumer (the Coach above all) keeps reading the same store. The PUT
|
||||
* echoes the current `aboutMe` free text (a required field that clears on empty)
|
||||
* and omits `allergies` (optional, preserved server-side) so saving conditions
|
||||
* never disturbs the other self-context fields.
|
||||
*
|
||||
* Plain text only — values render exclusively through inputs and React text
|
||||
* children; no markdown renderer (XSS posture, see contributor notes).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
|
||||
interface AboutMeData {
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
fieldMaxChars: number;
|
||||
}
|
||||
|
||||
const FIELD_CLASSES =
|
||||
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
|
||||
|
||||
export function ConditionsManager() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [drafts, setDrafts] = useState<{
|
||||
conditions: string | null;
|
||||
coachFocus: string | null;
|
||||
}>({ conditions: null, coachFocus: null });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
|
||||
});
|
||||
|
||||
const saved = {
|
||||
conditions: query.data?.conditions ?? "",
|
||||
coachFocus: query.data?.coachFocus ?? "",
|
||||
};
|
||||
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
|
||||
|
||||
const value = {
|
||||
conditions: drafts.conditions ?? saved.conditions,
|
||||
coachFocus: drafts.coachFocus ?? saved.coachFocus,
|
||||
};
|
||||
const dirty =
|
||||
value.conditions !== saved.conditions ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (input: { conditions: string; coachFocus: string }) => {
|
||||
// Echo the current free-text `aboutMe` (required; empty clears it) and
|
||||
// omit `allergies` (optional, preserved server-side) so this write only
|
||||
// touches the two conditions fields.
|
||||
return apiPut<AboutMeData>("/api/coach/about-me", {
|
||||
aboutMe: query.data?.aboutMe ?? "",
|
||||
conditions: input.conditions,
|
||||
coachFocus: input.coachFocus,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.conditions.savedToast"));
|
||||
setDrafts({ conditions: null, coachFocus: null });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("records.conditions.saveError"));
|
||||
},
|
||||
});
|
||||
|
||||
const disabled = query.isLoading || save.isPending;
|
||||
|
||||
function field(
|
||||
key: "conditions" | "coachFocus",
|
||||
label: string,
|
||||
placeholder: string,
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`records-conditions-${key}`}>{label}</Label>
|
||||
<textarea
|
||||
id={`records-conditions-${key}`}
|
||||
data-testid={`records-conditions-${key}`}
|
||||
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
|
||||
rows={2}
|
||||
value={value[key]}
|
||||
maxLength={fieldMaxChars}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="conditions-manager">
|
||||
{query.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("records.conditions.loadError")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{field(
|
||||
"conditions",
|
||||
t("records.conditions.conditionsLabel"),
|
||||
t("settings.ai.aboutMe.conditionsPlaceholder"),
|
||||
)}
|
||||
{field(
|
||||
"coachFocus",
|
||||
t("records.conditions.focusLabel"),
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="records-conditions-save"
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => save.mutate(value)}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("settings.ai.aboutMe.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.1 — pre-existing / chronic conditions, surfaced in the Anamnese
|
||||
* (medical-history) home.
|
||||
*
|
||||
* The chronic-conditions note and the explicit "what should the Coach watch"
|
||||
* focus the user entered in Settings → Profile → "About me" are foundational
|
||||
* medical history, so they are surfaced here next to allergies + family history
|
||||
* to give one coherent medical-history overview.
|
||||
*
|
||||
* This is a READ-ONLY shared view of the existing self-context
|
||||
* (`GET /api/coach/about-me`) — it reads through the same query-key the "About
|
||||
* me" panel writes, so the two stay in lockstep. Editing keeps its single home
|
||||
* in personal context (linked below); nothing is moved, no new storage, no
|
||||
* schema change. Plain text only — values render as React text children, no
|
||||
* markdown renderer (XSS posture, see contributor notes).
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface SelfContext {
|
||||
conditions: string | null;
|
||||
coachFocus: string | null;
|
||||
}
|
||||
|
||||
export function ConditionsOverview() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
queryFn: () => apiGet<SelfContext>("/api/coach/about-me"),
|
||||
});
|
||||
|
||||
const conditions = (data?.conditions ?? "").trim();
|
||||
const coachFocus = (data?.coachFocus ?? "").trim();
|
||||
const hasAny = conditions.length > 0 || coachFocus.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="conditions-overview">
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("records.conditions.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{hasAny ? (
|
||||
<dl className="space-y-3">
|
||||
{conditions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-sm font-medium">
|
||||
{t("records.conditions.conditionsLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{conditions}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{coachFocus.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-sm font-medium">
|
||||
{t("records.conditions.focusLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{coachFocus}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.conditions.empty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs">
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="text-dracula-purple underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("records.conditions.editCta")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,21 @@
|
||||
/**
|
||||
* v1.15.20 — Settings → AI "About me" panel (free text only).
|
||||
* v1.16.0 — structured self-context: alongside the free text the user
|
||||
* answers three short questions (chronic conditions, allergies /
|
||||
* intolerances, what the Coach should watch). All four fields are
|
||||
* encrypted at rest; age/gender stay on the profile and are merged
|
||||
* into the prompt server-side — the panel says so instead of asking
|
||||
* again.
|
||||
* notes allergies / intolerances. All fields are encrypted at rest;
|
||||
* age/gender stay on the profile and are merged into the prompt
|
||||
* server-side — the panel says so instead of asking again.
|
||||
*
|
||||
* v1.25.12 — the pre-existing / chronic conditions and the "what should the
|
||||
* Coach watch" focus moved out of this panel into Settings → Anamnese, where
|
||||
* they sit with allergies + family history as one medical history. Editing them
|
||||
* has a single home there now; this panel keeps the free-text note and the
|
||||
* allergies line. The store is unchanged — every field is still part of the
|
||||
* `/api/coach/about-me` payload; the PUT here simply omits the two conditions
|
||||
* fields, which are preserved server-side.
|
||||
*
|
||||
* GET /api/coach/about-me — full structured payload + pending questions
|
||||
* PUT /api/coach/about-me — writes all four fields (empty string clears)
|
||||
* PUT /api/coach/about-me — writes aboutMe (empty string clears) + allergies;
|
||||
* conditions / coachFocus are omitted here and left untouched
|
||||
*
|
||||
* After a save the server derives up to 3 clarifying questions (AI
|
||||
* when a provider + budget allow, deterministic hints otherwise); the
|
||||
@@ -54,7 +61,7 @@ interface AboutMeData {
|
||||
fieldMaxChars: number;
|
||||
}
|
||||
|
||||
type StructuredKey = "conditions" | "allergies" | "coachFocus";
|
||||
type StructuredKey = "allergies";
|
||||
|
||||
async function fetchAboutMe(): Promise<AboutMeData> {
|
||||
return apiGet<AboutMeData>("/api/coach/about-me");
|
||||
@@ -72,10 +79,8 @@ export function AboutMeSection({
|
||||
const queryClient = useQueryClient();
|
||||
const [drafts, setDrafts] = useState<{
|
||||
aboutMe: string | null;
|
||||
conditions: string | null;
|
||||
allergies: string | null;
|
||||
coachFocus: string | null;
|
||||
}>({ aboutMe: null, conditions: null, allergies: null, coachFocus: null });
|
||||
}>({ aboutMe: null, allergies: null });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
@@ -85,9 +90,7 @@ export function AboutMeSection({
|
||||
|
||||
const saved = {
|
||||
aboutMe: query.data?.aboutMe ?? "",
|
||||
conditions: query.data?.conditions ?? "",
|
||||
allergies: query.data?.allergies ?? "",
|
||||
coachFocus: query.data?.coachFocus ?? "",
|
||||
};
|
||||
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
|
||||
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
|
||||
@@ -95,57 +98,33 @@ export function AboutMeSection({
|
||||
|
||||
const value = {
|
||||
aboutMe: drafts.aboutMe ?? saved.aboutMe,
|
||||
conditions: drafts.conditions ?? saved.conditions,
|
||||
allergies: drafts.allergies ?? saved.allergies,
|
||||
coachFocus: drafts.coachFocus ?? saved.coachFocus,
|
||||
};
|
||||
const dirty =
|
||||
value.aboutMe !== saved.aboutMe ||
|
||||
value.conditions !== saved.conditions ||
|
||||
value.allergies !== saved.allergies ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
const hasAnyContent =
|
||||
saved.aboutMe.length > 0 ||
|
||||
saved.conditions.length > 0 ||
|
||||
saved.allergies.length > 0 ||
|
||||
saved.coachFocus.length > 0;
|
||||
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
|
||||
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
|
||||
|
||||
// Quiet completeness meter: one segment per answered field. Reads
|
||||
// the SAVED state (not the draft) so it only moves on persistence.
|
||||
const answered = [
|
||||
saved.conditions,
|
||||
saved.allergies,
|
||||
saved.coachFocus,
|
||||
saved.aboutMe,
|
||||
].filter((v) => v.length > 0).length;
|
||||
const answered = [saved.allergies, saved.aboutMe].filter(
|
||||
(v) => v.length > 0,
|
||||
).length;
|
||||
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (input: {
|
||||
aboutMe: string;
|
||||
conditions: string;
|
||||
allergies: string;
|
||||
coachFocus: string;
|
||||
}) => {
|
||||
// Send only the two fields this panel owns. `conditions` / `coachFocus`
|
||||
// are omitted so the server preserves them (they are edited under Anamnese).
|
||||
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
|
||||
return apiPut<AboutMeData>("/api/coach/about-me", input);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
const cleared =
|
||||
!next.aboutMe &&
|
||||
!next.conditions &&
|
||||
!next.allergies &&
|
||||
!next.coachFocus;
|
||||
const cleared = !next.aboutMe && !next.allergies;
|
||||
toast.success(
|
||||
cleared
|
||||
? t("settings.ai.aboutMe.clearedToast")
|
||||
: t("settings.ai.aboutMe.savedToast"),
|
||||
);
|
||||
setDrafts({
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
});
|
||||
setDrafts({ aboutMe: null, allergies: null });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
@@ -195,19 +174,19 @@ export function AboutMeSection({
|
||||
titleId="settings-ai-about-me-title"
|
||||
description={t("settings.ai.aboutMe.description")}
|
||||
/>
|
||||
{/* Completeness — four quiet segments, no percentage shouting. */}
|
||||
{/* Completeness — two quiet segments, no percentage shouting. */}
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
total: 2,
|
||||
})}
|
||||
title={t("settings.ai.aboutMe.completeness", {
|
||||
answered,
|
||||
total: 4,
|
||||
total: 2,
|
||||
})}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
{[0, 1].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
@@ -220,24 +199,12 @@ export function AboutMeSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 pl-7 sm:grid-cols-2">
|
||||
{structuredField(
|
||||
"conditions",
|
||||
t("settings.ai.aboutMe.conditionsLabel"),
|
||||
t("settings.ai.aboutMe.conditionsPlaceholder"),
|
||||
)}
|
||||
<div className="pl-7">
|
||||
{structuredField(
|
||||
"allergies",
|
||||
t("settings.ai.aboutMe.allergiesLabel"),
|
||||
t("settings.ai.aboutMe.allergiesPlaceholder"),
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
{structuredField(
|
||||
"coachFocus",
|
||||
t("settings.ai.aboutMe.focusLabel"),
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pl-7">
|
||||
@@ -277,14 +244,7 @@ export function AboutMeSection({
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-about-me-clear"
|
||||
disabled={!isAuthenticated || save.isPending}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
aboutMe: "",
|
||||
conditions: "",
|
||||
allergies: "",
|
||||
coachFocus: "",
|
||||
})
|
||||
}
|
||||
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("settings.ai.aboutMe.clear")}
|
||||
@@ -351,9 +311,9 @@ export function AboutMeSection({
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
|
||||
{/* v1.25.1 — cross-link to the Anamnese (medical-history) home. The
|
||||
chronic conditions noted above plus allergies + family history read as
|
||||
one coherent medical history there. */}
|
||||
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
|
||||
chronic conditions the Coach watches now live alongside allergies +
|
||||
family history as one coherent medical history. */}
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
{t("settings.ai.aboutMe.recordsLink")}{" "}
|
||||
<Link
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* signal and not a clinical diagnosis — surfaced alongside the existing
|
||||
* tracking-domain settings sections (Labs / Illness / Vorsorge).
|
||||
*
|
||||
* v1.25.1 — the section also surfaces the pre-existing / chronic conditions the
|
||||
* Coach watches (the self-context entered under Profile → "About me") as a
|
||||
* read-only shared view, so conditions + allergies + family history read as one
|
||||
* coherent medical history. The conditions card is coach-gated (the data only
|
||||
* feeds the Coach) and links back to its single editing home in personal
|
||||
* context.
|
||||
* v1.25.12 — the section is the single home for the pre-existing / chronic
|
||||
* conditions the Coach watches, edited inline here so conditions + allergies +
|
||||
* family history read (and write) as one coherent medical history. The
|
||||
* conditions card is coach-gated (the data only feeds the Coach); it reads and
|
||||
* writes the same self-context store (`/api/coach/about-me`) the rest of the app
|
||||
* uses — the placement simply moved out of personal context into the medical
|
||||
* record where it belongs.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -28,7 +29,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
|
||||
import { AllergyManager } from "@/components/records/allergy-manager";
|
||||
import { ConditionsOverview } from "@/components/records/conditions-overview";
|
||||
import { ConditionsManager } from "@/components/records/conditions-manager";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
|
||||
export function AnamnesisSection() {
|
||||
@@ -45,7 +46,7 @@ export function AnamnesisSection() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConditionsOverview />
|
||||
<ConditionsManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -61,8 +61,20 @@ export const AI_BUDGETS = {
|
||||
* generation runs off the request hot path (nightly warm + the on-demand
|
||||
* background warm), and the explicit regenerate is rate-limited, so the
|
||||
* wider ceiling is bounded.
|
||||
*
|
||||
* v1.25.12 — raised again to 180 s. The same "operation aborted due to
|
||||
* timeout" hop (no upstream status) recurred at exactly the 120 s ceiling
|
||||
* once the codex default slug moved to the heavier-reasoning `gpt-5.5`
|
||||
* line: a wide account (full-history briefing, raw signals included) spends
|
||||
* longer in the reasoning channel before the first visible token than the
|
||||
* 120 s budget allowed, so the single-provider chain timed out with nothing
|
||||
* to fall back to and the briefing rendered its greeting with no body. The
|
||||
* per-user `aiResponseTimeoutSeconds` setting still overrides this default
|
||||
* for operators on even slower backends; this only lifts the floor for
|
||||
* accounts that never set it. The worker cap in `insight-pregenerate.ts`
|
||||
* derives from this value plus fixed headroom, so it tracks automatically.
|
||||
*/
|
||||
comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 120_000 },
|
||||
comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 180_000 },
|
||||
|
||||
/**
|
||||
* Per-metric status assessment cards — output is a single
|
||||
|
||||
@@ -304,10 +304,10 @@ describe("runInsightPregenerate — outcome tally", () => {
|
||||
observedSignal = opts.signal;
|
||||
setTimeout(
|
||||
() => resolve({ status: "generated", providerType: "x" }),
|
||||
// v1.25.3 — above the default warm budget (~150 s = the 120 s
|
||||
// v1.25.12 — above the default warm budget (~210 s = the 180 s
|
||||
// comprehensive provider budget + headroom) so the bounded budget
|
||||
// fires before this "slow" generation settles.
|
||||
200_000,
|
||||
260_000,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -323,7 +323,7 @@ describe("runInsightPregenerate — outcome tally", () => {
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(160_000);
|
||||
await vi.advanceTimersByTimeAsync(230_000);
|
||||
const result = await promise;
|
||||
|
||||
// The timeout fired, aborted the still-running generation (so its
|
||||
@@ -798,10 +798,10 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
// advance past it so the warm continues.
|
||||
setTimeout(
|
||||
() => resolve({ status: "generated", providerType: "x" }),
|
||||
// v1.25.3 — above the default warm budget (~150 s = the 120 s
|
||||
// v1.25.12 — above the default warm budget (~210 s = the 180 s
|
||||
// comprehensive provider budget + headroom) so the bounded budget
|
||||
// fires before this "slow" generation settles.
|
||||
200_000,
|
||||
260_000,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -818,7 +818,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
warmGenericMetrics,
|
||||
});
|
||||
// Advance past the comprehensive budget so withTimeout fires.
|
||||
await vi.advanceTimersByTimeAsync(160_000);
|
||||
await vi.advanceTimersByTimeAsync(230_000);
|
||||
const result = await promise;
|
||||
|
||||
for (const g of statusGenerators) {
|
||||
@@ -916,10 +916,10 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
observedSignal = opts.signal;
|
||||
setTimeout(
|
||||
() => resolve({ status: "generated", providerType: "x" }),
|
||||
// v1.25.3 — above the default warm budget (~150 s = the 120 s
|
||||
// v1.25.12 — above the default warm budget (~210 s = the 180 s
|
||||
// comprehensive provider budget + headroom) so the bounded budget
|
||||
// fires before this "slow" generation settles.
|
||||
200_000,
|
||||
260_000,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -935,7 +935,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(160_000);
|
||||
await vi.advanceTimersByTimeAsync(230_000);
|
||||
const result = await promise;
|
||||
|
||||
// The timeout fired and aborted the still-running generation.
|
||||
|
||||
Reference in new issue
Block a user