Merge branch 'fix/qol-a11y-sweep' into release/v1.27.32

# Conflicts:
#	src/components/dashboard/recent-workouts-tile.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 02:09:57 +02:00
commit e4eb3b04ab
63 files changed
+1389 -889

No files matched your search

+15 -7
View File
@@ -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": {
+15 -7
View File
@@ -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": {
+15 -7
View File
@@ -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": {
+15 -7
View File
@@ -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": {
+15 -7
View File
@@ -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": {
+15 -7
View File
@@ -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": {
+19 -7
View File
@@ -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 `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` 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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
// 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 (
<div className="space-y-6">
<PageHeader
title={t("achievements.title")}
description={t("achievements.subtitle")}
/>
<QueryErrorCard onRetry={() => refetch()} />
</div>
);
}
const summary = data?.summary;
const achievements = data?.achievements ?? [];
const grouped = groupByCategory(achievements);
+2 -6
View File
@@ -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 (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate />;
}
return (
+23 -2
View File
@@ -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<string>("");
const [confirmId, setConfirmId] = useState<string | null>(null);
@@ -129,6 +132,19 @@ function CoachConversationsBody() {
{t("insights.coach.historyTitle")}
</span>
}
actions={
// Surface the sibling Plans ledger — otherwise it is reachable
// only from the composer's `+` menu.
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
}
/>
<div className="relative">
<Search
@@ -160,6 +176,11 @@ function CoachConversationsBody() {
>
{t("common.loading")}
</p>
) : 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.
<QueryErrorCard onRetry={() => refetch()} />
) : groups.length === 0 ? (
<div
data-slot="coach-conversations-empty"
+36 -16
View File
@@ -1,12 +1,22 @@
"use client";
import { useEffect, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Loader2, Target, Trash2, X } from "lucide-react";
import {
Check,
Loader2,
MessagesSquare,
Target,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachPlans,
@@ -204,6 +214,17 @@ function CoachPlansBody() {
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
}
description={t("coach.plans.pageDescription")}
actions={
<Button asChild variant="outline" size="sm">
<Link
href="/coach/conversations"
data-slot="coach-plans-conversations-link"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Link>
</Button>
}
/>
<div
@@ -216,23 +237,22 @@ function CoachPlansBody() {
{query.isError ? (
<QueryErrorCard onRetry={() => query.refetch()} />
) : query.isLoading ? (
<p
data-slot="coach-plans-loading"
className="text-muted-foreground px-1 py-3 text-sm"
>
{t("common.loading")}
</p>
) : groups.length === 0 ? (
<div
data-slot="coach-plans-empty"
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
data-slot="coach-plans-loading"
className="flex flex-col gap-2"
aria-hidden="true"
>
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
<Target className="size-6" aria-hidden="true" />
</span>
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
{t("coach.plans.empty")}
</p>
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
) : groups.length === 0 ? (
<div data-slot="coach-plans-empty">
<EmptyState
variant="plain"
icon={<Target className="size-6" />}
title={t("coach.plans.empty")}
/>
</div>
) : (
groups.map((group) => (
+2 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
return (
+3 -13
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate />;
}
// `<CycleView>` 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 (
<Suspense
fallback={
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
}
>
<Suspense fallback={<PageAuthGate />}>
<CycleView />
</Suspense>
);
+2 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
return (
+2 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate />;
}
return <IllnessView />;
+7 -18
View File
@@ -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 <VitalsDashboard> error pattern.
if (isError) {
return (
<div
data-slot="insights-overview-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="insights-overview-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
<div data-slot="insights-overview-error">
<QueryErrorCard
description={t("insights.loadError")}
onRetry={() => refetch()}
/>
</div>
);
}
+2 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
return (
+3 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
return (
+2 -14
View File
@@ -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 (
<div
className="flex h-64 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
/>
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
if (isError || !medication) {
+6 -14
View File
@@ -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() {
)}
</div>
) : isError ? (
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm">
{t("medications.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => void refetchMedications()}
>
{t("medications.retryLoad")}
</Button>
</div>
</div>
<QueryErrorCard
description={t("medications.loadFailed")}
retryLabel={t("medications.retryLoad")}
onRetry={() => 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
+2 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate />;
}
return (
+3 -6
View File
@@ -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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
return <PageAuthGate label={t("common.loading")} />;
}
return (
@@ -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(
<I18nProvider initialLocale="en">
<NotificationsPage />
</I18nProvider>,
);
}
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");
});
});
+58 -13
View File
@@ -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<PreferencesData>("/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 = (
<>
<Button asChild variant="outline" size="sm">
<Link href="/settings/notifications">
{t("notifications.reminderSettingsLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("notifications.channelsLink")}
</Link>
</Button>
</>
);
if (authLoading || isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-6">
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
{/* Skeleton mirrors the event-row matrix so the auth-resolving frame
reserves layout instead of flashing a lone spinner. */}
<div
data-slot="notifications-loading"
className="bg-card border-border overflow-hidden rounded-xl border"
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="border-border flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
>
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-3 w-56 rounded" />
</div>
<Skeleton className="h-5 w-9 shrink-0 rounded-full" />
</div>
))}
</div>
</div>
);
}
@@ -149,12 +191,10 @@ export default function NotificationsPage() {
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
>
{t("notifications.loadError")}
</div>
<QueryErrorCard
description={t("notifications.loadError")}
onRetry={() => refetch()}
/>
</div>
);
}
@@ -166,6 +206,7 @@ export default function NotificationsPage() {
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
@@ -173,8 +214,10 @@ export default function NotificationsPage() {
<p className="text-muted-foreground text-sm">
{t("notifications.noChannels")}
</p>
{/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */}
<Button asChild variant="outline">
<Link href="/settings/notifications">
<Link href="/settings/integrations#channels">
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
@@ -190,6 +233,7 @@ export default function NotificationsPage() {
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
/>
{/* Desktop: table layout */}
@@ -321,12 +365,13 @@ export default function NotificationsPage() {
})}
</div>
{/* 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) && (
<div className="flex items-start gap-2 rounded-lg p-3 text-sm">
<AlertCircle className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("notifications.channelDisabled")}
{t("notifications.someChannelsGloballyDisabled")}
</p>
</div>
)}
+61 -18
View File
@@ -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<string, string> = {
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) => (
<div key={entry.id} className="flex min-w-0">
{/*
* Per-tile `<Suspense>` 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 `<Suspense>` 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 = (
<Suspense fallback={<TrendCardSkeleton />}>
{entry.node}
</Suspense>
</div>
))}
);
// 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 ? (
<Link
key={entry.id}
href={href}
data-slot="dashboard-tile-link"
data-tile-id={entry.id}
className="group focus-visible:ring-ring/50 hover:ring-ring/30 flex min-w-0 rounded-xl transition-[box-shadow] hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
>
{cell}
</Link>
) : (
<div key={entry.id} className="flex min-w-0">
{cell}
</div>
);
})}
</div>
)}
{charts.map((entry) => (
+5
View File
@@ -192,6 +192,11 @@ export function SettingsToggle({
</div>
</div>
<Switch
// The visible label is a sibling `<p>` 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}
+1 -1
View File
@@ -56,7 +56,7 @@ export function ChartEmptyState({
style={height === undefined ? undefined : { height }}
>
<LineChartIcon
className="text-muted-foreground/60 h-8 w-8"
className="text-muted-foreground h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
+1 -1
View File
@@ -73,7 +73,7 @@ export function ChartErrorState({
style={height === undefined ? undefined : { height }}
>
<AlertTriangle
className="text-muted-foreground/60 h-8 w-8"
className="text-muted-foreground h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
@@ -90,6 +90,7 @@ export function ComplianceLineChart({
<Button
key={r.label}
variant={activeRangePoints === r.points ? "default" : "ghost"}
aria-pressed={activeRangePoints === r.points}
size="sm"
className="min-h-11 px-3 text-xs"
onClick={() => setActiveRangePoints(r.points)}
+1
View File
@@ -1666,6 +1666,7 @@ export function HealthChart({
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
+1
View File
@@ -1050,6 +1050,7 @@ export function MoodChart({
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
@@ -106,6 +106,7 @@ export function CustomMetricChart({
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
aria-pressed={range === r.key}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
@@ -178,23 +179,13 @@ export function CustomMetricDetail({
{description}
</p>
</header>
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-destructive text-sm">
{t("customMetrics.loadError")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
if (metricError) void refetchMetric();
if (listError) void refetchList();
}}
>
{t("common.retry")}
</Button>
</CardContent>
</Card>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => {
if (metricError) void refetchMetric();
if (listError) void refetchList();
}}
/>
</div>
);
}
@@ -2,11 +2,13 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Gauge, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
@@ -27,10 +29,11 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
});
@@ -40,10 +43,8 @@ export function CustomMetricList() {
function afterAdd(saved: CustomMetricDto) {
setAddOpen(false);
// Land the user on the new metric's detail page so the next step (logging
// a value) is one tap away.
if (typeof window !== "undefined") {
window.location.assign(`/custom-metrics/${saved.id}`);
}
// a value) is one tap away — client nav, not a full-page reload.
router.push(`/custom-metrics/${saved.id}`);
}
return (
@@ -80,9 +81,10 @@ export function CustomMetricList() {
</CardContent>
</Card>
) : isError ? (
<p role="alert" className="text-destructive py-4 text-center text-sm">
{t("customMetrics.loadError")}
</p>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => refetch()}
/>
) : metrics.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
+35 -9
View File
@@ -3,7 +3,13 @@
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import {
Loader2,
Plus,
RefreshCw,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -100,6 +106,13 @@ export function CycleView() {
: "calendar";
}, [searchParams]);
// Controlled so the header "cycle settings" wrench (parity with the
// labs/medications/illness modules, which expose a customize glyph) can jump
// to the settings tab. Initialised from the `?tab=` deep-link, matching the
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
// a later param change does not force-switch a tab the user is reading).
const [tab, setTab] = useState<CycleTab>(initialTab);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
@@ -158,13 +171,26 @@ export function CycleView() {
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
description={t("cycle.subtitle")}
actions={
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
<>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("cycle.tabs.settings")}
title={t("cycle.tabs.settings")}
data-slot="cycle-settings-wrench"
onClick={() => setTab("settings")}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</>
}
/>
@@ -266,7 +292,7 @@ export function CycleView() {
) : null}
</div>
<Tabs defaultValue={initialTab}>
<Tabs value={tab} onValueChange={(v) => setTab(v as CycleTab)}>
{/* v1.15.10 — the four German labels (Kalender/Prognosen/
Erkenntnisse/Einstellungen) overflowed the `flex-1` equal-width
cells at 375 px and forced a horizontal scroll. Below `sm` the
@@ -15,6 +15,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
/**
@@ -108,23 +109,23 @@ export function RecentWorkoutsTile() {
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Activity
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<h2 className="text-sm font-semibold">
{t("dashboard.recentWorkouts.title")}
</h2>
</div>
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
</div>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
{isLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
@@ -95,6 +95,36 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("Weight down 30 d");
});
it("links the key-finding rows for metrics whose Insights sub-page exists", () => {
// Regression guard for the stale METRIC_HREF map: hrv / resting_hr /
// active_energy / flights / distance / body_temp / recovery all have
// dedicated Insights sub-pages now, so their briefing rows must be
// tappable `<a>` links, not inert rows.
const html = render(
<DailyBriefing
briefing={{
...baseBriefing,
keyFindings: [
{ ...baseBriefing.keyFindings[2], sourceMetric: "hrv" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "resting_hr" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "active_energy" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "flights" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "distance" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "body_temp" },
{ ...baseBriefing.keyFindings[2], sourceMetric: "recovery" },
],
}}
/>,
);
expect(html).toContain('href="/insights/hrv"');
expect(html).toContain('href="/insights/resting-pulse"');
expect(html).toContain('href="/insights/active-energy"');
expect(html).toContain('href="/insights/flights-climbed"');
expect(html).toContain('href="/insights/walking-distance"');
expect(html).toContain('href="/insights/body-temperature"');
expect(html).toContain('href="/insights/recovery"');
});
it("renders the delta string when supplied", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
expect(html).toMatch(/data-slot="daily-briefing-delta"/);
+11 -11
View File
@@ -53,28 +53,28 @@ export const METRIC_HREF: Record<
pulse: "/insights/pulse",
mood: "/insights/mood",
compliance: "/insights/medications",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
// Apple Health / GLP-1 additive metrics — each now routes to its
// dedicated sub-page that shipped since the original map was written.
hrv: "/insights/hrv",
sleep: "/insights/sleep",
resting_hr: null,
resting_hr: "/insights/resting-pulse",
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
// generic readings view keyed to its `MeasurementType` (the same target
// the category pages use to drill into a single type). Every other
// briefing finding with a real destination is already tappable; steps
// was the lone static row.
steps: "/insights/steps",
active_energy: null,
flights: null,
distance: null,
active_energy: "/insights/active-energy",
flights: "/insights/flights-climbed",
distance: "/insights/walking-distance",
vo2_max: "/insights/pulse",
body_temp: null,
body_temp: "/insights/body-temperature",
glp1_plateau: "/insights/medications",
// ── v1.10.0 derived-wellness additive ──
// Readiness has a score-anatomy detail page; recovery is a read-only
// ring with no dedicated sub-page yet (renders as a static row).
// Readiness has a score-anatomy detail page; recovery has its own
// read-only sub-page.
readiness: "/insights/scores/readiness",
recovery: null,
recovery: "/insights/recovery",
};
/**
+4 -1
View File
@@ -291,8 +291,11 @@ export function HeroStrip({
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
reserves primary/accent for actions, not decorative header
icons. */}
<Sparkles
className="text-primary h-5 w-5 shrink-0"
className="text-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1
@@ -115,6 +115,7 @@ export function LabBiomarkerChart({
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
aria-pressed={range === r.key}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
+26 -1
View File
@@ -9,9 +9,17 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { FlaskConical, ListOrdered, Pencil, Plus } from "lucide-react";
import {
ArrowRight,
FlaskConical,
FolderOpen,
ListOrdered,
Pencil,
Plus,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -84,6 +92,7 @@ const READINGS_PAGE_SIZE = 200;
* it. Mirrors the measurement-detail layout.
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
@@ -367,6 +376,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
catalog-less marker still carries a description. */}
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
to lab-result documents rather than the exact scan behind one value.
Only when the documents module is enabled. */}
{user?.modules?.inboundDocuments ? (
<Link
href="/documents?kind=LAB_RESULT"
data-slot="lab-biomarker-documents-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-4" aria-hidden="true" />
{t("labs.documentsLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
{isLoading ? (
<Card>
<CardContent>
+14
View File
@@ -230,11 +230,17 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
</Link>
) : (
// Un-linked group (not backfilled to a catalog biomarker):
// render inert but say so, so it doesn't read as a broken
// link next to its clickable neighbours.
<div className="flex min-w-0 flex-1 items-center gap-3">
{inner}
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
<span className="text-muted-foreground shrink-0 text-xs italic">
{t("labs.notLinkedYet")}
</span>
</div>
)}
</div>
@@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
values={group.readings.map((r) => r.value)}
/>
</div>
{/* Un-linked group: the header renders no link, so name the
reason rather than leaving a card that silently does
nothing on tap. */}
{!group.biomarkerId ? (
<p className="text-muted-foreground text-xs italic">
{t("labs.notLinkedYet")}
</p>
) : null}
</CardContent>
</Card>
</li>
+1 -1
View File
@@ -54,7 +54,7 @@ export function LabTrendSparkline({
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="text-muted-foreground/70 overflow-visible"
className="text-muted-foreground overflow-visible"
aria-hidden
role="presentation"
>
@@ -17,20 +17,13 @@
* ad-hoc (no `scheduledFor`); an exact slot match pins directly with no nudge.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Switch } from "@/components/ui/switch";
@@ -135,6 +128,7 @@ export function LedgerAddDialog({
}: LedgerAddDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
const [skipped, setSkipped] = useState(false);
@@ -200,136 +194,127 @@ export function LedgerAddDialog({
}
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="ledger-add-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.addDialog.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.addDialog.description")}
</DialogDescription>
</DialogHeader>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
the buttons carry explicit types so cancel never submits. The
nested nudge dialog renders through a portal, so its buttons
never land inside this form. */}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
<DateTimeField
id="ledger-add-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
</div>
<label
htmlFor="ledger-add-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.detail.verlauf.addDialog.skippedLabel")}
</span>
<Switch
id="ledger-add-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
{/* A near-miss take previews where it will land before the user
commits — calm, not alarming. */}
{!skipped && classification === "nudge" && nearest && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-add-nudge-hint"
>
{t("medications.detail.verlauf.addDialog.nudgeHint", {
slot: nearest.slotHm,
})}
</p>
<ResponsiveSheet
open={open}
onOpenChange={(o) => !busy && onOpenChange(o)}
title={t("medications.detail.verlauf.addDialog.title")}
description={t("medications.detail.verlauf.addDialog.description")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
the buttons carry explicit types so cancel never submits. */}
<form
id={formId}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
<DateTimeField
id="ledger-add-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.detail.verlauf.addDialog.cancel")}
</Button>
<Button
type="submit"
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-submit"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.addDialog.submit")}
</Button>
</DialogFooter>
</form>
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
{nudgeOpen && nearest && (
<Dialog
open={nudgeOpen}
onOpenChange={(o) => !busy && setNudgeOpen(o)}
<label
htmlFor="ledger-add-skipped"
className="flex items-center justify-between gap-3"
>
<DialogContent data-slot="ledger-add-nudge-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.verlauf.nudge.title")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => void write(false)}
disabled={busy}
data-slot="ledger-add-nudge-adhoc"
>
{t("medications.detail.verlauf.nudge.keepAdHoc")}
</Button>
<Button
onClick={() => void write(true)}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-nudge-pin"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DialogContent>
</Dialog>
<span className="text-sm font-medium">
{t("medications.detail.verlauf.addDialog.skippedLabel")}
</span>
<Switch
id="ledger-add-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
{/* A near-miss take previews where it will land before the user
commits — calm, not alarming. */}
{!skipped && classification === "nudge" && nearest && (
<p
className="text-muted-foreground text-xs"
data-slot="ledger-add-nudge-hint"
>
{t("medications.detail.verlauf.addDialog.nudgeHint", {
slot: nearest.slotHm,
})}
</p>
)}
</div>
</form>
{/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the
take as a standalone ad-hoc entry. */}
{nudgeOpen && nearest && (
<ResponsiveSheet
open={nudgeOpen}
onOpenChange={(o) => !busy && setNudgeOpen(o)}
title={t("medications.detail.verlauf.nudge.title")}
footer={
<>
<Button
variant="outline"
onClick={() => void write(false)}
disabled={busy}
data-slot="ledger-add-nudge-adhoc"
>
{t("medications.detail.verlauf.nudge.keepAdHoc")}
</Button>
<Button
onClick={() => void write(true)}
disabled={busy}
aria-busy={busy || undefined}
data-slot="ledger-add-nudge-pin"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.verlauf.nudge.pin")}
</Button>
</>
}
>
<p className="text-muted-foreground text-sm">
{t("medications.detail.verlauf.nudge.body", {
slot: nearest.slotHm,
})}
</p>
</ResponsiveSheet>
)}
</ResponsiveSheet>
);
}
@@ -927,7 +927,7 @@ function DeleteRowConfirm({
e.preventDefault();
onConfirm();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
@@ -21,19 +21,13 @@
* persisted it (audit LOW-10), so the dialog no longer pretends.
*/
import { useState } from "react";
import { useId, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -107,6 +101,7 @@ function IntakeEditDialogBody({
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const formId = useId();
const [takenAt, setTakenAt] = useState(() =>
toDateTimeLocal(event?.takenAt ?? null),
);
@@ -145,87 +140,92 @@ function IntakeEditDialogBody({
}
return (
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
<DialogContent data-slot="intake-edit-dialog">
<DialogHeader>
<DialogTitle>
{t("medications.detail.intake.edit.dialogTitle")}
</DialogTitle>
</DialogHeader>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
<ResponsiveSheet
open={!!event}
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.intake.edit.dialogTitle")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("medications.detail.intake.edit.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the datetime field submits;
the buttons carry explicit types so cancel never submits. */}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="intake-edit-taken-at">
{t("medications.detail.intake.edit.takenAtLabel")}
</Label>
<DateTimeField
id="intake-edit-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
{scheduledFor && (
<p
className="text-muted-foreground text-xs"
data-slot="intake-edit-scheduled-hint"
>
{t("medications.detail.intake.edit.scheduledForHint", {
dateTime: fmt.dateTime(scheduledFor),
})}
</p>
)}
{/* Non-blocking: a 48h+ gap to the slot is almost always a
<form
id={formId}
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="intake-edit-taken-at">
{t("medications.detail.intake.edit.takenAtLabel")}
</Label>
<DateTimeField
id="intake-edit-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
{scheduledFor && (
<p
className="text-muted-foreground text-xs"
data-slot="intake-edit-scheduled-hint"
>
{t("medications.detail.intake.edit.scheduledForHint", {
dateTime: fmt.dateTime(scheduledFor),
})}
</p>
)}
{/* Non-blocking: a 48h+ gap to the slot is almost always a
date typo, but a deliberate far edit stays saveable. */}
{showFarHint && (
<p
className="text-warning text-xs"
data-slot="intake-edit-far-hint"
>
{t("medications.detail.intake.edit.farFromScheduledWarning")}
</p>
)}
</div>
<label
htmlFor="intake-edit-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.detail.intake.edit.skippedLabel")}
</span>
<Switch
id="intake-edit-skipped"
checked={skipped}
onCheckedChange={(checked) => setSkipped(checked)}
/>
</label>
{showFarHint && (
<p
className="text-warning text-xs"
data-slot="intake-edit-far-hint"
>
{t("medications.detail.intake.edit.farFromScheduledWarning")}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("medications.detail.intake.edit.cancel")}
</Button>
<Button type="submit" disabled={busy} aria-busy={busy || undefined}>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.detail.intake.edit.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<label
htmlFor="intake-edit-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.detail.intake.edit.skippedLabel")}
</span>
<Switch
id="intake-edit-skipped"
checked={skipped}
onCheckedChange={(checked) => setSkipped(checked)}
/>
</label>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -228,7 +228,7 @@ export function IntakeHistoryEditable({
}}
disabled={bulkBusy}
aria-busy={bulkBusy || undefined}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.bulkDelete.confirmAction")}
</AlertDialogAction>
@@ -262,7 +262,7 @@ export function IntakeHistoryEditable({
}}
disabled={rowDeleteBusy}
aria-busy={rowDeleteBusy || undefined}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
@@ -22,12 +22,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -143,105 +138,104 @@ export function IntakeImportDialog({
}
return (
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
<div className="space-y-2">
<Label htmlFor="intake-import-file" className="text-xs font-medium">
{t("medications.importUploadFile")}
</Label>
<input
key={fileInputKey}
id="intake-import-file"
type="file"
accept="application/json,.json"
onChange={handleFileSelect}
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
/>
{selectedFileName && (
<p className="text-muted-foreground text-xs">
{t("medications.importSelected", { name: selectedFileName })}
</p>
<ResponsiveSheet
open={!!medicationId}
onOpenChange={() => handleClose()}
title={t("medications.importIntakes")}
contentWidth="lg"
footer={
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="mr-auto h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-4 w-4" />
)}
</div>
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
{`[
{t("common.import")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("medications.importDescription")}
</p>
<div className="space-y-2">
<Label htmlFor="intake-import-file" className="text-xs font-medium">
{t("medications.importUploadFile")}
</Label>
<input
key={fileInputKey}
id="intake-import-file"
type="file"
accept="application/json,.json"
onChange={handleFileSelect}
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
/>
{selectedFileName && (
<p className="text-muted-foreground text-xs">
{t("medications.importSelected", { name: selectedFileName })}
</p>
)}
</div>
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
{`[
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
]`}
</pre>
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder={t("medications.importPaste")}
rows={8}
autoCapitalize="none"
spellCheck={false}
className="font-mono"
/>
{result && (
<p
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
role="status"
aria-live="polite"
>
{result}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
disabled={importing}
aria-label={t("common.moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetImportForm}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.reset")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={importing}
className="min-h-11 sm:min-h-9"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
disabled={importing || !jsonText.trim()}
aria-busy={importing || undefined}
className="min-h-11 sm:min-h-9"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-4 w-4" />
)}
{t("common.import")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</pre>
<Textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder={t("medications.importPaste")}
rows={8}
autoCapitalize="none"
spellCheck={false}
className="font-mono"
/>
{result && (
<p
className={`text-sm ${resultType === "success" ? "text-success" : "text-destructive"}`}
role="status"
aria-live="polite"
>
{result}
</p>
)}
</div>
</ResponsiveSheet>
);
}
@@ -4,14 +4,7 @@ import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
import {
@@ -85,7 +78,7 @@ export function LogInjectionSiteDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// Hold the dialog while the PATCH is in flight so a backdrop tap /
@@ -93,76 +86,12 @@ export function LogInjectionSiteDialog({
if (submitting) return;
if (!next) onSkip();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logInjectionSiteDescription", {
name: medicationName,
})}
</DialogDescription>
</DialogHeader>
{allowed.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("medications.logInjectionSiteNoneAvailable")}
</p>
) : (
<>
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
{/* Marker legend — explains the two body-map annotations so the
dashed primary ring and the amber ring read at a glance. The
swatches mirror the picker's own marker strokes exactly. */}
<ul
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
aria-label={t("medications.injectionSiteLegendAriaLabel")}
>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-primary"
strokeWidth="1.6"
strokeDasharray="3 3"
/>
</svg>
{t("medications.injectionSiteLegendRecommended")}
</li>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-[var(--warning)]"
strokeWidth="1.8"
/>
</svg>
{t("medications.injectionSiteLegendLastUsed")}
</li>
</ul>
</>
)}
<DialogFooter className="gap-2 sm:gap-2">
title={t("medications.logInjectionSiteTitle")}
description={t("medications.logInjectionSiteDescription", {
name: medicationName,
})}
footer={
<>
<Button variant="outline" onClick={onSkip} disabled={submitting}>
{t("medications.logInjectionSiteSkip")}
</Button>
@@ -178,8 +107,66 @@ export function LogInjectionSiteDialog({
) : null}
{t("medications.logInjectionSiteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
{allowed.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("medications.logInjectionSiteNoneAvailable")}
</p>
) : (
<>
<InjectionSitePicker
value={selected}
history={history}
allowed={allowed}
onChange={setSelected}
/>
{/* Marker legend — explains the two body-map annotations so the
dashed primary ring and the amber ring read at a glance. The
swatches mirror the picker's own marker strokes exactly. */}
<ul
className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs"
aria-label={t("medications.injectionSiteLegendAriaLabel")}
>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-primary"
strokeWidth="1.6"
strokeDasharray="3 3"
/>
</svg>
{t("medications.injectionSiteLegendRecommended")}
</li>
<li className="flex items-center gap-1.5">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
className="stroke-[var(--warning)]"
strokeWidth="1.8"
/>
</svg>
{t("medications.injectionSiteLegendLastUsed")}
</li>
</ul>
</>
)}
</ResponsiveSheet>
);
}
+131 -138
View File
@@ -19,20 +19,13 @@
* the form state + the medication/slot pickers.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
@@ -119,6 +112,7 @@ export function LogIntakeDialog({
}: LogIntakeDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const formId = useId();
const [medicationId, setMedicationId] = useState<string>(
() => medications[0]?.id ?? "",
@@ -208,147 +202,146 @@ export function LogIntakeDialog({
}
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent data-slot="log-intake-dialog">
<DialogHeader>
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.logIntake.dialogDescription")}
</DialogDescription>
</DialogHeader>
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
<ResponsiveSheet
open={open}
onOpenChange={(o) => !busy && onOpenChange(o)}
title={t("medications.logIntake.dialogTitle")}
description={t("medications.logIntake.dialogDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</>
}
>
{/* v1.16.4 — a real form so Enter in the dose / datetime fields
submits; mirrors the intake-edit and dose-history-add dialogs. */}
<form
onSubmit={(e) => {
e.preventDefault();
if (busy || medications.length === 0 || !medicationId) return;
void submit();
}}
className="space-y-4"
>
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
) : (
<div className="space-y-3">
<form
id={formId}
onSubmit={(e) => {
e.preventDefault();
if (busy || medications.length === 0 || !medicationId) return;
void submit();
}}
className="space-y-4"
>
{medications.length === 0 ? (
<p className="text-muted-foreground py-4 text-sm">
{t("medications.logIntake.noMedications")}
</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
</Label>
<NativeSelect
id="log-intake-medication"
value={medicationId}
onChange={(e) => handleMedicationChange(e.target.value)}
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
</option>
))}
</NativeSelect>
</div>
{slotTimes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="log-intake-medication">
{t("medications.logIntake.medicationLabel")}
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
</Label>
<NativeSelect
id="log-intake-medication"
value={medicationId}
onChange={(e) => handleMedicationChange(e.target.value)}
id="log-intake-slot"
value={slot}
onChange={(e) => {
setSlot(e.target.value);
// The configured baseline tracks the slot's schedule
// dose, so an untouched field re-prefills on change.
setDoseOverride(null);
}}
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
<option value={NO_SLOT}>
{t("medications.logIntake.slotNone")}
</option>
{slotTimes.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</NativeSelect>
</div>
)}
{slotTimes.length > 0 && (
<div className="space-y-2">
<Label htmlFor="log-intake-slot">
{t("medications.logIntake.slotLabel")}
</Label>
<NativeSelect
id="log-intake-slot"
value={slot}
onChange={(e) => {
setSlot(e.target.value);
// The configured baseline tracks the slot's schedule
// dose, so an untouched field re-prefills on change.
setDoseOverride(null);
}}
>
<option value={NO_SLOT}>
{t("medications.logIntake.slotNone")}
</option>
{slotTimes.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</NativeSelect>
</div>
)}
<div className="space-y-2">
<Label htmlFor="log-intake-dose">
{t("medications.logIntake.doseLabel")}
</Label>
<Input
id="log-intake-dose"
value={dose}
maxLength={50}
onChange={(e) => setDoseOverride(e.target.value)}
placeholder={configuredDose}
autoComplete="off"
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.doseHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<DateTimeField
id="log-intake-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.takenAtHint")}
</p>
</div>
<label
htmlFor="log-intake-skipped"
className="flex items-center justify-between gap-3"
>
<span className="text-sm font-medium">
{t("medications.logIntake.skippedLabel")}
</span>
<Switch
id="log-intake-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
<div className="space-y-2">
<Label htmlFor="log-intake-dose">
{t("medications.logIntake.doseLabel")}
</Label>
<Input
id="log-intake-dose"
value={dose}
maxLength={50}
onChange={(e) => setDoseOverride(e.target.value)}
placeholder={configuredDose}
autoComplete="off"
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.doseHint")}
</p>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
<div className="space-y-2">
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<DateTimeField
id="log-intake-taken-at"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
{t("medications.logIntake.takenAtHint")}
</p>
</div>
<label
htmlFor="log-intake-skipped"
className="flex items-center justify-between gap-3"
>
{t("medications.logIntake.cancel")}
</Button>
<Button
type="submit"
disabled={busy || medications.length === 0 || !medicationId}
aria-busy={busy || undefined}
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("medications.logIntake.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<span className="text-sm font-medium">
{t("medications.logIntake.skippedLabel")}
</span>
<Switch
id="log-intake-skipped"
checked={skipped}
onCheckedChange={setSkipped}
/>
</label>
</div>
)}
</form>
</ResponsiveSheet>
);
}
@@ -327,7 +327,7 @@ export function MedicationTable({
)
) : (
<ChevronsUpDown
className="text-muted-foreground/60 size-3.5"
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
)}
@@ -8,6 +8,16 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
@@ -113,6 +123,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
// its `<form>` association via the HTML `form` attribute.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const formId = useId();
const listKey = queryKeys.medicationSideEffects(medicationId);
@@ -330,14 +341,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(t("medications.sideEffects.deleteConfirm"))
) {
deleteMutation.mutate(row.id);
}
}}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</div>
)}
{/* Themed confirm — replaces the native window.confirm so the delete
gate matches every other destructive action in the app. */}
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(o) => {
if (!o) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.sideEffects.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.sideEffects.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
setPendingDeleteId(null);
}}
disabled={deleteMutation.isPending}
aria-busy={deleteMutation.isPending || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.sideEffects.deleteCta")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
@@ -19,14 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { CheckCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import { formatTimeWindowRange } from "@/lib/time-window-format";
@@ -65,49 +58,17 @@ export function TakeAllDueDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
// Refuse to close while the take loop is in flight.
if (submitting && !next) return;
onOpenChange(next);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.takeAllDue.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("medications.takeAllDue.dialogDescription")}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground">
{" "}
— {formatDose(med.dose, t)}
</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
<DialogFooter>
title={t("medications.takeAllDue.dialogTitle")}
description={t("medications.takeAllDue.dialogDescription")}
footer={
<>
<Button
type="button"
variant="outline"
@@ -136,8 +97,36 @@ export function TakeAllDueDialog({
count: dueMedications.length,
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<ul className="space-y-2" data-testid="take-all-due-list">
{dueMedications.map((med) => (
<li
key={med.id}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="min-w-0 truncate">
<span className="font-medium">{med.name}</span>
{med.dose ? (
<span className="text-muted-foreground">
{" "}
— {formatDose(med.dose, t)}
</span>
) : null}
</span>
{med.window ? (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatTimeWindowRange(
med.window.start,
med.window.end,
locale,
)}
</span>
) : null}
</li>
))}
</ul>
</ResponsiveSheet>
);
}
@@ -183,7 +183,7 @@ export function TitrationTimeline({
? "border-primary bg-primary/15 text-primary"
: step.isPast
? "border-border bg-muted text-muted-foreground"
: "border-border/60 text-muted-foreground/70 border-dashed",
: "border-border text-muted-foreground border-dashed",
)}
aria-hidden="true"
>
@@ -170,7 +170,15 @@ export function SourceCardGrid() {
<li key={item.slug}>
<Button asChild variant="outline" size="sm" className="min-h-9">
<Link
href="/settings/integrations"
// Anchor each chip to its provider card so the tap lands on
// the card, not the top of the generic Integrations page.
// Apple Health has no web card (it syncs from the iOS app),
// so its chip stays on the page root.
href={
item.slug === "apple-health"
? "/settings/integrations"
: `/settings/integrations#${item.slug}`
}
data-testid={`source-more-${item.slug}`}
>
{t(item.labelKey)}
@@ -21,12 +21,14 @@
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*/
import Link from "next/link";
import { Link2, Send } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { Button } from "@/components/ui/button";
export {
parseOAuthOutcome,
@@ -65,6 +67,15 @@ export function IntegrationsSection() {
title={t("settings.sections.integrations.channelsHeading")}
description={t("settings.sections.integrations.channelsDescription")}
/>
{/* Names the per-event notification matrix so the three notification
surfaces (matrix, reminder types, channels) cross-reference. */}
<div>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.integrations.matrixLink")}
</Link>
</Button>
</div>
<NotificationChannelsPanel />
</section>
</div>
@@ -262,13 +262,29 @@ export function ConnectionsPanel() {
return (
<div className="space-y-6" data-slot="connections-panel">
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<GoogleHealthCard viewModel={googleHealthViewModel} />
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<NightscoutCard enabled={isAuthenticated} />
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
`/settings/integrations#oura`) scroll straight to the right card. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard viewModel={withingsViewModel} />
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard viewModel={whoopViewModel} />
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard viewModel={fitbitViewModel} />
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard enabled={isAuthenticated} />
</div>
</div>
);
}
@@ -228,7 +228,7 @@ export function WithingsCard({
<a
href="/api/withings/connect"
data-testid="withings-reconnect-banner"
className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
className="border-warning/30 bg-warning/10 text-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
>
<span className="font-medium">
{t("settings.integrations.withings.reconnect.banner.title")}
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { Bell } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
@@ -7,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { Button } from "@/components/ui/button";
/**
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
@@ -44,6 +46,24 @@ export function NotificationsSection() {
return (
<div className="space-y-10">
{/* Cross-links to the other two notification surfaces so this screen
names them — the per-event matrix and the delivery channels. */}
<div
className="flex flex-wrap gap-2"
data-slot="notifications-crosslinks"
>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
least one reminder-type card is visible, so a fully-disabled
account never shows an empty heading. */}
+1 -1
View File
@@ -204,7 +204,7 @@ function ShareLinksCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { PasswordInput } from "../password-input";
/**
* PasswordInput bakes the password-manager autofill guard in by default so
* BYOK API keys / webhook secrets / ntfy tokens (config secrets, not
* credentials) are never offered up to Bitwarden / 1Password / LastPass.
* Credential fields that pass a real autocomplete token keep manager
* integration.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("PasswordInput", () => {
it("suppresses password managers by default for config secrets", () => {
const html = render(<PasswordInput id="ntfy-token" />);
expect(html).toMatch(/autocomplete="off"/i);
expect(html).toContain("data-bwignore");
expect(html).toContain("data-1p-ignore");
expect(html).toContain('data-lpignore="true"');
});
it("treats an explicit autoComplete=off as a config secret too", () => {
const html = render(<PasswordInput id="tg-token" autoComplete="off" />);
expect(html).toContain("data-bwignore");
expect(html).toContain("data-1p-ignore");
});
it("keeps manager integration for credential fields", () => {
const html = render(
<PasswordInput id="current-password" autoComplete="current-password" />,
);
expect(html).toMatch(/autocomplete="current-password"/i);
expect(html).not.toContain("data-bwignore");
expect(html).not.toContain("data-1p-ignore");
expect(html).not.toContain("data-lpignore");
});
});
+49
View File
@@ -0,0 +1,49 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
* Auth-resolving placeholder for a top-level page.
*
* Every module page renders while `useAuth()` / its first query settles. The
* legacy pattern was a lone centered `Loader2` that then snapped into the full
* layout — a reflow, and inconsistent with the skeleton-first surfaces
* (documents, Insights tiles, dashboard hero). This reserves the page frame
* instead: a `PageHeader`-shaped title/description block above a tile grid, so
* the auth-resolving frame holds the same geometry the loaded page will.
*
* Presentational + `aria-hidden` (matching the old spinner, which announced
* nothing); pass `label` to expose an `sr-only` status line for screen readers.
*/
export function PageAuthGate({
className,
label,
tiles = 3,
}: {
className?: string;
label?: string;
/** Number of content tile skeletons below the header. */
tiles?: number;
}) {
return (
<div
data-slot="page-auth-gate"
className={cn("space-y-6", className)}
aria-hidden="true"
>
{label ? (
<span className="sr-only" aria-live="polite" aria-hidden={false}>
{label}
</span>
) : null}
<div className="space-y-1.5">
<Skeleton className="h-8 w-48 max-w-full rounded" />
<Skeleton className="h-4 w-72 max-w-full rounded" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: tiles }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-xl" />
))}
</div>
</div>
);
}
+14
View File
@@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export function PasswordInput({
className,
autoComplete,
...props
}: React.ComponentProps<typeof Input>) {
const [visible, setVisible] = useState(false);
const { t } = useTranslations();
// Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no
// `autoComplete` — they are not credentials, so a password manager offering
// to fill or *save* them is noise and risks clobbering the field. Suppress
// Bitwarden / 1Password / LastPass by default for those. Credential fields
// (login, register, password change) pass a real token
// (`current-password` / `new-password` / `username`) and keep manager
// integration; only an explicit `"off"` opts back into suppression.
const suppressManagers = autoComplete == null || autoComplete === "off";
return (
<div className="relative">
<Input
{...props}
autoComplete={autoComplete ?? "off"}
data-bwignore={suppressManagers ? "" : undefined}
data-1p-ignore={suppressManagers ? "" : undefined}
data-lpignore={suppressManagers ? "true" : undefined}
type={visible ? "text" : "password"}
className={cn("pr-12", className)}
/>
+1 -1
View File
@@ -40,7 +40,7 @@ export function QueryErrorCard({
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
>
<AlertTriangle
className="text-muted-foreground/60 h-8 w-8"
className="text-muted-foreground h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>