From b46550d9a27517202490626de4f82de2dc410ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 03:54:23 +0200 Subject: [PATCH 01/19] fix(settings): correct the MCP token scope note to match the write toggle The note above the write toggle claimed tokens are read-only in every case, which the toggle directly below it contradicts by minting a read_write scope. Rewrite it to state the real model: read-only by default, write opt-in per token, never admin. Resolve the pronoun that read as a formal address, and promote both scope notes to foreground so this security copy no longer renders as muted meta. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/components/settings/mcp-section.tsx | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index d86231fae..d106a7180 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6283,7 +6283,7 @@ "connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.", "tokensTitle": "Connector-Tokens", "tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden.", - "scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Admin-Funktionen erreichen.", + "scopeNote": "Tokens haben standardmäßig nur Lesezugriff auf deine eigenen Daten. Schreibzugriff ist optional und wird pro Token über den Schalter unten aktiviert. Die Tokens erreichen niemals Admin-Funktionen.", "writeScopeToggleLabel": "Eintragen erlauben (health:write)", "writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.", "connectionsTitle": "Verbundene Assistenten", diff --git a/messages/en.json b/messages/en.json index 2b9733388..27abc1201 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6283,7 +6283,7 @@ "connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.", "tokensTitle": "Connector tokens", "tokensDescription": "Mint a read-only token to connect an assistant.", - "scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never reach admin functions.", + "scopeNote": "Tokens grant read-only access to your own records by default. Write access is optional and enabled per token with the toggle below. The tokens can never reach admin functions.", "writeScopeToggleLabel": "Allow logging (health:write)", "writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.", "connectionsTitle": "Connected assistants", diff --git a/messages/es.json b/messages/es.json index 9f3c62e0b..0575679e5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6283,7 +6283,7 @@ "connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.", "tokensTitle": "Tokens del conector", "tokensDescription": "Genera un token de solo lectura para conectar un asistente.", - "scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden acceder a funciones de administración.", + "scopeNote": "De forma predeterminada, los tokens solo tienen acceso de lectura a tus propios registros. El acceso de escritura es opcional y se activa por token con el interruptor de abajo. Los tokens nunca pueden acceder a funciones de administración.", "writeScopeToggleLabel": "Permitir registro (health:write)", "writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.", "connectionsTitle": "Asistentes conectados", diff --git a/messages/fr.json b/messages/fr.json index 4d0a640d9..f76edb4e8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6283,7 +6283,7 @@ "connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.", "tokensTitle": "Jetons du connecteur", "tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant.", - "scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais atteindre les fonctions d'administration.", + "scopeNote": "Par défaut, les jetons n'ont qu'un accès en lecture à vos propres données. L'accès en écriture est optionnel et s'active par jeton avec le commutateur ci-dessous. Les jetons ne peuvent jamais atteindre les fonctions d'administration.", "writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)", "writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.", "connectionsTitle": "Assistants connectés", diff --git a/messages/it.json b/messages/it.json index 1168f6731..015f0b5c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6283,7 +6283,7 @@ "connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.", "tokensTitle": "Token del connettore", "tokensDescription": "Genera un token in sola lettura per collegare un assistente.", - "scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai raggiungere funzioni di amministrazione.", + "scopeNote": "Per impostazione predefinita, i token hanno accesso in sola lettura ai tuoi dati. L'accesso in scrittura è opzionale e si attiva per singolo token con l'interruttore qui sotto. I token non possono mai raggiungere funzioni di amministrazione.", "writeScopeToggleLabel": "Consenti la registrazione (health:write)", "writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.", "connectionsTitle": "Assistenti collegati", diff --git a/messages/pl.json b/messages/pl.json index 31d997419..577b5aa92 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6283,7 +6283,7 @@ "connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.", "tokensTitle": "Tokeny łącznika", "tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta.", - "scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.", + "scopeNote": "Domyślnie tokeny mają dostęp tylko do odczytu własnych danych. Dostęp do zapisu jest opcjonalny i włączany dla każdego tokena przełącznikiem poniżej. Tokeny nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.", "writeScopeToggleLabel": "Zezwól na zapis (health:write)", "writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.", "connectionsTitle": "Połączeni asystenci", diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index e368d3f0a..9c0692e1c 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -340,7 +340,7 @@ function McpTokensCard() {

{t("settings.mcp.tokensDetail")}

-

+

{t("settings.mcp.scopeNote")}

@@ -355,7 +355,7 @@ function McpTokensCard() { aria-label={t("settings.mcp.writeScopeToggleLabel")} />
-

+

{t("settings.mcp.writeScopeNote")}

From 338e9bc81877d93c88afc8f84cb64b0ec69cab9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 03:59:19 +0200 Subject: [PATCH 02/19] fix(settings): render AI consent copy as foreground content The central-codex and auto-read consent notes are safety copy the user has to read before enabling a data-sharing path, but both rendered as muted extra-small meta. Promote them to foreground text-sm and keep only the decorative icon muted. --- src/components/settings/ai/auto-read-card.tsx | 7 +++++-- src/components/settings/ai/central-codex-switch.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/settings/ai/auto-read-card.tsx b/src/components/settings/ai/auto-read-card.tsx index 87da8c36c..197598a9d 100644 --- a/src/components/settings/ai/auto-read-card.tsx +++ b/src/components/settings/ai/auto-read-card.tsx @@ -105,8 +105,11 @@ export function AutoReadCard() { role="note" className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5" > -
- +
+

{t("settings.ai.autoRead.honesty")}

diff --git a/src/components/settings/ai/central-codex-switch.tsx b/src/components/settings/ai/central-codex-switch.tsx index 9f8ee6981..9e2806825 100644 --- a/src/components/settings/ai/central-codex-switch.tsx +++ b/src/components/settings/ai/central-codex-switch.tsx @@ -104,8 +104,11 @@ export function CentralCodexSwitch({ role="note" className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5" > -
- +
+

{t("settings.ai.centralCodex.honesty")}

From 6d67e84ceab09a226dfe68af4003bbf812990b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 03:59:19 +0200 Subject: [PATCH 03/19] fix(dashboard): localize the respiratory-rate unit label The respiratory-rate tile hard-coded the English unit while the metric sub-page already reads the localized string. Point the tile at the same key so every locale shows its own unit. --- src/app/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index f5fa53d10..5287c60f8 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -1354,7 +1354,7 @@ export default function DashboardPageClient({ key="respiratoryRate" label={t("measurements.typeRespiratoryRate")} latest={respRateSummary?.latest ?? null} - unit="breaths/min" + unit={t("insights.units.respiratoryRate")} avg7={respRateSummary?.avg7 ?? null} avg30={respRateSummary?.avg30 ?? null} slope30={respRateSummary?.slope30 ?? null} From 6e3965146c77d6554de1261eca4c252ae6b3ab4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 03:59:34 +0200 Subject: [PATCH 04/19] fix(settings): honest address, greeting comma, and managed-record error classes Three small copy corrections that share the locale bundles: - Resolve the formal-address outliers on the owner sharing and report scopes to the informal register the rest of those surfaces use. - Add the direct-address comma to the personalized dashboard greeting. - Split the managed-record form error: a client-side validation failure no longer surfaces as a network error, and a failed save gets its own line. --- messages/de.json | 10 ++++++---- messages/en.json | 4 +++- messages/es.json | 10 ++++++---- messages/fr.json | 4 +++- messages/it.json | 4 +++- messages/pl.json | 4 +++- .../settings/managed-record-settings-section.tsx | 8 ++++++-- 7 files changed, 30 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index d106a7180..d0f074f51 100644 --- a/messages/de.json +++ b/messages/de.json @@ -427,7 +427,7 @@ "evening": "Guten Abend" }, "welcomeBack": "{greeting}, willkommen zurück.", - "welcomeBackWithName": "{greeting} {name}, willkommen zurück.", + "welcomeBackWithName": "{greeting}, {name}, willkommen zurück.", "greetingSalutation": "Hallo, {name}", "streakLabel": "Tage in Folge", "weight": "Gewicht", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "validationError": "Bitte prüfe deine Eingaben.", "profile": { "title": "Profil", "displayName": "Anzeigename", @@ -6024,7 +6026,7 @@ "scopeTitle": "Was dieser Link enthält", "scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.", "statusNeedsReselect": "Inhalt neu wählen", - "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.", + "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.", "reselectAction": "Ersatz anlegen", "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen." }, @@ -9836,8 +9838,8 @@ "standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.", "groupCount": "{on}/{total}", "groupDisclosure": "Einträge in {group} anzeigen", - "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.", - "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", + "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wähle jeden Eintrag selbst.", + "scopeNone": "Noch nichts ausgewählt. Wähle aus, was enthalten sein soll, oder übernimm den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}", "leafVisits": "Arztbesuche", diff --git a/messages/en.json b/messages/en.json index 27abc1201..75193bb2c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -427,7 +427,7 @@ "evening": "Good evening" }, "welcomeBack": "{greeting}, welcome back.", - "welcomeBackWithName": "{greeting} {name}, welcome back.", + "welcomeBackWithName": "{greeting}, {name}, welcome back.", "greetingSalutation": "Hi, {name}", "streakLabel": "days in a row", "weight": "Weight", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "Couldn't save. Please try again.", + "validationError": "Please check your entries.", "profile": { "title": "Profile", "displayName": "Display name", diff --git a/messages/es.json b/messages/es.json index 0575679e5..0ba944055 100644 --- a/messages/es.json +++ b/messages/es.json @@ -427,7 +427,7 @@ "evening": "Buenas tardes" }, "welcomeBack": "{greeting}, te damos la bienvenida de nuevo.", - "welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.", + "welcomeBackWithName": "{greeting}, {name}, te damos la bienvenida de nuevo.", "greetingSalutation": "Hola, {name}", "streakLabel": "días seguidos", "weight": "Peso", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "validationError": "Revisa tus datos.", "profile": { "title": "Perfil", "displayName": "Nombre visible", @@ -6024,7 +6026,7 @@ "scopeTitle": "Qué contiene este enlace", "scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.", "statusNeedsReselect": "Elegir contenido de nuevo", - "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.", + "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.", "reselectAction": "Crear un reemplazo", "createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar." }, @@ -9836,8 +9838,8 @@ "standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.", "groupCount": "{on}/{total}", "groupDisclosure": "Mostrar las entradas de {group}", - "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.", - "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", + "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elige cada entrada tú mismo.", + "scopeNone": "Nada seleccionado todavía. Elige qué incluir o aplica el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}", "leafVisits": "Visitas médicas", diff --git a/messages/fr.json b/messages/fr.json index f76edb4e8..4f8344eb7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -427,7 +427,7 @@ "evening": "Bonsoir" }, "welcomeBack": "{greeting}, content de te revoir.", - "welcomeBackWithName": "{greeting} {name}, content de te revoir.", + "welcomeBackWithName": "{greeting}, {name}, content de te revoir.", "greetingSalutation": "Bonjour, {name}", "streakLabel": "jours d’affilée", "weight": "Poids", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "Échec de l'enregistrement. Réessayez.", + "validationError": "Vérifiez vos saisies.", "profile": { "title": "Profil", "displayName": "Nom affiché", diff --git a/messages/it.json b/messages/it.json index 015f0b5c9..09ab45195 100644 --- a/messages/it.json +++ b/messages/it.json @@ -427,7 +427,7 @@ "evening": "Buonasera" }, "welcomeBack": "{greeting}, bentornato.", - "welcomeBackWithName": "{greeting} {name}, bentornato.", + "welcomeBackWithName": "{greeting}, {name}, bentornato.", "greetingSalutation": "Ciao, {name}", "streakLabel": "giorni di fila", "weight": "Peso", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "Salvataggio non riuscito. Riprova.", + "validationError": "Controlla i tuoi dati.", "profile": { "title": "Profilo", "displayName": "Nome visualizzato", diff --git a/messages/pl.json b/messages/pl.json index 577b5aa92..473e7c6fa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -427,7 +427,7 @@ "evening": "Dobry wieczór" }, "welcomeBack": "{greeting}, witaj ponownie.", - "welcomeBackWithName": "{greeting} {name}, witaj ponownie.", + "welcomeBackWithName": "{greeting}, {name}, witaj ponownie.", "greetingSalutation": "Cześć, {name}", "streakLabel": "dni z rzędu", "weight": "Waga", @@ -4615,6 +4615,8 @@ }, "sharedRecord": { "managedSettings": { + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "validationError": "Sprawdź wprowadzone dane.", "profile": { "title": "Profil", "displayName": "Wyświetlana nazwa", diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 853763c45..b72d0708d 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -1010,9 +1010,13 @@ export function ManagedRecordSettingsSection({ saveLabel={t("common.save")} settings={query.data.settings} /> - {save.isError || validationFailed ? ( + {validationFailed ? (

- {t("common.networkError")} + {t("settings.sharedRecord.managedSettings.validationError")} +

+ ) : save.isError ? ( +

+ {t("settings.sharedRecord.managedSettings.saveError")}

) : null} From 53a463f11bbafaaa0b548ea678f97b331b88bdf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:03:09 +0200 Subject: [PATCH 05/19] fix(settings): show a read failure on the share-link list, not an empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share-link query had no error or loading handling, so a failed read collapsed to an empty list and rendered the "no active share links" copy — on a clinical sharing surface a load failure was indistinguishable from "you have shared nothing". Gate the list on the query state: QueryErrorCard with retry on failure, a skeleton while loading, the lists only once the data is in. The added test was watched fail first against the old fall-through before this fix made it pass. --- .../sharing-section-error-state.test.tsx | 54 +++ src/components/settings/sharing-section.tsx | 354 +++++++++--------- 2 files changed, 241 insertions(+), 167 deletions(-) create mode 100644 src/components/settings/__tests__/sharing-section-error-state.test.tsx diff --git a/src/components/settings/__tests__/sharing-section-error-state.test.tsx b/src/components/settings/__tests__/sharing-section-error-state.test.tsx new file mode 100644 index 000000000..a1e110d50 --- /dev/null +++ b/src/components/settings/__tests__/sharing-section-error-state.test.tsx @@ -0,0 +1,54 @@ +/** + * A read failure on the owner share-link surface must not read as "no active + * links". `useQuery` is forced into its error state; the card has to render the + * `query-error-card` slot with a retry affordance and must never fall through + * to the empty-state copy — that fall-through is the §6-forbidden case a + * clinical sharing surface cannot afford (a failed read is indistinguishable + * from "you have shared nothing"). + * + * SSR-static render, matching the sibling sharing-section test. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isError: true, + isLoading: false, + refetch: vi.fn(), + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SharingSection } from "../sharing-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a read failure is honest, not empty", () => { + it("renders the query-error card with retry and never the empty state", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + // The empty-state must not appear: a failed read is not "no active links". + expect(html).not.toContain('data-testid="share-active-empty"'); + expect(html).not.toContain("No active share links"); + }); +}); diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 6bbc8ba2a..88cb483ca 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -39,6 +39,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { @@ -106,7 +108,12 @@ function ShareLinksCard() { expiryDays: number; } | null>(null); - const { data: links } = useQuery({ + const { + data: links, + isError, + isLoading, + refetch, + } = useQuery({ queryKey: queryKeys.shareLinks(), queryFn: () => apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then( @@ -147,180 +154,193 @@ function ShareLinksCard() { initialExpiryDays={prefill?.expiryDays} /> -
-

- {t("settings.sharing.activeTitle")} -

- {activeLinks.length === 0 ? ( - - ) : ( -
    - {activeLinks.map((link) => ( -
  • refetch()} /> + ) : isLoading ? ( +
    + + +
    + ) : ( + <> +
    +

    + {t("settings.sharing.activeTitle")} +

    + {activeLinks.length === 0 ? ( + + ) : ( +
      + {activeLinks.map((link) => ( +
    • +
      +

      + {link.label} +

      +
      + + {t("settings.sharing.statusActive")} + + {link.protected && ( + + + {t("settings.sharing.protected")} + + )} + {link.documentCount > 0 && ( + + + {link.documentCount} + + )} +
      +
      +

      + + {t("settings.sharing.created")}: + {" "} + {formatDate(link.createdAt)} +

      +

      + + {t("settings.sharing.expires")}: + {" "} + {formatDateTime(link.expiresAt)} +

      +

      + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount} + {link.lastAccessAt + ? ` · ${formatDateTime(link.lastAccessAt)}` + : ""} +

      + + + + + + + + {t("settings.sharing.revoke")} + + + {t("settings.sharing.revokeDescription")} + + + + + {t("common.cancel")} + + revokeMutation.mutate(link.id)} + > + {t("settings.sharing.revoke")} + + + + +
    • + ))} +
    + )} +
    + + {inactiveLinks.length > 0 && ( +
    + - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - -
  • - ))} -
- )} -
- - {inactiveLinks.length > 0 && ( -
- - {showRevoked && ( -
    - {inactiveLinks.map((link) => ( -
  • -
    -

    - {link.label} -

    - - {link.needsReselection - ? t("settings.sharing.statusNeedsReselect") - : link.revokedAt - ? t("settings.sharing.statusRevoked") - : t("settings.sharing.statusExpired")} - -
    - {/* A link whose frozen scope predates the selection model. +
    +

    + {link.label} +

    + + {link.needsReselection + ? t("settings.sharing.statusNeedsReselect") + : link.revokedAt + ? t("settings.sharing.statusRevoked") + : t("settings.sharing.statusExpired")} + +
    + {/* A link whose frozen scope predates the selection model. It served a scope nobody chose, so it was closed; the owner re-mints above with the label, window and expiry still in front of them. */} - {link.needsReselection ? ( -
    + {link.needsReselection ? ( +
    +

    + {t("settings.sharing.reselectReason")} +

    + +
    + ) : null}

    - {t("settings.sharing.reselectReason")} + + {t("settings.sharing.accessCount")}: + {" "} + {link.accessCount}

    - -
    - ) : null} -

    - - {t("settings.sharing.accessCount")}: - {" "} - {link.accessCount} -

    -
  • - ))} -
+ + ))} + + )} +
)} -
+ )} ); From ab2c5cb2aad154487404eccaf702bc76f20f9704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:05:35 +0200 Subject: [PATCH 06/19] fix(settings): show a read failure on the API-token list, not an empty state Same fall-through as the share-link list on another security surface: the token query had no error or loading branch, so a failed read rendered "no active tokens". Gate the list on the query state with QueryErrorCard and retry, a skeleton while loading. Test watched fail first against the old empty-state fall-through. --- .../api-section-error-state.test.tsx | 61 ++ src/components/settings/api-section.tsx | 563 +++++++++--------- 2 files changed, 353 insertions(+), 271 deletions(-) create mode 100644 src/components/settings/__tests__/api-section-error-state.test.tsx diff --git a/src/components/settings/__tests__/api-section-error-state.test.tsx b/src/components/settings/__tests__/api-section-error-state.test.tsx new file mode 100644 index 000000000..6b3f01754 --- /dev/null +++ b/src/components/settings/__tests__/api-section-error-state.test.tsx @@ -0,0 +1,61 @@ +/** + * A read failure on the API-token list must not read as "no active tokens". + * `useQuery` is forced into its error state; the card has to render the + * `query-error-card` slot with retry and must never fall through to the empty + * state — the same §6 fall-through the share-link surface had, on another + * security surface. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/api", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ApiSection } from "../api-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ApiSection — a token-read failure is honest, not empty", () => { + it("renders the query-error card with retry and never the empty state", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + expect(html).not.toContain('data-testid="settings-api-tokens-active-empty"'); + expect(html).not.toContain("No active tokens"); + }); +}); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index d065378c4..af2565527 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -19,6 +19,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; @@ -150,7 +152,12 @@ function ApiTokensCard() { const queryClient = useQueryClient(); const [showRevokedTokens, setShowRevokedTokens] = useState(false); - const { data: tokens } = useQuery({ + const { + data: tokens, + isError, + isLoading, + refetch, + } = useQuery({ queryKey: queryKeys.tokens(), queryFn: async () => { return apiGet("/api/tokens"); @@ -215,87 +222,196 @@ function ApiTokensCard() { medication. This card lists and revokes. */}

