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 ? (
{t("coach.plans.title")} } description={t("coach.plans.pageDescription")} + actions={ + + } />
query.refetch()} /> ) : query.isLoading ? ( -

- {t("common.loading")} -

- ) : groups.length === 0 ? ( + ) : groups.length === 0 ? ( +
+ } + title={t("coach.plans.empty")} + />
) : ( 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. */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
); } @@ -149,12 +191,10 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} /> -
- {t("notifications.loadError")} -
+ refetch()} + />
); } @@ -166,6 +206,7 @@ export default function NotificationsPage() {
@@ -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) && (

- {t("notifications.channelDisabled")} + {t("notifications.someChannelsGloballyDisabled")}

)} 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} + + ) : ( +
+ {cell} +
+ ); + })}
)} {charts.map((entry) => ( diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index b9c8ff1aa..38d907201 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -192,6 +192,11 @@ export function SettingsToggle({
` 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 }} >