diff --git a/messages/de.json b/messages/de.json
index 251a01cae..1891ebcc2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -924,7 +924,6 @@
"recentTitle": "Letzte 30 Tage",
"loading": "Wird geladen…",
"deleteCta": "Löschen",
- "deleteConfirm": "Eintrag löschen?",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
"errorEntry": "Bitte ein Symptom auswählen",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Verlangsamte Magenentleerung",
"dysgeusia": "Geschmacksveränderung",
"gallbladderDiscomfort": "Gallenblasen-Beschwerden"
- }
+ },
+ "deleteConfirmTitle": "Eintrag löschen?",
+ "deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Verbindungen",
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
"channelsHeading": "Zustellkanäle",
- "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
+ "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden.",
+ "matrixLink": "Benachrichtigungen je Kanal"
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
"remindersHeading": "Erinnerungen",
- "remindersDescription": "Was du erhältst."
+ "remindersDescription": "Was du erhältst.",
+ "matrixLink": "Benachrichtigungen je Kanal",
+ "channelsLink": "Zustellkanäle"
},
"layout": {
"title": "Darstellung",
@@ -6271,7 +6275,7 @@
"eventColumn": "Ereignis",
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
"loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
- "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
+ "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal unter Einstellungen → Integrationen.",
"goToSettings": "Zu den Kanal-Einstellungen",
"channelDisabled": "Deaktiviert",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
"unknownLocation": "unbekannter Ort"
},
- "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten."
+ "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.",
+ "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.",
+ "reminderSettingsLink": "Erinnerungseinstellungen",
+ "channelsLink": "Kanäle"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
},
"loadMoreReadings": "Weitere Werte laden",
- "documentsLink": "Befund-Dokumente"
+ "documentsLink": "Befund-Dokumente",
+ "notLinkedYet": "Noch keinem Biomarker zugeordnet"
},
"modules": {
"cycle": {
diff --git a/messages/en.json b/messages/en.json
index e7c2b5281..3fb5eaf38 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -924,7 +924,6 @@
"recentTitle": "Last 30 days",
"loading": "Loading…",
"deleteCta": "Delete",
- "deleteConfirm": "Delete this entry?",
"deleteError": "Could not delete the entry",
"errorSeverity": "Severity must be between 1 and 5",
"errorEntry": "Pick a symptom",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Slow stomach emptying",
"dysgeusia": "Taste change",
"gallbladderDiscomfort": "Gallbladder discomfort"
- }
+ },
+ "deleteConfirmTitle": "Delete this entry?",
+ "deleteConfirmBody": "This side-effect entry will be permanently removed."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Connections",
"connectionsDescription": "Connect and manage your data sources and devices.",
"channelsHeading": "Delivery channels",
- "channelsDescription": "Where reminders and alerts are delivered."
+ "channelsDescription": "Where reminders and alerts are delivered.",
+ "matrixLink": "Per-channel notifications"
},
"notifications": {
"title": "Notifications",
"description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
"subtitle": "Choose which reminders you receive and where they're delivered.",
"remindersHeading": "Reminders",
- "remindersDescription": "What you receive."
+ "remindersDescription": "What you receive.",
+ "matrixLink": "Per-channel notifications",
+ "channelsLink": "Delivery channels"
},
"layout": {
"title": "Appearance",
@@ -6271,7 +6275,7 @@
"eventColumn": "Event",
"subtitle": "Choose which notifications you want to receive through which channel.",
"loadError": "Notification preferences could not be loaded.",
- "noChannels": "Configure a notification channel in Settings first.",
+ "noChannels": "Configure a notification channel in Settings → Integrations first.",
"goToSettings": "Go to channel settings",
"channelDisabled": "Disabled",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
"unknownLocation": "unknown location"
},
- "loginRequired": "Please sign in to manage notifications."
+ "loginRequired": "Please sign in to manage notifications.",
+ "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.",
+ "reminderSettingsLink": "Reminder settings",
+ "channelsLink": "Channels"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
},
"loadMoreReadings": "Load more readings",
- "documentsLink": "Lab report documents"
+ "documentsLink": "Lab report documents",
+ "notLinkedYet": "Not linked to a biomarker yet"
},
"modules": {
"cycle": {
diff --git a/messages/es.json b/messages/es.json
index d5274da32..a47925492 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -924,7 +924,6 @@
"recentTitle": "Últimos 30 días",
"loading": "Cargando…",
"deleteCta": "Eliminar",
- "deleteConfirm": "¿Eliminar esta entrada?",
"deleteError": "No se pudo eliminar la entrada",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
"errorEntry": "Elige un síntoma",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Vaciado gástrico lento",
"dysgeusia": "Alteración del gusto",
"gallbladderDiscomfort": "Molestia en la vesícula biliar"
- }
+ },
+ "deleteConfirmTitle": "¿Eliminar esta entrada?",
+ "deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Conexiones",
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"channelsHeading": "Canales de entrega",
- "channelsDescription": "Dónde se entregan los recordatorios y avisos."
+ "channelsDescription": "Dónde se entregan los recordatorios y avisos.",
+ "matrixLink": "Notificaciones por canal"
},
"notifications": {
"title": "Notificaciones",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
"remindersHeading": "Recordatorios",
- "remindersDescription": "Lo que recibes."
+ "remindersDescription": "Lo que recibes.",
+ "matrixLink": "Notificaciones por canal",
+ "channelsLink": "Canales de entrega"
},
"layout": {
"title": "Apariencia",
@@ -6271,7 +6275,7 @@
"eventColumn": "Evento",
"subtitle": "Elige qué notificaciones recibes y por qué canal.",
"loadError": "No se han podido cargar los ajustes de notificación.",
- "noChannels": "Configura primero un canal de notificación en los ajustes.",
+ "noChannels": "Configura primero un canal de notificación en Ajustes → Integraciones.",
"goToSettings": "Ir a los ajustes del canal",
"channelDisabled": "Desactivado",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
"unknownLocation": "ubicación desconocida"
},
- "loginRequired": "Inicia sesión para gestionar las notificaciones."
+ "loginRequired": "Inicia sesión para gestionar las notificaciones.",
+ "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.",
+ "reminderSettingsLink": "Ajustes de recordatorios",
+ "channelsLink": "Canales"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
},
"loadMoreReadings": "Cargar más mediciones",
- "documentsLink": "Documentos de resultados"
+ "documentsLink": "Documentos de resultados",
+ "notLinkedYet": "Aún no vinculado a un biomarcador"
},
"modules": {
"cycle": {
diff --git a/messages/fr.json b/messages/fr.json
index 3d5f7d347..c271324c2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -924,7 +924,6 @@
"recentTitle": "30 derniers jours",
"loading": "Chargement…",
"deleteCta": "Supprimer",
- "deleteConfirm": "Supprimer cette entrée ?",
"deleteError": "Impossible de supprimer l'entrée",
"errorSeverity": "La sévérité doit être entre 1 et 5",
"errorEntry": "Choisissez un symptôme",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Vidange gastrique lente",
"dysgeusia": "Altération du goût",
"gallbladderDiscomfort": "Inconfort de la vésicule biliaire"
- }
+ },
+ "deleteConfirmTitle": "Supprimer cette entrée ?",
+ "deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Connexions",
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
"channelsHeading": "Canaux de diffusion",
- "channelsDescription": "Où sont envoyés les rappels et alertes."
+ "channelsDescription": "Où sont envoyés les rappels et alertes.",
+ "matrixLink": "Notifications par canal"
},
"notifications": {
"title": "Notifications",
"description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
"remindersHeading": "Rappels",
- "remindersDescription": "Ce que vous recevez."
+ "remindersDescription": "Ce que vous recevez.",
+ "matrixLink": "Notifications par canal",
+ "channelsLink": "Canaux de diffusion"
},
"layout": {
"title": "Apparence",
@@ -6271,7 +6275,7 @@
"eventColumn": "Événement",
"subtitle": "Choisis quelles notifications tu reçois et par quel canal.",
"loadError": "Impossible de charger les paramètres de notification.",
- "noChannels": "Configure d’abord un canal de notification dans les paramètres.",
+ "noChannels": "Configure d’abord un canal de notification dans Paramètres → Intégrations.",
"goToSettings": "Aller aux paramètres du canal",
"channelDisabled": "Désactivé",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
"unknownLocation": "lieu inconnu"
},
- "loginRequired": "Connecte-toi pour gérer les notifications."
+ "loginRequired": "Connecte-toi pour gérer les notifications.",
+ "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.",
+ "reminderSettingsLink": "Réglages des rappels",
+ "channelsLink": "Canaux"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
},
"loadMoreReadings": "Charger plus de mesures",
- "documentsLink": "Documents de résultats"
+ "documentsLink": "Documents de résultats",
+ "notLinkedYet": "Pas encore lié à un biomarqueur"
},
"modules": {
"cycle": {
diff --git a/messages/it.json b/messages/it.json
index ee46c3e30..b983b8a06 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -924,7 +924,6 @@
"recentTitle": "Ultimi 30 giorni",
"loading": "Caricamento…",
"deleteCta": "Elimina",
- "deleteConfirm": "Eliminare questa voce?",
"deleteError": "Impossibile eliminare la voce",
"errorSeverity": "La gravità deve essere tra 1 e 5",
"errorEntry": "Scegli un sintomo",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Svuotamento gastrico lento",
"dysgeusia": "Alterazione del gusto",
"gallbladderDiscomfort": "Fastidio alla cistifellea"
- }
+ },
+ "deleteConfirmTitle": "Eliminare questa voce?",
+ "deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Connessioni",
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"channelsHeading": "Canali di recapito",
- "channelsDescription": "Dove vengono recapitati promemoria e avvisi."
+ "channelsDescription": "Dove vengono recapitati promemoria e avvisi.",
+ "matrixLink": "Notifiche per canale"
},
"notifications": {
"title": "Notifiche",
"description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
"remindersHeading": "Promemoria",
- "remindersDescription": "Ciò che ricevi."
+ "remindersDescription": "Ciò che ricevi.",
+ "matrixLink": "Notifiche per canale",
+ "channelsLink": "Canali di recapito"
},
"layout": {
"title": "Aspetto",
@@ -6271,7 +6275,7 @@
"eventColumn": "Evento",
"subtitle": "Scegli quali notifiche ricevere e su quale canale.",
"loadError": "Impossibile caricare le impostazioni di notifica.",
- "noChannels": "Configura prima un canale di notifica nelle impostazioni.",
+ "noChannels": "Configura prima un canale di notifica in Impostazioni → Integrazioni.",
"goToSettings": "Vai alle impostazioni del canale",
"channelDisabled": "Disabilitato",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
"unknownLocation": "posizione sconosciuta"
},
- "loginRequired": "Accedi per gestire le notifiche."
+ "loginRequired": "Accedi per gestire le notifiche.",
+ "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.",
+ "reminderSettingsLink": "Impostazioni promemoria",
+ "channelsLink": "Canali"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
},
"loadMoreReadings": "Carica altre misurazioni",
- "documentsLink": "Documenti dei referti"
+ "documentsLink": "Documenti dei referti",
+ "notLinkedYet": "Non ancora collegato a un biomarcatore"
},
"modules": {
"cycle": {
diff --git a/messages/pl.json b/messages/pl.json
index da51aae35..a41d97b3c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -924,7 +924,6 @@
"recentTitle": "Ostatnie 30 dni",
"loading": "Ładowanie…",
"deleteCta": "Usuń",
- "deleteConfirm": "Usunąć ten wpis?",
"deleteError": "Nie udało się usunąć wpisu",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
"errorEntry": "Wybierz objaw",
@@ -957,7 +956,9 @@
"gastroparesisLike": "Spowolnione opróżnianie żołądka",
"dysgeusia": "Zmiana smaku",
"gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego"
- }
+ },
+ "deleteConfirmTitle": "Usunąć ten wpis?",
+ "deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty."
},
"scheduling": {
"naturalLanguage": {
@@ -4057,14 +4058,17 @@
"connectionsHeading": "Połączenia",
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"channelsHeading": "Kanały dostarczania",
- "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
+ "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia.",
+ "matrixLink": "Powiadomienia wg kanału"
},
"notifications": {
"title": "Powiadomienia",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
"remindersHeading": "Przypomnienia",
- "remindersDescription": "Co otrzymujesz."
+ "remindersDescription": "Co otrzymujesz.",
+ "matrixLink": "Powiadomienia wg kanału",
+ "channelsLink": "Kanały dostarczania"
},
"layout": {
"title": "Wygląd",
@@ -6271,7 +6275,7 @@
"eventColumn": "Zdarzenie",
"subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.",
"loadError": "Nie udało się załadować ustawień powiadomień.",
- "noChannels": "Najpierw skonfiguruj kanał powiadomień w ustawieniach.",
+ "noChannels": "Najpierw skonfiguruj kanał powiadomień w Ustawienia → Integracje.",
"goToSettings": "Przejdź do ustawień kanału",
"channelDisabled": "Wyłączone",
"admin": {
@@ -6382,7 +6386,10 @@
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
"unknownLocation": "nieznana lokalizacja"
},
- "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami."
+ "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.",
+ "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.",
+ "reminderSettingsLink": "Ustawienia przypomnień",
+ "channelsLink": "Kanały"
},
"i18n": {
"maintainershipBanner": {
@@ -7378,7 +7385,8 @@
"pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
},
"loadMoreReadings": "Wczytaj więcej pomiarów",
- "documentsLink": "Dokumenty wyników badań"
+ "documentsLink": "Dokumenty wyników badań",
+ "notLinkedYet": "Jeszcze niepowiązane z biomarkerem"
},
"modules": {
"cycle": {
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index 6e4c2fae1..603726acd 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -13,7 +13,6 @@ import {
Heart,
KeyRound,
Languages,
- Loader2,
Lock,
LogIn,
Moon,
@@ -33,6 +32,8 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/format";
@@ -271,16 +272,12 @@ export default function AchievementsPage() {
// alongside `` and
// `` so the three consumers never trigger
// more than one network call on a cold dashboard mount.
- const { data, isLoading } = useAchievementsQuery({
+ const { data, isLoading, isError, refetch } = useAchievementsQuery({
enabled: isAuthenticated && achievementsEnabled,
});
if (authLoading || (achievementsEnabled && isLoading)) {
- return (
-
-
-
- );
+ return ;
}
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
@@ -302,6 +299,21 @@ export default function AchievementsPage() {
);
}
+ // A failed load must never fall through to the zeroed summary tiles + the
+ // "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
+ // retry instead.
+ if (isError) {
+ return (
+
+
+ refetch()} />
+
+ );
+ }
+
const summary = data?.summary;
const achievements = data?.achievements ?? [];
const grouped = groupByCategory(achievements);
diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx
index 162843a3c..2105fb54b 100644
--- a/src/app/checkups/page.tsx
+++ b/src/app/checkups/page.tsx
@@ -1,13 +1,13 @@
"use client";
import { useCallback } from "react";
-import { Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
import { queryKeys } from "@/lib/query-keys";
@@ -35,11 +35,7 @@ export default function CheckupsPage() {
const pull = usePullToRefresh({ onRefresh: refresh });
if (!mounted || isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 6110786cb..e5df84226 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
-import { MessagesSquare, Search, Trash2 } from "lucide-react";
+import { MessagesSquare, Search, Target, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
@@ -92,7 +94,8 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
function CoachConversationsBody() {
const { t } = useTranslations();
const router = useRouter();
- const { conversations, isLoading } = useCoachConversations();
+ const { conversations, isLoading, isError, refetch } =
+ useCoachConversations();
const deleteMutation = useDeleteCoachConversation();
const [filter, setFilter] = useState("");
const [confirmId, setConfirmId] = useState(null);
@@ -129,6 +132,19 @@ function CoachConversationsBody() {
{t("insights.coach.historyTitle")}
}
+ actions={
+ // Surface the sibling Plans ledger — otherwise it is reachable
+ // only from the composer's `+` menu.
+
+ }
/>
{t("common.loading")}
+ ) : isError && conversations.length === 0 ? (
+ // A load failure must not masquerade as "no conversations yet" —
+ // that reads as a data-loss scare and offers no way back. Mirror
+ // /coach/plans and surface a retry.
+ refetch()} />
) : groups.length === 0 ? (
) : (
groups.map((group) => (
diff --git a/src/app/custom-metrics/[id]/page.tsx b/src/app/custom-metrics/[id]/page.tsx
index 39e787634..4f884e645 100644
--- a/src/app/custom-metrics/[id]/page.tsx
+++ b/src/app/custom-metrics/[id]/page.tsx
@@ -9,7 +9,7 @@
*/
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CustomMetricDetail } from "@/components/custom-metrics/custom-metric-detail";
import { BackLink } from "@/components/ui/back-link";
@@ -35,11 +35,7 @@ export default function CustomMetricDetailPage({
}, [isLoading, isAuthenticated, router]);
if (!mounted || isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx
index 680ea2223..a4ab8c9aa 100644
--- a/src/app/cycle/page.tsx
+++ b/src/app/cycle/page.tsx
@@ -2,9 +2,9 @@
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { CycleView } from "@/components/cycle/cycle-view";
/**
@@ -36,24 +36,14 @@ export default function CyclePage() {
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
- return (
-
-
-
- );
+ return ;
}
// `` reads `useSearchParams` for the `?tab=` deep-link; wrap it in
// a Suspense boundary so the client-search-params bailout never de-opts the
// build (Next's `missing-suspense-with-csr-bailout`).
return (
-
-
-
- }
- >
+ }>
);
diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx
index 8186871ab..98e4c174c 100644
--- a/src/app/illness/[id]/page.tsx
+++ b/src/app/illness/[id]/page.tsx
@@ -9,7 +9,7 @@
*/
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail";
import { BackLink } from "@/components/ui/back-link";
@@ -40,11 +40,7 @@ export default function IllnessEpisodePage({
}, [isLoading, isAuthenticated, enabled, router]);
if (!mounted || isLoading || !isAuthenticated || !enabled) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx
index ec16578b3..6fef680cc 100644
--- a/src/app/illness/page.tsx
+++ b/src/app/illness/page.tsx
@@ -2,9 +2,9 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { IllnessView } from "@/components/illness/illness-view";
/**
@@ -35,11 +35,7 @@ export default function IllnessPage() {
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
- return (
-
-
-
- );
+ return ;
}
return ;
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 66ef264ed..8340bd4b5 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
-import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react";
+import { SlidersHorizontal, TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -20,6 +20,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
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 { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
@@ -340,23 +341,11 @@ export default function InsightsPage() {
// mirroring the error pattern.
if (isError) {
return (
-
- {t("insights.loadError")}
-
+
+ refetch()}
+ />
);
}
diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx
index 29189ca39..fc01acfcd 100644
--- a/src/app/labs/[biomarkerId]/page.tsx
+++ b/src/app/labs/[biomarkerId]/page.tsx
@@ -9,7 +9,7 @@
*/
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
import { BackLink } from "@/components/ui/back-link";
@@ -35,11 +35,7 @@ export default function LabBiomarkerDetailPage({
}, [isLoading, isAuthenticated, router]);
if (!mounted || isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 6d6aed90d..3244bfc8e 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -14,9 +14,10 @@ import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
-import { Plus, Loader2 } from "lucide-react";
+import { Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
@@ -116,11 +117,7 @@ export default function MeasurementsPage() {
// SSR HTML when this boundary hydrates after `/api/auth/me` settled
// (React #418 family); see `useMounted`.
if (!mounted || isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx
index dd1365992..2eb9488a8 100644
--- a/src/app/medications/[id]/page.tsx
+++ b/src/app/medications/[id]/page.tsx
@@ -15,7 +15,7 @@
import { use, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -68,19 +68,7 @@ export default function MedicationDetailPage({
});
if (authLoading || isLoading || !isAuthenticated) {
- return (
-
-
-
- );
+ return ;
}
if (isError || !medication) {
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index bc2121edf..bc8a5b0e2 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EmptyState } from "@/components/ui/empty-state";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import {
CheckCheck,
@@ -426,20 +427,11 @@ export default function MedicationsPage() {
)}
) : isError ? (
-
-
-
- {t("medications.loadFailed")}
-
-
-
-
+ void refetchMedications()}
+ />
) : !medications?.length ? (
// v1.4.15 phase-C5: refactor the inline icon+text+button block
// to the shared EmptyState primitive so the empty path matches
diff --git a/src/app/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx
index 56b2e2d47..970860c68 100644
--- a/src/app/mental-wellbeing/page.tsx
+++ b/src/app/mental-wellbeing/page.tsx
@@ -2,9 +2,9 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
/**
@@ -40,11 +40,7 @@ export default function MentalWellbeingPage() {
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx
index 231eae7f9..54b0d893f 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -10,9 +10,10 @@ import { MoodForm } from "@/components/mood/mood-form";
import { MoodList } from "@/components/mood/mood-list";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
+import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
-import { Plus, Loader2, Wrench } from "lucide-react";
+import { Plus, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
@@ -54,11 +55,7 @@ export default function MoodPage() {
// and React logged hydration error #418 — same fix as the measurements
// page; see `useMounted`.
if (!mounted || isLoading) {
- return (
-
-
-
- );
+ return ;
}
return (
diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx
new file mode 100644
index 000000000..c2743c816
--- /dev/null
+++ b/src/app/notifications/__tests__/page.test.tsx
@@ -0,0 +1,129 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * Notification-matrix page contract (QoL/UX audit 2026-07-08):
+ * - H2: the "no channels" CTA points at Settings → Integrations#channels,
+ * the surface that can actually add a channel (not the reminder-types
+ * screen, which no longer configures channels since v1.25.7).
+ * - M3: a failed load renders the shared `QueryErrorCard` with a Retry,
+ * never a bare alert box; the loading frame reserves a skeleton.
+ * - L1: the globally-disabled hint reads as a full sentence, not the bare
+ * "(Disabled)" inline tag.
+ * - M7: the page cross-links the other two notification surfaces.
+ */
+
+type AuthState = { isAuthenticated: boolean; isLoading: boolean };
+type QueryState = {
+ data: unknown;
+ isLoading: boolean;
+ isError: boolean;
+};
+
+let authState: AuthState = { isAuthenticated: true, isLoading: false };
+let queryState: QueryState = {
+ data: undefined,
+ isLoading: false,
+ isError: false,
+};
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ ...queryState, refetch: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({
+ cancelQueries: vi.fn(),
+ getQueryData: vi.fn(),
+ setQueryData: vi.fn(),
+ invalidateQueries: vi.fn(),
+ }),
+}));
+
+import NotificationsPage from "../page";
+
+function render() {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ authState = { isAuthenticated: true, isLoading: false };
+ queryState = { data: undefined, isLoading: false, isError: false };
+});
+
+describe("NotificationsPage", () => {
+ it("M3: renders a skeleton while loading, not a lone spinner", () => {
+ queryState.isLoading = true;
+ const html = render();
+ expect(html).toContain('data-slot="notifications-loading"');
+ expect(html).not.toContain("animate-spin");
+ });
+
+ it("M3: renders the shared QueryErrorCard with a retry on load failure", () => {
+ queryState.isError = true;
+ const html = render();
+ expect(html).toContain('data-slot="query-error-card"');
+ expect(html).toContain("Notification preferences could not be loaded.");
+ });
+
+ it("H2 + L4: the no-channels CTA targets Settings → Integrations#channels", () => {
+ queryState.data = { channels: [], preferences: [], eventTypes: [] };
+ const html = render();
+ expect(html).toContain('href="/settings/integrations#channels"');
+ // The reminder-types screen no longer configures channels — never point there.
+ expect(html).not.toContain('href="/settings/notifications"><');
+ expect(html).toContain("Settings → Integrations");
+ });
+
+ it("M7: the matrix cross-links the reminder-types + channels surfaces", () => {
+ queryState.data = {
+ channels: [
+ {
+ id: "c1",
+ type: "telegram",
+ label: "Telegram",
+ enabled: true,
+ globallyEnabled: true,
+ },
+ ],
+ preferences: [],
+ eventTypes: ["MEDICATION_REMINDER"],
+ };
+ const html = render();
+ expect(html).toContain('href="/settings/notifications"');
+ expect(html).toContain('href="/settings/integrations#channels"');
+ });
+
+ it("L1: the globally-disabled hint is a full sentence, not the bare tag", () => {
+ queryState.data = {
+ channels: [
+ {
+ id: "c1",
+ type: "telegram",
+ label: "Telegram",
+ enabled: true,
+ globallyEnabled: true,
+ },
+ {
+ id: "c2",
+ type: "ntfy",
+ label: "ntfy",
+ enabled: true,
+ globallyEnabled: false,
+ },
+ ],
+ preferences: [],
+ eventTypes: ["MEDICATION_REMINDER"],
+ };
+ const html = render();
+ expect(html).toContain("won’t receive notifications");
+ });
+});
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index 4078419e7..f5c64067d 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -7,8 +7,10 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
+import { Skeleton } from "@/components/ui/skeleton";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
-import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
+import { Bell, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
@@ -52,7 +54,7 @@ export default function NotificationsPage() {
const { t } = useTranslations();
const queryClient = useQueryClient();
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet("/api/notifications/preferences");
@@ -119,10 +121,50 @@ export default function NotificationsPage() {
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
}
+ // Cross-links to the other two notification surfaces (reminder types +
+ // delivery channels) so the matrix never reads as the only place to manage
+ // notifications. Mirrored by back-links on those two settings screens.
+ const headerActions = (
+ <>
+
+
+ >
+ );
+
if (authLoading || isLoading) {
return (
-
-
+
+
+ {/* Skeleton mirrors the event-row matrix so the auth-resolving frame
+ reserves layout instead of flashing a lone spinner. */}
+
@@ -173,8 +214,10 @@ export default function NotificationsPage() {
{t("notifications.noChannels")}
+ {/* Channel setup lives in Settings → Integrations since v1.25.7;
+ point the CTA at that anchor, not the reminder-types screen. */}
- {/* Info hint about disabled channels */}
+ {/* Info hint about globally-disabled channels — a full sentence that
+ explains the state, not the bare "(Disabled)" inline tag. */}
{channels.some((ch) => !ch.globallyEnabled) && (
)}
diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx
index 158f85062..61e734eab 100644
--- a/src/app/page-client.tsx
+++ b/src/app/page-client.tsx
@@ -25,6 +25,7 @@ import {
} from "@/lib/dashboard-layout";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
import dynamic from "next/dynamic";
+import Link from "next/link";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
@@ -131,6 +132,30 @@ import {
resolveChartRowPlaceholderCount,
} from "@/components/dashboard/dashboard-gates";
+/**
+ * Dashboard metric tiles are no longer dead-ends: each tile links to its
+ * Insights detail page (bands, correlations, history). Keyed by the stable
+ * tile `id` used in the `trendCards` builder; glucose contexts share one
+ * blood-glucose destination via the `glucose-` prefix. Tiles with no
+ * dedicated sub-page (body fat %) stay un-linked rather than misroute.
+ */
+const TILE_INSIGHT_HREF: Record = {
+ weight: "/insights/weight",
+ "bp-sys": "/insights/blood-pressure",
+ "bp-dia": "/insights/blood-pressure",
+ bpInTarget: "/insights/blood-pressure",
+ pulse: "/insights/pulse",
+ mood: "/insights/mood",
+ sleep: "/insights/sleep",
+ steps: "/insights/steps",
+ vo2Max: "/insights/cardio-fitness",
+};
+
+function tileInsightHref(id: string): string | null {
+ if (id.startsWith("glucose-")) return "/insights/blood-glucose";
+ return TILE_INSIGHT_HREF[id] ?? null;
+}
+
export default function DashboardPageClient() {
const { isAuthenticated, user } = useAuth();
const mounted = useMounted();
@@ -1611,27 +1636,45 @@ export default function DashboardPageClient() {
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
- {trendCards.map((entry) => (
-
- {/*
- * Per-tile `` boundary with a layout-stable
- * placeholder that mirrors the trend-card chrome. Tile
- * bodies are synchronous today so the fallback rarely
- * paints, but a future RSC hoist of any tile slot
- * would otherwise leave the grid track empty and
- * trigger CLS as the cell paints in.
- *
- * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder.
- */}
- {/* v1.16.0 — the fallback is the same structured
- silhouette the tile-strip skeleton paints, so a
- suspending tile slot previews the final shape
- instead of an empty card. */}
+ {trendCards.map((entry) => {
+ /*
+ * Per-tile `` boundary with a layout-stable
+ * placeholder that mirrors the trend-card chrome. Tile
+ * bodies are synchronous today so the fallback rarely
+ * paints, but a future RSC hoist of any tile slot
+ * would otherwise leave the grid track empty and
+ * trigger CLS as the cell paints in.
+ *
+ * v1.16.0 — the fallback is the same structured
+ * silhouette the tile-strip skeleton paints, so a
+ * suspending tile slot previews the final shape
+ * instead of an empty card.
+ */
+ const cell = (
}>
{entry.node}
-
- ))}
+ );
+ // A tile is no longer a dead-end: it links to its Insights
+ // detail page. The link fills the grid cell and paints a
+ // ring on hover / focus so the whole tile reads as tappable.
+ const href = tileInsightHref(entry.id);
+ return href ? (
+
+ {cell}
+
+ ) : (
+
` with no association to the
+ // Radix switch button, so a screen reader would announce a bare
+ // "switch, on/off". Name it explicitly — one fix covers every admin
+ // toggle that mounts this shared component.
+ aria-label={label}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx
index fe71164ad..790521df9 100644
--- a/src/components/charts/chart-empty-state.tsx
+++ b/src/components/charts/chart-empty-state.tsx
@@ -56,7 +56,7 @@ export function ChartEmptyState({
style={height === undefined ? undefined : { height }}
>