{t("settings.tokenMintMovedDescription")}

-
-

- {t("settings.activeTokensTitle")} -

- {/* Desktop table — verbatim layout for md+, hidden on phones. */} -
- - - - - - - - - - - - - {activeTokens.length === 0 && ( - - - - )} - {activeTokens.map((tok, index) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableStatus")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} - - {t("settings.tokenTableActions")} -
- {t("settings.noActiveTokens")} -
{tok.name} - {tok.permissions.join(", ")} - - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} - + {isError ? ( + // A read failure must not read as "no active tokens" on this + // security surface — the §6 fall-through the token list must avoid. + refetch()} /> + ) : isLoading ? ( +
+ +
+ ) : ( + <> +
+

+ {t("settings.activeTokensTitle")} +

+ {/* Desktop table — verbatim layout for md+, hidden on phones. */} +
+ + + + + + + + + + + + + {activeTokens.length === 0 && ( + + + + )} + {activeTokens.map((tok, index) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableStatus")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} + + {t("settings.tokenTableActions")} +
+ {t("settings.noActiveTokens")} +
{tok.name} + {tok.permissions.join(", ")} + + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} + + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
+
+ + {/* Mobile card list — each row stacks its columns vertically + with explicit labels so the data is readable inside a + narrow viewport. The revoke action stays full-width to + clear the 44pt tap-target floor. */} + {activeTokens.length === 0 ? ( + + ) : ( +
    + {activeTokens.map((tok) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( +
  • +
    +

    + {tok.name} +

    + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    @@ -320,212 +436,117 @@ function ApiTokensCard() { -
-
+ + ); + })} + + )} +
- {/* Mobile card list — each row stacks its columns vertically - with explicit labels so the data is readable inside a - narrow viewport. The revoke action stays full-width to - clear the 44pt tap-target floor. */} - {activeTokens.length === 0 ? ( - - ) : ( -
    - {activeTokens.map((tok) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( -
  • -
    -

    - {tok.name} -

    - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} + {revokedTokens.length > 0 && ( +
    + + {showRevokedTokens && ( + <> + {/* Desktop table — verbatim layout for md+. */} +
    + + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
    + {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
    + {tok.name} + + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
    -

    - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

    -

    - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

    -

    - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

    - - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevoked")} - - - - -
  • - ); - })} -
- )} -
- {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( - <> - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - + {revokedTokens.map((tok) => ( +
  • -
  • - - - - +

    + ))} - -
    - {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
    {tok.name} +

    + {tok.name} +

    +

    + + {t("settings.tokenTablePermissions")}: + {" "} {tok.permissions.join(", ")} -

    +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} {formatDate(tok.createdAt)} -

    +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")} -

    -
    - - {/* Mobile card list — stacked meta layout, no - horizontal scroll. Revoked tokens are read-only so - no action footer is needed. */} -
      - {revokedTokens.map((tok) => ( -
    • -

      - {tok.name} -

      -

      - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

      -

      - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

      -

      - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

      -
    • - ))} -
    - + + + )} +
    )} -
    + )}
    From 48e48824009fc474349047903fc5727bd90bae60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:07:10 +0200 Subject: [PATCH 07/19] fix(settings): show read failures on the MCP connector lists, not empty states The connected-assistants and connector-token queries had no error or loading branch, so a failed read rendered "no connected assistants" or "no active tokens" on this security surface. Gate both lists with QueryErrorCard and retry, a skeleton while loading. Test watched fail first against the old empty-state fall-through. --- .../mcp-section-error-state.test.tsx | 51 +++++++++++++++++++ src/components/settings/mcp-section.tsx | 32 +++++++++--- 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/components/settings/__tests__/mcp-section-error-state.test.tsx diff --git a/src/components/settings/__tests__/mcp-section-error-state.test.tsx b/src/components/settings/__tests__/mcp-section-error-state.test.tsx new file mode 100644 index 000000000..3511d6890 --- /dev/null +++ b/src/components/settings/__tests__/mcp-section-error-state.test.tsx @@ -0,0 +1,51 @@ +/** + * A read failure on the MCP connector cards (connected assistants, connector + * tokens) must not read as "nothing connected / no tokens". Both queries are + * forced into their error state; each card has to render the `query-error-card` + * slot with retry and never fall through to its empty copy — the §6 + * fall-through on another security surface. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: { mcp: true } }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { McpSection } from "../mcp-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("McpSection — a read failure is honest, not empty", () => { + it("renders query-error cards with retry and never the empty copy on error", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + // Neither empty copy may appear when the reads failed. + expect(html).not.toContain("No connected assistants"); + expect(html).not.toContain("No active tokens"); + }); +}); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 9c0692e1c..3d6099963 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -35,6 +35,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -77,7 +79,12 @@ function McpConnectionsCard() { const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); - const { data: connections } = useQuery({ + const { + data: connections, + isError, + isLoading, + refetch, + } = useQuery({ queryKey: queryKeys.mcpConnections(), queryFn: async () => apiGet("/api/mcp/connections"), enabled: isAuthenticated, @@ -110,7 +117,11 @@ function McpConnectionsCard() {

    {t("settings.mcp.connectionsDetail")}

    - {list.length === 0 ? ( + {isError ? ( + refetch()} /> + ) : isLoading ? ( + + ) : list.length === 0 ? (

    {t("settings.mcp.noConnections")}

    @@ -264,7 +275,12 @@ function McpTokensCard() { const [tokenMsg, setTokenMsg] = useState(null); const [tokenCopied, setTokenCopied] = useState(false); - const { data: tokens } = useQuery({ + const { + data: tokens, + isError, + isLoading, + refetch, + } = useQuery({ queryKey: queryKeys.mcpTokens(), queryFn: async () => apiGet("/api/mcp/tokens"), enabled: isAuthenticated, @@ -340,9 +356,7 @@ function McpTokensCard() {

    {t("settings.mcp.tokensDetail")}

    -

    - {t("settings.mcp.scopeNote")} -

    +

    {t("settings.mcp.scopeNote")}

    @@ -421,7 +435,11 @@ function McpTokensCard() {

    {t("settings.activeTokensTitle")}

    - {activeTokens.length === 0 ? ( + {isError ? ( + refetch()} /> + ) : isLoading ? ( + + ) : activeTokens.length === 0 ? (

    {t("settings.noActiveTokens")}

    From 51c40c61d9d81f7da5aacb4b3cebe7f67385fa4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:08:57 +0200 Subject: [PATCH 08/19] fix(dashboard): show a read failure on the Vorsorge tile, not an empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tile already read the query's error flag but never branched on it, so a failed reminder read fell through to "no upcoming checkup reminders" — a missed preventive-care nudge hiding behind an honest-looking empty state. Add a QueryErrorRow with retry before the empty check. Test watched fail first against the fall-through. --- ...rsorge-dashboard-card-error-state.test.tsx | 57 +++++++++++++++++++ .../vorsorge-dashboard-card.tsx | 7 ++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx new file mode 100644 index 000000000..1caf6002e --- /dev/null +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx @@ -0,0 +1,57 @@ +/** + * A reminder-read failure on the dashboard Vorsorge tile must not read as "no + * upcoming checkup reminders" — a missed preventive-care nudge is the harm + * behind that fall-through. `useMeasurementReminders` is forced into its error + * state; the tile has to render a `query-error-row` with retry and never the + * empty copy. + * + * SSR-static render; the query-backed hooks are mocked so the error branch is + * synchronous. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-measurement-reminders", () => ({ + useMeasurementReminders: () => ({ + data: undefined, + isError: true, + refetch: vi.fn(), + }), + useMeasurementReminderMutations: () => ({ + satisfy: { mutate: vi.fn(), isPending: false }, + }), +})); + +vi.mock("@/hooks/use-encounters", () => ({ + useEncounters: () => ({ data: { upcoming: [] } }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canAdd: true }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { VorsorgeDashboardCard } from "../vorsorge-dashboard-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a read failure is honest, not empty", () => { + it("renders the query-error row with retry and never the empty copy", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain("No upcoming checkup reminders"); + }); +}); diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index 5f65c474e..848e5e996 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -32,6 +32,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ListRow } from "@/components/ui/list-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementForm } from "@/components/measurements/measurement-form"; @@ -78,7 +79,7 @@ export function VorsorgeDashboardCard() { // the dashboard summary offering what the dedicated page withholds was the // tell that this row had never been asked. const { canManage } = useRecordCapabilities(); - const { data: reminders, isError } = useMeasurementReminders(); + const { data: reminders, isError, refetch } = useMeasurementReminders(); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the // optimistic mount fetch on the client's very first render and cannot on the @@ -153,6 +154,10 @@ export function VorsorgeDashboardCard() { ))}
    + ) : isError ? ( + // A read failure must not read as "no upcoming reminders" — a missed + // checkup is the harm behind that fall-through (§6). + refetch()} /> ) : upcoming.length === 0 ? (

    From 22d5c01a687db6c8bd231fb0ebe197e6cf9e9fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:11:08 +0200 Subject: [PATCH 09/19] fix(settings): surface AI settings-read failures instead of a default form The three AI configuration reads each returned null on a non-OK response, so a failed read painted the default provider form as if the account were configured that way. Throw on a non-OK response so the query reports its error, and render a QueryErrorCard with retry when any read fails. Test watched the misleading form render first, then pass on the honest error. --- .../ai-insights-card-error-state.test.tsx | 46 ++++++ .../settings/ai/ai-insights-card.tsx | 132 +++++++++++------- 2 files changed, 127 insertions(+), 51 deletions(-) create mode 100644 src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx diff --git a/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx b/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx new file mode 100644 index 000000000..506b19b8e --- /dev/null +++ b/src/components/settings/ai/__tests__/ai-insights-card-error-state.test.tsx @@ -0,0 +1,46 @@ +/** + * When the AI settings reads fail, the card must not render the provider form + * as if it were configured a certain way — a swallowed error there is a lie + * about the account's provider state. Every read is forced into its error + * state; the card has to render a `query-error-card` with retry and must not + * paint the active-provider select. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/ai", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isError: true, + isLoading: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiInsightsCard } from "../ai-insights-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a settings-read failure is honest, not a default form", () => { + it("renders the query-error card with retry and never the provider form", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + expect(html).not.toContain('data-testid="ai-active-provider-select"'); + }); +}); diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 0a75df2a3..7fdc3ccf8 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; @@ -61,39 +62,62 @@ export function AiInsightsCard({ const pathname = usePathname(); const searchParams = useSearchParams(); - const { data: insightsSettings } = useQuery({ + // A read failure must surface, not be swallowed into a null that renders the + // default provider form as if the account were configured that way (§6). The + // queryFns throw on a non-OK response so the query reports `isError`, and the + // card paints an honest error instead of a misleading form. + const { + data: insightsSettings, + isError: settingsError, + refetch: refetchSettings, + } = useQuery({ queryKey: queryKeys.insightsSettings(), queryFn: async () => { const res = await apiFetchRaw("/api/insights/settings"); - if (!res.ok) return null; + if (!res.ok) throw new Error("insights settings read failed"); const json = await res.json(); return json.data as InsightsSettings; }, enabled: isAuthenticated, }); - const { data: userProvider } = useQuery({ + const { + data: userProvider, + isError: providerError, + refetch: refetchProvider, + } = useQuery({ queryKey: queryKeys.userAiProvider(), queryFn: async () => { const res = await apiFetchRaw("/api/user/ai-provider"); - if (!res.ok) return null; + if (!res.ok) throw new Error("user AI provider read failed"); const json = await res.json(); return json.data as UserAIProvider; }, enabled: isAuthenticated, }); - const { data: chainData } = useQuery({ + const { + data: chainData, + isError: chainError, + refetch: refetchChain, + } = useQuery({ queryKey: queryKeys.insightsProviderChain(), queryFn: async () => { const res = await apiFetchRaw("/api/insights/provider-chain"); - if (!res.ok) return null; + if (!res.ok) throw new Error("insights provider chain read failed"); const json = await res.json(); return json.data as ProviderChainData; }, enabled: isAuthenticated, }); + const hasReadError = settingsError || providerError || chainError; + function retryReads() { + void refetchSettings(); + void refetchProvider(); + void refetchChain(); + } + // v1.16.13 — heal the web AI-consent receipt on mount. The server-side // consent gate (admin-openai egress) requires an active `ai_full` // receipt; the web client never minted one, so existing web users on a @@ -160,65 +184,71 @@ export function AiInsightsCard({ />

    - + {hasReadError ? ( + + ) : ( + <> + - + - + - {/* v1.22 (#89) — per-user response timeout (mainly for slow + {/* v1.22 (#89) — per-user response timeout (mainly for slow local/self-hosted backends). */} - + - {/* Read uploaded vault documents automatically with AI (opt-in). */} - + {/* Read uploaded vault documents automatically with AI (opt-in). */} + - {/* Use the operator's shared central Codex connection (opt-in; only + {/* Use the operator's shared central Codex connection (opt-in; only renders when the operator has connected it). */} - + - {/* The standing consent and the one control that withdraws it. It + {/* The standing consent and the one control that withdraws it. It sits last because it governs everything above rather than configuring any single provider. */} - + - - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - onPrivacyChanged={() => - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - /> + + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + onPrivacyChanged={() => + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + /> - {/* v1.25 — the data-posture statement lives where the provider key is + {/* v1.25 — the data-posture statement lives where the provider key is configured: self-hosted, data stays on this instance, BYOK / local model, no third-party analytics. One calm line, not a banner. */} - + + + )}
    ); From 1229d54d308bcab47d798e0050cb6ef00488715b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:13:46 +0200 Subject: [PATCH 10/19] fix(insights): recoverable read failures on the ECG detail and list page The ECG detail rendered a read failure as a muted, non-recoverable line; the routed list page had no error branch at all, leaving the shell heading over an empty body that read the same as loading. The detail now paints a QueryErrorCard with retry (an alert is content, not muted meta), and the page shows the same instead of an empty shell. The overview teaser still unmounts silently, which is right for the overview. Tests watched each fail first. --- .../ecg/__tests__/page-error-state.test.tsx | 42 +++++++++++++++++++ src/app/insights/ecg/page.tsx | 18 +++++++- .../insights/__tests__/ecg-detail.test.tsx | 7 +++- src/components/insights/ecg-detail.tsx | 17 ++++---- 4 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 src/app/insights/ecg/__tests__/page-error-state.test.tsx diff --git a/src/app/insights/ecg/__tests__/page-error-state.test.tsx b/src/app/insights/ecg/__tests__/page-error-state.test.tsx new file mode 100644 index 000000000..489551177 --- /dev/null +++ b/src/app/insights/ecg/__tests__/page-error-state.test.tsx @@ -0,0 +1,42 @@ +/** + * A failed ECG-list read on the routed `/insights/ecg` page must not leave the + * shell heading over an empty body — indistinguishable from "still loading". + * The shared list query is forced into its error state; the page has to render + * a `query-error-card` with retry inside the shell. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: null }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import InsightsEcgPage from "../page"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("/insights/ecg page — a read failure is honest, not an empty shell", () => { + it("renders the query-error card with retry, not an empty section body", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + // The heading shell is still there, but not over a silently empty body. + expect(html).toContain('id="insights-subpage-title"'); + }); +}); diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index eaec88fa0..110be6dbe 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -9,6 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { EcgSection } from "@/components/insights/ecg-section"; @@ -38,12 +39,27 @@ export default function InsightsEcgPage() { // Reuse the SAME query cell `` reads (`insightsEcgList`) so the // page and the section share one cache entry. The page only needs the // `hasRecordings` flag to choose between the empty state and the section. - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsEcgList(), queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), enabled: isAuthenticated, }); + // A read failure must be visible, not a heading over an empty body that + // reads the same as "still loading" (§6). The section teaser below unmounts + // silently on error, which is right for the overview but wrong for the page + // the user opened to see this list. + if (isError) { + return ( + + refetch()} /> + + ); + } + // Data-availability empty state — a direct URL hit (or deleted recordings) // lands here without a section to show. Mirrors the sub-page convention: // a calm one-line scaffold + a connect-a-device CTA, never a blank surface. diff --git a/src/components/insights/__tests__/ecg-detail.test.tsx b/src/components/insights/__tests__/ecg-detail.test.tsx index 9c7c9b80e..b39ccc35a 100644 --- a/src/components/insights/__tests__/ecg-detail.test.tsx +++ b/src/components/insights/__tests__/ecg-detail.test.tsx @@ -78,18 +78,21 @@ describe(" — non-diagnostic framing", () => { expect(html).toContain("as reported by the recording device"); }); - it("says a failed read failed instead of painting an empty recording", () => { + it("shows a recoverable read failure instead of painting an empty recording", () => { useQueryMock.mockReturnValue({ data: undefined, isLoading: false, isError: true, + refetch: vi.fn(), }); const html = renderToStaticMarkup( , ); - expect(html).toContain('data-slot="ecg-detail-error"'); + // The failure is a recoverable card, not a muted "no data" line. + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); expect(html).not.toContain('data-slot="ecg-trace"'); }); }); diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx index 8c2f5fc11..c60c2abee 100644 --- a/src/components/insights/ecg-detail.tsx +++ b/src/components/insights/ecg-detail.tsx @@ -7,6 +7,7 @@ import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { EcgWaveform } from "@/components/insights/ecg-waveform"; import { InsightSectionCard } from "@/components/insights/insight-section-card"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ecgResultLabel, isNonNormalEcg, @@ -41,7 +42,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsEcgDetail(recordingId, false), queryFn: async () => { try { @@ -55,15 +56,13 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { }); if (isError) { - // A failed read must never read as "no data" (UI-STANDARDS §6). + // A failed read must never read as "no data" (UI-STANDARDS §6), and the + // alert is content, not muted meta — it also has to be recoverable. return ( -

    - {t("insights.ecg.loadError")} -

    + refetch()} + /> ); } From 6485aae7877a94bc6d5f4c585f8e8cf007df7f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:15:12 +0200 Subject: [PATCH 11/19] fix(insights): surface mood-insights read failures instead of silent degradation The mood-insights regions returned nothing on a failed read by documented intent, degrading to the line chart. Enforce the honest-error standard here too: on error the main region paints a QueryErrorRow with retry, once, mirroring how the loading skeleton is scoped to that one region so the three regions do not stack identical rows. Test watched the silent empty first. --- ...ood-insights-sections-error-state.test.tsx | 53 +++++++++++++++++++ .../insights/mood/mood-insights-sections.tsx | 12 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/components/insights/mood/__tests__/mood-insights-sections-error-state.test.tsx diff --git a/src/components/insights/mood/__tests__/mood-insights-sections-error-state.test.tsx b/src/components/insights/mood/__tests__/mood-insights-sections-error-state.test.tsx new file mode 100644 index 000000000..7df15c84d --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-insights-sections-error-state.test.tsx @@ -0,0 +1,53 @@ +/** + * The mood-insights regions used to render nothing on a failed read, by + * documented intent ("degrades gracefully to the line chart"). The maintainer + * decision is to enforce §6 here like everywhere else: a failed read is not an + * empty page. The shared query is forced into its error state; the main "rest" + * region has to surface a `query-error-row` with retry (mirroring the loading + * convention, which also only paints in the "rest" region). + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1" }, + isLoading: false, + isAuthenticated: true, + error: null, + refetch: () => {}, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MoodInsightsSections } from "../mood-insights-sections"; + +function render(region: "heatmap" | "assessment" | "rest") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a read failure is honest, not empty", () => { + it("surfaces a query-error row with retry in the rest region", () => { + const html = render("rest"); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + }); + + it("stays quiet in the heatmap/assessment regions to avoid stacking rows", () => { + expect(render("heatmap")).toBe(""); + expect(render("assessment")).toBe(""); + }); +}); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index be65c1124..a5de9a854 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -7,6 +7,7 @@ import { CalendarDays, Sparkles } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -100,7 +101,7 @@ export function MoodInsightsSections({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodInsights(), queryFn: async () => { return apiGet("/api/mood/insights"); @@ -118,6 +119,15 @@ export function MoodInsightsSections({ ) : null; } + if (isError) { + // §6 — a failed read is not an empty page. Surface it once, in the main + // "rest" region (mirroring the loading convention above), rather than + // stacking an identical row in all three regions down the page. + return region === "rest" ? ( + refetch()} /> + ) : null; + } + if (!data || data.summary.totalEntries === 0) { return null; } From f63fdb373efe9573686f1be8ba2f4779b2c60f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:20:55 +0200 Subject: [PATCH 12/19] fix(cycle): use the shared query-error primitives on failed cycle reads The cycle tabs rendered a bespoke error row in muted text, wrapped in a bare card. Replace it with the shared primitives: QueryErrorCard on the standalone prediction/insights/settings tabs, and a borderless QueryErrorRow inside the calendar card so the failure reads as foreground content with retry rather than muted meta. Test watched the muted row first. --- .../__tests__/cycle-view-error-state.test.tsx | 69 +++++++++++++++++++ src/components/cycle/cycle-view.tsx | 59 ++++++---------- 2 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-view-error-state.test.tsx diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx new file mode 100644 index 000000000..13ef5cae2 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -0,0 +1,69 @@ +/** + * A failed cycle read must surface as a recoverable, foreground error, not a + * hand-rolled muted line. The calendar read (which drives the default tab) is + * forced into its error state; the tab has to render the shared query-error + * primitive with retry, and no longer the muted `cycle-tab-error` row. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canAdd: true }), +})); + +const errorQuery = { + data: undefined, + isError: true, + isLoading: false, + refetch: vi.fn(), +}; +const idleQuery = { + data: undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), +}; +const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; + +vi.mock("../use-cycle", () => ({ + localYmd: () => "2026-07-01", + useCycleCalendar: () => errorQuery, + useCycleHistory: () => idleQuery, + useCycleInsights: () => idleQuery, + useCycleProfile: () => idleQuery, + useCycleDayLog: () => idleQuery, + useCustomSymptoms: () => idleQuery, + useLogDay: () => mutationStub, + usePatchDayLog: () => mutationStub, + useStartPeriod: () => mutationStub, + useEndPeriod: () => mutationStub, + useDeleteDayLog: () => mutationStub, + useCreateCustomSymptom: () => mutationStub, + useDeleteCustomSymptom: () => mutationStub, + CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit", + CustomSymptomError: class extends Error {}, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CycleView } from "../cycle-view"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a read failure is honest, not a muted line", () => { + it("renders the shared query-error primitive with retry, not the muted row", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain('data-slot="cycle-tab-error"'); + }); +}); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index ddccf448f..c268f4a20 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -8,6 +8,8 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -349,7 +351,13 @@ export function CycleView() { {t("cycle.calendar.loading")}

    ) : calendarError ? ( - void calendar.refetch()} /> + // Inside the calendar card already — a borderless row keeps + // it in line with the plain loading line above it (§3/§6). + void calendar.refetch()} + className="border-0 bg-transparent p-0" + /> ) : ( <> {/* Cold start: the grid carries no fertile/ovulation/phase @@ -388,11 +396,10 @@ export function CycleView() { {calendarError ? ( - - - void calendar.refetch()} /> - - + void calendar.refetch()} + /> ) : ( <> {insights.isError ? ( - - - void insights.refetch()} /> - - + void insights.refetch()} + /> ) : ( <> {profileQuery.isError ? ( - - - void profileQuery.refetch()} /> - - + void profileQuery.refetch()} + /> ) : profileQuery.isLoading ? (
    @@ -480,25 +485,3 @@ export function CycleView() { } /** A compact in-place error + Retry for a failed tab read. */ -function TabError({ onRetry }: { onRetry: () => void }) { - const { t } = useTranslations(); - return ( -
    - {t("cycle.loadError")} - -
    - ); -} From 44582008e9d85d4f114a62e0996cbc6fbfd181d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:26:46 +0200 Subject: [PATCH 13/19] fix(settings): make settings read failures foreground and recoverable A family of settings surfaces rendered a failed read as a bare text-destructive line with no way to recover: the security sessions, trusted-devices and activity cards, the coach memory and reminders sections, the about-me section, and the document link/share pickers. Swap each to the shared QueryErrorRow primitive with a retry control. The suite covering the statically renderable members was watched fail first against the bare lines; the two portaled pickers take the same swap. --- .../documents/document-link-picker.tsx | 8 ++- .../settings-read-error-family.test.tsx | 65 +++++++++++++++++++ src/components/settings/about-me-section.tsx | 12 ++-- .../settings/coach-memory-section.tsx | 12 ++-- .../settings/coach-reminders-section.tsx | 12 ++-- .../settings/security-activity-card.tsx | 10 +-- .../settings/security-sessions-card.tsx | 10 +-- .../settings/share-document-picker.tsx | 8 ++- .../settings/trusted-devices-card.tsx | 10 +-- 9 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 src/components/settings/__tests__/settings-read-error-family.test.tsx diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index d1e5ff6e3..aa5c50158 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -132,9 +133,10 @@ export function DocumentLinkPicker({ ))}
    ) : list.isError ? ( -

    - {t("documents.list.loadError")} -

    + list.refetch()} + /> ) : documents.length === 0 ? (

    {t("documents.linkPicker.empty")} diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx new file mode 100644 index 000000000..4a3bffd11 --- /dev/null +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -0,0 +1,65 @@ +/** + * The bare-destructive read-failure family: several settings cards rendered a + * failed read as a bare `text-destructive` line with no way to recover. Each is + * forced into its query error state and must now render the shared + * `query-error-row` primitive with a retry control — an alert is foreground + * content, and a read failure has to be retryable. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + reset: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SecuritySessionsCard } from "../security-sessions-card"; +import { SecurityActivityCard } from "../security-activity-card"; +import { TrustedDevicesCard } from "../trusted-devices-card"; +import { CoachMemorySection } from "../coach-memory-section"; +import { CoachRemindersSection } from "../coach-reminders-section"; +import { AboutMeSection } from "../about-me-section"; + +function renderCard(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const cases: Array<[string, React.ReactNode]> = [ + ["SecuritySessionsCard", ], + ["SecurityActivityCard", ], + ["TrustedDevicesCard", ], + ["CoachMemorySection", ], + ["CoachRemindersSection", ], + ["AboutMeSection", ], +]; + +describe("settings read-error family — a read failure is foreground and recoverable", () => { + it.each(cases)("%s renders a query-error row with retry", (_name, node) => { + const html = renderCard(node); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + // No bare red line: the alert is the primitive now. + expect(html).not.toContain('

    + query.refetch()} + /> )}

    diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index bf382459a..73826576a 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -34,6 +34,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -152,13 +153,10 @@ export function CoachMemorySection({

    {t("settings.ai.coachMemory.detail")}

    {query.isError && ( -

    - {t("settings.ai.coachMemory.loadError")} -

    + query.refetch()} + /> )} {!query.isError && facts.length === 0 ? ( diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index c0e47c204..c4cde2c14 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -22,6 +22,7 @@ import { BellRing, Check, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { formatDateOrRelative } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -165,13 +166,10 @@ export function CoachRemindersSection({

    {t("settings.ai.coachReminders.detail")}

    {query.isError && ( -

    - {t("settings.ai.coachReminders.loadError")} -

    + query.refetch()} + /> )} {!query.isError && reminders.length === 0 ? ( diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 6d4806d4c..1091e9fc7 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -15,6 +15,7 @@ import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -62,7 +63,7 @@ export function SecurityActivityCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.securityActivity(), queryFn: () => apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), @@ -102,9 +103,10 @@ export function SecurityActivityCard({ )} {isError && ( -

    - {t("settings.security.activityLoadError")} -

    + refetch()} + /> )} {!isLoading && !isError && events.length === 0 && ( diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index d9b2a6821..206a743f1 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -18,6 +18,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; @@ -48,7 +49,7 @@ export function SecuritySessionsCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.sessions(), queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), enabled: isAuthenticated, @@ -117,9 +118,10 @@ export function SecuritySessionsCard({ )} {isError && ( -

    - {t("settings.security.sessionsLoadError")} -

    + refetch()} + /> )} {!isLoading && !isError && sessions.length === 0 && ( diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 8db9feb08..4320f448f 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; @@ -152,9 +153,10 @@ export function ShareDocumentPicker({ ))}
    ) : list.isError ? ( -

    - {t("documents.list.loadError")} -

    + list.refetch()} + /> ) : documents.length === 0 ? (

    {t("settings.sharing.pickerEmpty")} diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 5213faffe..7fa19272f 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -15,6 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -39,7 +40,7 @@ export function TrustedDevicesCard({ const queryClient = useQueryClient(); const [status, setStatus] = useState(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.trustedDevices(), queryFn: () => apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"), @@ -102,9 +103,10 @@ export function TrustedDevicesCard({ )} {isError && ( -

    - {t("settings.security.trustedDevices.loadError")} -

    + refetch()} + /> )} {devices.length > 0 && ( From f6a0ee462350d08e990895e9b8ae6ea50360daea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:28:18 +0200 Subject: [PATCH 14/19] fix(checkups): use the shared query-error primitive on the Vorsorge list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section carried a bespoke muted error row with its own retry button — the exact drift QueryErrorRow was made to absorb. Swap it for the shared primitive so the failure reads as foreground content. Test watched the bespoke row first. --- .../__tests__/vorsorge-section.test.tsx | 15 ++++++++++++ .../vorsorge-section.tsx | 23 ++++--------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 8bd47bdce..691508ff2 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -89,6 +89,21 @@ describe(" loading + empty", () => { expect(html).not.toContain('data-slot="empty-state"'); }); + it("surfaces a read failure through the shared query-error primitive with retry", () => { + remindersMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }); + const html = render(); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + // Not the empty state, and no longer the muted bespoke row. + expect(html).not.toContain('data-slot="empty-state"'); + expect(html).not.toContain('data-slot="vorsorge-error"'); + }); + it("routes the no-data case through the shared EmptyState with an action", () => { remindersMock.mockReturnValue({ data: [], isLoading: false }); const html = render(); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 4aa1b1c03..118560d83 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -38,7 +38,6 @@ import { CalendarPlus, CheckCircle2, Plus, - RefreshCw, Wrench, } from "lucide-react"; @@ -76,6 +75,7 @@ import { } from "@/components/ui/dropdown-menu"; import { DateField } from "@/components/ui/date-field"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LineChart, MoreVertical, Pencil, Trash2 } from "lucide-react"; @@ -703,23 +703,10 @@ export function VorsorgeSection({ )} {!isLoading && isError && ( -
    - {t("measurementReminders.loadError")} - -
    + void refetch()} + /> )} {!isLoading && !isError && (reminders?.length ?? 0) === 0 && ( From ba42354733c202b443976f4cb5eb988f430b5b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:32:54 +0200 Subject: [PATCH 15/19] fix(admin): replace hand-rolled read-error boxes with the shared primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight admin surfaces rendered a failed read as their own destructive- tinted box, and most offered no way to recover. Sweep them onto the shared QueryErrorRow with retry so the console uses one error form (§13). The statically renderable sections were watched fail first against the hand-rolled boxes. --- src/app/admin/page.tsx | 14 +++--- .../admin-read-error-family.test.tsx | 45 +++++++++++++++++++ src/components/admin/ai-quality-section.tsx | 11 +++-- src/components/admin/backups-section.tsx | 13 +++--- .../admin/coach-feedback-section.tsx | 11 +++-- src/components/admin/host-metrics-chart.tsx | 13 +++--- src/components/admin/recent-audit-preview.tsx | 13 +++--- .../admin/system-status-section.tsx | 33 ++++---------- .../admin/system-status-summary.tsx | 13 +++--- 9 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 src/components/admin/__tests__/admin-read-error-family.test.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9d956cc88..5b6e2f7f1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { AdminShell } from "@/components/admin/admin-shell"; import { SystemStatusSummary } from "@/components/admin/system-status-summary"; import { VersionTileSection } from "@/components/admin/version-tile-section"; @@ -23,7 +24,8 @@ export default function AdminOverviewPage() { // Many sub-sections share `useAdminSettings()` and render defaults // silently on error — the banner makes the failure visible so the // admin knows the toggles below aren't reflecting real state. - const { isError: settingsError } = useAdminSettings(); + const { isError: settingsError, refetch: refetchSettings } = + useAdminSettings(); if (!user || user.role !== "ADMIN") return null; @@ -33,12 +35,10 @@ export default function AdminOverviewPage() { {/* v1.18.6.1 — the console title + subtitle render in `` now (its own grid row, so the nav lines up with the first card). */} {settingsError && ( -
    - {t("admin.adminSettingsLoadError")} -
    + void refetchSettings()} + /> )} diff --git a/src/components/admin/__tests__/admin-read-error-family.test.tsx b/src/components/admin/__tests__/admin-read-error-family.test.tsx new file mode 100644 index 000000000..cece02147 --- /dev/null +++ b/src/components/admin/__tests__/admin-read-error-family.test.tsx @@ -0,0 +1,45 @@ +/** + * The admin error-box family rendered a failed read as a hand-rolled + * destructive-tinted box (§13 drift). Each self-contained admin section is + * forced into its query error state and must now render the shared + * `query-error-row` primitive with retry. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { RecentAuditPreview } from "../recent-audit-preview"; +import { CoachFeedbackSection } from "../coach-feedback-section"; +import { AiQualitySection } from "../ai-quality-section"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const cases: Array<[string, React.ReactNode]> = [ + ["RecentAuditPreview", ], + ["CoachFeedbackSection", ], + ["AiQualitySection", ], +]; + +describe("admin read-error family — a read failure is foreground and recoverable", () => { + it.each(cases)("%s renders a query-error row with retry", (_name, node) => { + const html = render(node); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain("bg-destructive/10"); + }); +}); diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 57cb8adf4..0183fb900 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -102,12 +103,10 @@ export function AiQualitySection() {
    ) : query.isError ? ( -
    - {t("admin.aiQuality.loadError")} -
    + void query.refetch()} + /> ) : !hasData ? (

    {t("admin.aiQuality.empty")} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 9b80465ea..7950bc323 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -36,6 +36,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -286,7 +287,7 @@ export function BackupsSection() { const fmt = useFormatters(); const queryClient = useQueryClient(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); @@ -596,12 +597,10 @@ export function BackupsSection() {

    ) : isError ? ( -
    - {t("admin.section.backups.loadError")} -
    + void refetch()} + /> ) : rows.length === 0 ? ( // v1.4.15 phase-C5: replace bare text with the EmptyState // primitive. The header already exposes "Backup now" but a diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 245bcee60..9780cdcf2 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -97,12 +98,10 @@ export function CoachFeedbackSection() {
    ) : query.isError ? ( -
    - {t("admin.coachFeedback.loadError")} -
    + void query.refetch()} + /> ) : !hasData ? (

    {t("admin.coachFeedback.empty")} diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 3422a8372..8c7f5a742 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -39,6 +39,7 @@ import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Activity } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -142,7 +143,7 @@ export function HostMetricsChart() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { return apiGet("/api/admin/host-metrics?since=2h"); @@ -195,12 +196,10 @@ export function HostMetricsChart() { } /> -

    - {t("admin.hostMetrics.loadError")} -

    + void refetch()} + /> ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 7a07b4a9a..fef83bcc1 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -19,6 +19,7 @@ import { XCircle, } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -37,7 +38,7 @@ interface AuditLogResponse { export function RecentAuditPreview() { const { t } = useTranslations(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { return apiGet( @@ -82,12 +83,10 @@ export function RecentAuditPreview() { ) : isError ? ( -
    - {t("admin.overview.auditLoadError")} -
    + void refetch()} + /> ) : entries.length === 0 ? ( // v1.4.15 phase-C5: compact + plain so it nests inside the // overview card without doubling the dashed border. Reuses diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index daecfa25a..0747a8aff 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -13,11 +13,10 @@ import { Key, Loader2, Map, - RefreshCw, Server, Users, } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -128,7 +127,7 @@ export function FailingJobsCard({ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data: status, isError, refetch, isFetching } = useSystemStatus(); + const { data: status, isError, refetch } = useSystemStatus(); const { data: version } = usePublicVersion(); return ( @@ -273,28 +272,12 @@ export function SystemStatusSection() { // v1.4.16 Wave-C MED — pair the alert with a Retry button so a // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. -
    - {t("admin.systemStatusLoadError")} - -
    + void refetch()} + retryLabel={t("admin.systemStatusRetry")} + retrySlot="system-status-retry" + /> ) : (
    diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 6960eac3d..051ba9f0e 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -16,12 +16,13 @@ import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; export function SystemStatusSummary() { const { t } = useTranslations(); - const { data: status, isError } = useSystemStatus(); + const { data: status, isError, refetch } = useSystemStatus(); const { data: version } = usePublicVersion(); return ( @@ -90,12 +91,10 @@ export function SystemStatusSummary() { )}
    ) : isError ? ( -
    - {t("admin.overview.snapshotLoadError")} -
    + void refetch()} + /> ) : (
    Date: Sun, 9 Aug 2026 04:33:35 +0200 Subject: [PATCH 16/19] docs(changelog): record the honest-error and MCP scope-note fixes under Unreleased --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d43ea4b8d..7aecef073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### Fixed + +- The connector-token note in Settings no longer claims a token is read-only in every case, which the write toggle right below it disproved. It now states the real model: read-only by default, write access an opt-in you turn on per token, and no token ever reaches admin functions. +- A failed load of your share links now says so, with a way to retry, instead of quietly showing "no active share links" as if you had never shared anything. The same held for the API and connector token lists on the security screens. +- A read that fails now shows a clear, recoverable error across the app instead of an empty list or a silent gap. The sharing and token screens, the ECG detail and list, the cycle and mood insights, the dashboard preventive-care tile, the checkups list, and the admin console all say when a read failed and offer a retry, rather than reading as "nothing here". +- The AI consent and connector notes in Settings render as normal text now, not faint fine print, so the copy you are meant to read before turning a feature on is legible. +- The respiratory-rate tile on the dashboard shows its unit in your own language instead of a fixed English label, and the personalized greeting carries its comma. + ## [1.37.3] — 2026-08-09 ### Added From edaa5bf424d2136dfadfd9e3f9b8ae5424e70d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:34:18 +0200 Subject: [PATCH 17/19] style: wrap a long assertion in the api-section error test --- .../settings/__tests__/api-section-error-state.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/settings/__tests__/api-section-error-state.test.tsx b/src/components/settings/__tests__/api-section-error-state.test.tsx index 6b3f01754..beb21a2ca 100644 --- a/src/components/settings/__tests__/api-section-error-state.test.tsx +++ b/src/components/settings/__tests__/api-section-error-state.test.tsx @@ -55,7 +55,9 @@ describe("ApiSection — a token-read failure is honest, not empty", () => { const html = render(); expect(html).toContain('data-slot="query-error-card"'); expect(html).toContain('data-slot="query-error-retry"'); - expect(html).not.toContain('data-testid="settings-api-tokens-active-empty"'); + expect(html).not.toContain( + 'data-testid="settings-api-tokens-active-empty"', + ); expect(html).not.toContain("No active tokens"); }); }); From 73b82f444ec5eb62a7c9828b096c6f06200af255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:37:03 +0200 Subject: [PATCH 18/19] test: use render thunks in the read-error family suites for the jsx-key rule --- .../__tests__/admin-read-error-family.test.tsx | 10 +++++----- .../settings-read-error-family.test.tsx | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/admin/__tests__/admin-read-error-family.test.tsx b/src/components/admin/__tests__/admin-read-error-family.test.tsx index cece02147..dd046b785 100644 --- a/src/components/admin/__tests__/admin-read-error-family.test.tsx +++ b/src/components/admin/__tests__/admin-read-error-family.test.tsx @@ -29,15 +29,15 @@ function render(node: React.ReactNode) { ); } -const cases: Array<[string, React.ReactNode]> = [ - ["RecentAuditPreview", ], - ["CoachFeedbackSection", ], - ["AiQualitySection", ], +const cases: Array<[string, () => React.ReactNode]> = [ + ["RecentAuditPreview", () => ], + ["CoachFeedbackSection", () => ], + ["AiQualitySection", () => ], ]; describe("admin read-error family — a read failure is foreground and recoverable", () => { it.each(cases)("%s renders a query-error row with retry", (_name, node) => { - const html = render(node); + const html = render(node()); expect(html).toContain('data-slot="query-error-row"'); expect(html).toContain('data-slot="query-error-row-retry"'); expect(html).not.toContain("bg-destructive/10"); diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx index 4a3bffd11..06b07fea9 100644 --- a/src/components/settings/__tests__/settings-read-error-family.test.tsx +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -45,18 +45,18 @@ function renderCard(node: React.ReactNode) { ); } -const cases: Array<[string, React.ReactNode]> = [ - ["SecuritySessionsCard", ], - ["SecurityActivityCard", ], - ["TrustedDevicesCard", ], - ["CoachMemorySection", ], - ["CoachRemindersSection", ], - ["AboutMeSection", ], +const cases: Array<[string, () => React.ReactNode]> = [ + ["SecuritySessionsCard", () => ], + ["SecurityActivityCard", () => ], + ["TrustedDevicesCard", () => ], + ["CoachMemorySection", () => ], + ["CoachRemindersSection", () => ], + ["AboutMeSection", () => ], ]; describe("settings read-error family — a read failure is foreground and recoverable", () => { it.each(cases)("%s renders a query-error row with retry", (_name, node) => { - const html = renderCard(node); + const html = renderCard(node()); expect(html).toContain('data-slot="query-error-row"'); expect(html).toContain('data-slot="query-error-row-retry"'); // No bare red line: the alert is the primitive now. From 9ae42ae33f5b4dd6aebaa23e1a6de5ce7b591404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 06:14:43 +0200 Subject: [PATCH 19/19] chore(bundle): raise the aggregate cap for the honest-error-state code Adding recoverable read-failure handling to thirteen surfaces and the MCP and managed-record copy rewrites across six locales lifts the whole-build client total to 3404 KB gz. Every per-route ceiling is still met; the aggregate cap moves 3352 to 3428 with the same margin. --- bundle-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index a470f1783..e4906cdd3 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 3352, + "totalClientKbGz": 3428, "maxRechartsChunks": 1 }