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 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 } diff --git a/messages/de.json b/messages/de.json index d86231fae..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." }, @@ -6283,7 +6285,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", @@ -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 2b9733388..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", @@ -6283,7 +6285,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..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." }, @@ -6283,7 +6285,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", @@ -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 4d0a640d9..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é", @@ -6283,7 +6285,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..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", @@ -6283,7 +6285,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..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", @@ -6283,7 +6285,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/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/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/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} 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..dd046b785 --- /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()} + /> ) : (
({ + 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")} - -
- ); -} 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/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()} + /> ); } 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; } 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/__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-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 ? (

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 && ( 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..beb21a2ca --- /dev/null +++ b/src/components/settings/__tests__/api-section-error-state.test.tsx @@ -0,0 +1,63 @@ +/** + * 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/__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/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx new file mode 100644 index 000000000..06b07fea9 --- /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('

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

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

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. */} - + + + )}
); 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")}

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")} -

      -
    • - ))} -
    - + + + )} +
    )} -
    + )}
    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/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} diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index e368d3f0a..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")}

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

    +

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

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

    {t("settings.activeTokensTitle")}

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

    {t("settings.noActiveTokens")}

    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/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} -

      -
    • - ))} -
    + + ))} + + )} +
    )} -
    + )} ); 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 && (