From 2dc9947de213843f887b20ebc1405428d9853e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:35:41 +0200 Subject: [PATCH 01/11] fix(dashboard): link metric tiles to Insights detail + unify tile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard metric tiles link to their /insights/ detail page (weight, blood pressure, pulse, glucose, mood, sleep, steps, VO2 max); tiles with no dedicated sub-page stay un-linked rather than misroute. - Daily-briefing key findings now route hrv, resting HR, active energy, flights, distance, body temperature and recovery to their sub-pages — the METRIC_HREF map had gone stale as those routes shipped. - RecentWorkoutsTile adopts the canonical TileHeader (foreground icon + title) instead of a muted hand-rolled row. - Hero-strip header glyph moves off text-primary to the foreground tier. --- src/app/page-client.tsx | 79 ++++++++++++++----- .../dashboard/recent-workouts-tile.tsx | 35 ++++---- .../__tests__/daily-briefing.test.tsx | 30 +++++++ src/components/insights/daily-briefing.tsx | 22 +++--- src/components/insights/hero-strip.tsx | 5 +- 5 files changed, 124 insertions(+), 47 deletions(-) diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 158f85062..955a0ba96 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/dashboard-layout"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; @@ -131,6 +132,30 @@ import { resolveChartRowPlaceholderCount, } from "@/components/dashboard/dashboard-gates"; +/** + * Dashboard metric tiles are no longer dead-ends: each tile links to its + * Insights detail page (bands, correlations, history). Keyed by the stable + * tile `id` used in the `trendCards` builder; glucose contexts share one + * blood-glucose destination via the `glucose-` prefix. Tiles with no + * dedicated sub-page (body fat %) stay un-linked rather than misroute. + */ +const TILE_INSIGHT_HREF: Record = { + weight: "/insights/weight", + "bp-sys": "/insights/blood-pressure", + "bp-dia": "/insights/blood-pressure", + bpInTarget: "/insights/blood-pressure", + pulse: "/insights/pulse", + mood: "/insights/mood", + sleep: "/insights/sleep", + steps: "/insights/steps", + vo2Max: "/insights/cardio-fitness", +}; + +function tileInsightHref(id: string): string | null { + if (id.startsWith("glucose-")) return "/insights/blood-glucose"; + return TILE_INSIGHT_HREF[id] ?? null; +} + export default function DashboardPageClient() { const { isAuthenticated, user } = useAuth(); const mounted = useMounted(); @@ -1611,27 +1636,45 @@ export default function DashboardPageClient() { data-tour-id="dashboard-tile-strip" data-tile-count={trendCards.length} > - {trendCards.map((entry) => ( -
- {/* - * Per-tile `` boundary with a layout-stable - * placeholder that mirrors the trend-card chrome. Tile - * bodies are synchronous today so the fallback rarely - * paints, but a future RSC hoist of any tile slot - * would otherwise leave the grid track empty and - * trigger CLS as the cell paints in. - * - * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder. - */} - {/* v1.16.0 — the fallback is the same structured - silhouette the tile-strip skeleton paints, so a - suspending tile slot previews the final shape - instead of an empty card. */} + {trendCards.map((entry) => { + /* + * Per-tile `` boundary with a layout-stable + * placeholder that mirrors the trend-card chrome. Tile + * bodies are synchronous today so the fallback rarely + * paints, but a future RSC hoist of any tile slot + * would otherwise leave the grid track empty and + * trigger CLS as the cell paints in. + * + * v1.16.0 — the fallback is the same structured + * silhouette the tile-strip skeleton paints, so a + * suspending tile slot previews the final shape + * instead of an empty card. + */ + const cell = ( }> {entry.node} -
- ))} + ); + // A tile is no longer a dead-end: it links to its Insights + // detail page. The link fills the grid cell and paints a + // ring on hover / focus so the whole tile reads as tappable. + const href = tileInsightHref(entry.id); + return href ? ( + + {cell} + + ) : ( +
+ {cell} +
+ ); + })} )} {charts.map((entry) => ( diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a4557b5c0..ffc508ce9 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -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"; /** * v1.4.32 — ``. @@ -107,23 +108,23 @@ export function RecentWorkoutsTile() { "bg-card border-border space-y-3 rounded-xl border p-4 md:p-6", )} > -
-
-
- - {t("dashboard.recentWorkouts.viewAll")} - -
+ {/* 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. */} + + {t("dashboard.recentWorkouts.viewAll")} + + } + /> {isLoading ? ( // v1.4.43 W11-L6 — reserve roughly the loaded tile height diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 8db6630e4..25aa60033 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -95,6 +95,36 @@ describe("", () => { 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 `` links, not inert rows. + const html = render( + , + ); + 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(); expect(html).toMatch(/data-slot="daily-briefing-delta"/); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index ce3da3cff..65eebed90 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -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", }; /** diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index d97dbf8b9..24570cf17 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -284,8 +284,11 @@ export function HeroStrip({
+ {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 + reserves primary/accent for actions, not decorative header + icons. */}

Date: Wed, 8 Jul 2026 23:39:59 +0200 Subject: [PATCH 02/11] fix(notifications): repoint channel CTA, add error/loading primitives, cross-link surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The 'no channels' CTA now points at Settings → Integrations#channels (where channels are actually configured since v1.25.7) instead of the reminder-types screen, which had become a dead-end. - A failed load renders the shared QueryErrorCard with Retry; the loading frame reserves a matrix skeleton instead of a lone spinner. - The globally-disabled hint reads as a full sentence rather than the bare '(Disabled)' inline tag. - The three notification surfaces (per-event matrix, reminder types, delivery channels) now cross-link each other. - noChannels copy names its destination. --- messages/de.json | 14 +- messages/en.json | 14 +- messages/es.json | 14 +- messages/fr.json | 14 +- messages/it.json | 14 +- messages/pl.json | 14 +- src/app/notifications/__tests__/page.test.tsx | 127 ++++++++++++++++++ src/app/notifications/page.tsx | 71 ++++++++-- .../settings/integrations-section.tsx | 11 ++ .../settings/notifications-section.tsx | 17 +++ 10 files changed, 273 insertions(+), 37 deletions(-) create mode 100644 src/app/notifications/__tests__/page.test.tsx diff --git a/messages/de.json b/messages/de.json index 5073c6e7b..42965e6ba 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/messages/en.json b/messages/en.json index e85e5c5fc..0deafe73f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/messages/es.json b/messages/es.json index 80d3c7b3c..bf2109424 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/messages/fr.json b/messages/fr.json index 5bd017305..3a211914e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/messages/it.json b/messages/it.json index 933289bb6..51dee5cd3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/messages/pl.json b/messages/pl.json index fc43f1599..50016525c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4055,14 +4055,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", @@ -6268,7 +6271,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": { @@ -6379,7 +6382,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": { diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx new file mode 100644 index 000000000..4306c4522 --- /dev/null +++ b/src/app/notifications/__tests__/page.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * Notification-matrix page contract (QoL/UX audit 2026-07-08): + * - H2: the "no channels" CTA points at Settings → Integrations#channels, + * the surface that can actually add a channel (not the reminder-types + * screen, which no longer configures channels since v1.25.7). + * - M3: a failed load renders the shared `QueryErrorCard` with a Retry, + * never a bare alert box; the loading frame reserves a skeleton. + * - L1: the globally-disabled hint reads as a full sentence, not the bare + * "(Disabled)" inline tag. + * - M7: the page cross-links the other two notification surfaces. + */ + +type AuthState = { isAuthenticated: boolean; isLoading: boolean }; +type QueryState = { + data: unknown; + isLoading: boolean; + isError: boolean; +}; + +let authState: AuthState = { isAuthenticated: true, isLoading: false }; +let queryState: QueryState = { data: undefined, isLoading: false, isError: false }; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authState, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ ...queryState, refetch: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ + cancelQueries: vi.fn(), + getQueryData: vi.fn(), + setQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), +})); + +import NotificationsPage from "../page"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + authState = { isAuthenticated: true, isLoading: false }; + queryState = { data: undefined, isLoading: false, isError: false }; +}); + +describe("NotificationsPage", () => { + it("M3: renders a skeleton while loading, not a lone spinner", () => { + queryState.isLoading = true; + const html = render(); + expect(html).toContain('data-slot="notifications-loading"'); + expect(html).not.toContain("animate-spin"); + }); + + it("M3: renders the shared QueryErrorCard with a retry on load failure", () => { + queryState.isError = true; + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain("Notification preferences could not be loaded."); + }); + + it("H2 + L4: the no-channels CTA targets Settings → Integrations#channels", () => { + queryState.data = { channels: [], preferences: [], eventTypes: [] }; + const html = render(); + expect(html).toContain('href="/settings/integrations#channels"'); + // The reminder-types screen no longer configures channels — never point there. + expect(html).not.toContain('href="/settings/notifications"><'); + expect(html).toContain("Settings → Integrations"); + }); + + it("M7: the matrix cross-links the reminder-types + channels surfaces", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "telegram", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + ], + preferences: [], + eventTypes: ["MEDICATION_REMINDER"], + }; + const html = render(); + expect(html).toContain('href="/settings/notifications"'); + expect(html).toContain('href="/settings/integrations#channels"'); + }); + + it("L1: the globally-disabled hint is a full sentence, not the bare tag", () => { + queryState.data = { + channels: [ + { + id: "c1", + type: "telegram", + label: "Telegram", + enabled: true, + globallyEnabled: true, + }, + { + id: "c2", + type: "ntfy", + label: "ntfy", + enabled: true, + globallyEnabled: false, + }, + ], + preferences: [], + eventTypes: ["MEDICATION_REMINDER"], + }; + const html = render(); + expect(html).toContain( + "won’t receive notifications", + ); + }); +}); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4078419e7..f5c64067d 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -7,8 +7,10 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/ui/page-header"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import Link from "next/link"; -import { Bell, Loader2, Settings, AlertCircle } from "lucide-react"; +import { Bell, Settings, AlertCircle } from "lucide-react"; import { EVENT_DEFAULT_ENABLED, type EventType, @@ -52,7 +54,7 @@ export default function NotificationsPage() { const { t } = useTranslations(); const queryClient = useQueryClient(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); @@ -119,10 +121,50 @@ export default function NotificationsPage() { return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true; } + // Cross-links to the other two notification surfaces (reminder types + + // delivery channels) so the matrix never reads as the only place to manage + // notifications. Mirrored by back-links on those two settings screens. + const headerActions = ( + <> + + + + ); + if (authLoading || isLoading) { return ( -
- +
+ + {/* Skeleton mirrors the event-row matrix so the auth-resolving frame + reserves layout instead of flashing a lone spinner. */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
); } @@ -149,12 +191,10 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} /> -
- {t("notifications.loadError")} -
+ refetch()} + />
); } @@ -166,6 +206,7 @@ export default function NotificationsPage() {
@@ -173,8 +214,10 @@ export default function NotificationsPage() {

{t("notifications.noChannels")}

+ {/* Channel setup lives in Settings → Integrations since v1.25.7; + point the CTA at that anchor, not the reminder-types screen. */}
- {/* Info hint about disabled channels */} + {/* Info hint about globally-disabled channels — a full sentence that + explains the state, not the bare "(Disabled)" inline tag. */} {channels.some((ch) => !ch.globallyEnabled) && (

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

)} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 21a537a54..4d9a34839 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -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. */} +
+ +
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 8ba29af03..05389e73a 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -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"; /** * `` — Settings → Notifications ("Benachrichtigungen"). @@ -44,6 +46,21 @@ export function NotificationsSection() { return (
+ {/* Cross-links to the other two notification surfaces so this screen + names them — the per-event matrix and the delivery channels. */} +
+ + +
+ {/* 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. */} From dbc2b15b1b7af585ea66d4aa70395787e7414a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:44:10 +0200 Subject: [PATCH 03/11] fix(coach,insights,achievements): honest error states + Plans discoverability - Achievements: a failed load renders QueryErrorCard instead of falling through to zeroed summary tiles and a 'none unlocked yet' empty grid. - /coach/conversations: a load failure now shows a retry instead of a false 'no conversations yet' empty state. - Insights overview error uses the shared QueryErrorCard. - Coach Plans gains a header cross-link from /coach/conversations (and back), a skeleton loading state, and the shared EmptyState primitive. --- src/app/achievements/page.tsx | 18 ++++++++++- src/app/coach/conversations/page.tsx | 22 ++++++++++++-- src/app/coach/plans/page.tsx | 45 ++++++++++++++++++---------- src/app/insights/page.tsx | 25 +++++----------- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 6e4c2fae1..dc9724cae 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -33,6 +33,7 @@ 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 { 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,7 +272,7 @@ export default function AchievementsPage() { // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading } = useAchievementsQuery({ + const { data, isLoading, isError, refetch } = useAchievementsQuery({ enabled: isAuthenticated && achievementsEnabled, }); @@ -302,6 +303,21 @@ export default function AchievementsPage() { ); } + // A failed load must never fall through to the zeroed summary tiles + the + // "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest + // retry instead. + if (isError) { + return ( +
+ + refetch()} /> +
+ ); + } + const summary = data?.summary; const achievements = data?.achievements ?? []; const grouped = groupByCategory(achievements); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 6110786cb..3c55a37c3 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -1,12 +1,14 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { MessagesSquare, Search, Trash2 } from "lucide-react"; +import { MessagesSquare, Search, Target, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { useCoachConversations, @@ -92,7 +94,8 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] { function CoachConversationsBody() { const { t } = useTranslations(); const router = useRouter(); - const { conversations, isLoading } = useCoachConversations(); + const { conversations, isLoading, isError, refetch } = + useCoachConversations(); const deleteMutation = useDeleteCoachConversation(); const [filter, setFilter] = useState(""); const [confirmId, setConfirmId] = useState(null); @@ -129,6 +132,16 @@ function CoachConversationsBody() { {t("insights.coach.historyTitle")} } + actions={ + // Surface the sibling Plans ledger — otherwise it is reachable + // only from the composer's `+` menu. + + } />
{t("common.loading")}

+ ) : isError && conversations.length === 0 ? ( + // A load failure must not masquerade as "no conversations yet" — + // that reads as a data-loss scare and offers no way back. Mirror + // /coach/plans and surface a retry. + refetch()} /> ) : groups.length === 0 ? (
{t("coach.plans.title")} } description={t("coach.plans.pageDescription")} + actions={ + + } />
query.refetch()} /> ) : query.isLoading ? ( -

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

- ) : groups.length === 0 ? ( + ) : groups.length === 0 ? ( +
+ } + title={t("coach.plans.empty")} + />
) : ( groups.map((group) => ( diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 66ef264ed..8340bd4b5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { RefreshCw, SlidersHorizontal, TrendingUp } from "lucide-react"; +import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -20,6 +20,7 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; @@ -340,23 +341,11 @@ export default function InsightsPage() { // mirroring the error pattern. if (isError) { return ( -
- {t("insights.loadError")} - +
+ refetch()} + />
); } From 9f4c7e48fe5efe316cdfcabef8c4dbba2acd3fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:47:46 +0200 Subject: [PATCH 04/11] fix(ui): bake password-manager autofill guard into PasswordInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config/secret fields (BYOK API keys, webhook secrets, ntfy tokens) pass no autocomplete token, so PasswordInput now defaults to autoComplete=off plus data-bwignore / data-1p-ignore / data-lpignore for them — Bitwarden / 1Password / LastPass no longer offer to fill or save a config secret. Credential fields (login, register, password change) pass a real token and keep manager integration untouched. One change covers every secret call site. --- .../ui/__tests__/password-input.test.tsx | 44 +++++++++++++++++++ src/components/ui/password-input.tsx | 14 ++++++ 2 files changed, 58 insertions(+) create mode 100644 src/components/ui/__tests__/password-input.test.tsx diff --git a/src/components/ui/__tests__/password-input.test.tsx b/src/components/ui/__tests__/password-input.test.tsx new file mode 100644 index 000000000..dadb1389d --- /dev/null +++ b/src/components/ui/__tests__/password-input.test.tsx @@ -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( + {node}, + ); +} + +describe("PasswordInput", () => { + it("suppresses password managers by default for config secrets", () => { + const html = render(); + 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(); + expect(html).toContain("data-bwignore"); + expect(html).toContain("data-1p-ignore"); + }); + + it("keeps manager integration for credential fields", () => { + const html = render( + , + ); + 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"); + }); +}); diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx index 7e1ac7625..e9149c3ce 100644 --- a/src/components/ui/password-input.tsx +++ b/src/components/ui/password-input.tsx @@ -22,14 +22,28 @@ import { useTranslations } from "@/lib/i18n/context"; */ export function PasswordInput({ className, + autoComplete, ...props }: React.ComponentProps) { 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 (
From 930fa8112450ee8b53fca9e7caa7db28b2324d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:51:22 +0200 Subject: [PATCH 05/11] fix(a11y): switch names, contrast floors, and chart-toggle pressed state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin SettingsToggle: name the Radix switch via aria-label so it no longer announces as a bare 'switch, on/off' (one shared component, six call sites). - Replace the phantom text-destructive-foreground / text-warning-foreground utilities with the vetted destructive pattern (bg-destructive text-white dark:bg-destructive/60) and a readable foreground on the warning wash — no more dead classes riding on cascade order. - Drop the sub-AA opacity tiers: lab sparkline + titration marker off muted/70, state icons (query-error, chart empty/error, med sort) off muted/60 to the full token so they clear the light-theme floor. - Chart range toggles expose aria-pressed (lab, custom-metric, health, mood, compliance) so the active window is conveyed to assistive tech. --- src/components/admin/_shared.tsx | 5 +++++ src/components/charts/chart-empty-state.tsx | 2 +- src/components/charts/chart-error-state.tsx | 2 +- src/components/charts/compliance-line-chart.tsx | 1 + src/components/charts/health-chart.tsx | 1 + src/components/charts/mood-chart.tsx | 1 + src/components/custom-metrics/custom-metric-chart.tsx | 1 + src/components/labs/lab-biomarker-chart.tsx | 1 + src/components/labs/lab-trend-sparkline.tsx | 2 +- src/components/medications/dose-history-ledger.tsx | 2 +- src/components/medications/intake-history-editable.tsx | 4 ++-- src/components/medications/medication-table.tsx | 2 +- src/components/medications/titration-timeline.tsx | 2 +- src/components/settings/integrations/withings-card.tsx | 2 +- src/components/settings/sharing-section.tsx | 2 +- src/components/ui/query-error-card.tsx | 2 +- 16 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index b9c8ff1aa..38d907201 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -192,6 +192,11 @@ export function SettingsToggle({
` with no association to the + // Radix switch button, so a screen reader would announce a bare + // "switch, on/off". Name it explicitly — one fix covers every admin + // toggle that mounts this shared component. + aria-label={label} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index fe71164ad..790521df9 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -56,7 +56,7 @@ export function ChartEmptyState({ style={height === undefined ? undefined : { height }} >
) : isError ? ( -
-
-

- {t("medications.loadFailed")} -

- -
-
+ void refetchMedications()} + /> ) : !medications?.length ? ( // v1.4.15 phase-C5: refactor the inline icon+text+button block // to the shared EmptyState primitive so the empty path matches diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 1f170814c..b809e8412 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -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}

- - -

- {t("customMetrics.loadError")} -

- -
-
+ { + if (metricError) void refetchMetric(); + if (listError) void refetchList(); + }} + />
); } diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index e9492c6df..b7669cc0c 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -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(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), queryFn: () => apiGet("/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() { ) : isError ? ( -

- {t("customMetrics.loadError")} -

+ refetch()} + /> ) : metrics.length === 0 ? ( diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 834437e05..f28e71e0a 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -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 `` footer slot. The submit button keeps // its `
` association via the HTML `form` attribute. const [footerEl, setFooterEl] = useState(null); + const [pendingDeleteId, setPendingDeleteId] = useState(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} > @@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)} + {/* Themed confirm — replaces the native window.confirm so the delete + gate matches every other destructive action in the app. */} + { + if (!o) setPendingDeleteId(null); + }} + > + + + + {t("medications.sideEffects.deleteConfirmTitle")} + + + {t("medications.sideEffects.deleteConfirmBody")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId); + setPendingDeleteId(null); + }} + disabled={deleteMutation.isPending} + aria-busy={deleteMutation.isPending || undefined} + className="bg-destructive dark:bg-destructive/60 text-white hover:bg-destructive/90" + > + {t("medications.sideEffects.deleteCta")} + + + + + Date: Wed, 8 Jul 2026 23:59:10 +0200 Subject: [PATCH 07/11] fix(onboarding,integrations,cycle): deep-link source chips + cycle settings wrench - Onboarding 'more sources' chips anchor to their provider card (/settings/integrations#oura etc.); connection cards gain scroll anchors. Apple Health (no web card) stays on the page root. - Cycle module gains a header settings wrench for parity with labs / medications / illness; the tab strip is now controlled so the wrench jumps to the settings tab and ?tab= deep-links keep working. --- src/components/cycle/cycle-view.tsx | 47 +++++++++++++++---- .../onboarding/source-card-grid.tsx | 10 +++- .../integrations/connections-panel.tsx | 30 +++++++++--- 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 8665fe1e4..0930fda56 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -1,9 +1,15 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, 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,14 @@ 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; a `?tab=` deep-link change still switches too. + const [tab, setTab] = useState(initialTab); + useEffect(() => { + setTab(initialTab); + }, [initialTab]); + const today = useMemo(() => shiftToday(0), []); const from = useMemo(() => shiftToday(-90), []); const to = useMemo(() => shiftToday(180), []); @@ -158,13 +172,26 @@ export function CycleView() { title={{t("cycle.title")}} description={t("cycle.subtitle")} actions={ - + <> + + + } /> @@ -266,7 +293,7 @@ export function CycleView() { ) : null}
- + 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 diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index de0f4375c..6ba4305d9 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -170,7 +170,15 @@ export function SourceCardGrid() {
  • @@ -319,6 +325,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { values={group.readings.map((r) => r.value)} />
    + {/* 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 ? ( +

    + {t("labs.notLinkedYet")} +

    + ) : null} From 96c99f4dfab272919252b2a399e2047fdf56d098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:13:08 +0200 Subject: [PATCH 09/11] fix(medications): render intake dialogs as bottom sheets on mobile Port the six medication dialogs (log-intake, intake-edit, dose-history-add, take-all-due, log-injection-site, intake-import) from the centred Dialog to ResponsiveSheet with the sticky-footer slot, matching every other module's add/edit form. Form-backed dialogs associate their footer submit button via a useId form id so Enter-to-submit and the footer button share one form; the nested slot-nudge confirm rides the same sheet. Primary actions now sit within thumb reach on phones. --- .../medications/dose-history-add-dialog.tsx | 113 ++++++++--------- .../medications/intake-edit-dialog.tsx | 66 +++++----- .../medications/intake-import-dialog.tsx | 114 +++++++++--------- .../medications/log-injection-site-dialog.tsx | 67 +++++----- .../medications/log-intake-dialog.tsx | 75 ++++++------ .../medications/take-all-due-dialog.tsx | 85 ++++++------- 6 files changed, 234 insertions(+), 286 deletions(-) diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 49587bea0..c2b332f24 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -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,22 +194,40 @@ export function LedgerAddDialog({ } return ( - !busy && onOpenChange(o)}> - - - - {t("medications.detail.verlauf.addDialog.title")} - - - {t("medications.detail.verlauf.addDialog.description")} - - - + !busy && onOpenChange(o)} + title={t("medications.detail.verlauf.addDialog.title")} + description={t("medications.detail.verlauf.addDialog.description")} + footer={ + <> + + + + } + > {/* 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. */} + the buttons carry explicit types so cancel never submits. */} { e.preventDefault(); @@ -263,49 +275,17 @@ export function LedgerAddDialog({

    )}
    - - - - - {/* The "diesem Slot zuordnen?" nudge — pin onto the slot, or keep the take as a standalone ad-hoc entry. */} {nudgeOpen && nearest && ( - !busy && setNudgeOpen(o)} - > - - - - {t("medications.detail.verlauf.nudge.title")} - - - {t("medications.detail.verlauf.nudge.body", { - slot: nearest.slotHm, - })} - - - + title={t("medications.detail.verlauf.nudge.title")} + footer={ + <> - - - + + } + > +

    + {t("medications.detail.verlauf.nudge.body", { + slot: nearest.slotHm, + })} +

    + )} - - + ); } diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index e01ab2feb..59489a485 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -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,16 +140,38 @@ function IntakeEditDialogBody({ } return ( - !open && onClose()}> - - - - {t("medications.detail.intake.edit.dialogTitle")} - - + !open && onClose()} + title={t("medications.detail.intake.edit.dialogTitle")} + footer={ + <> + + + + } + > {/* v1.16.4 — a real form so Enter in the datetime field submits; the buttons carry explicit types so cancel never submits. */}
    { e.preventDefault(); @@ -208,24 +225,7 @@ function IntakeEditDialogBody({ /> - - - -
    -
    -
    + ); } diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index 591bf85eb..62589547f 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -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,12 +138,58 @@ export function IntakeImportDialog({ } return ( - handleClose()}> - - - {t("medications.importIntakes")} - -
    + handleClose()} + title={t("medications.importIntakes")} + contentWidth="lg" + footer={ + <> + + + + + + + + {t("common.reset")} + + + + + + + } + > +

    {t("medications.importDescription")}

    @@ -194,54 +235,7 @@ export function IntakeImportDialog({ {result}

    )} -
    - - - - - - - - {t("common.reset")} - - - - -
    - - -
    -
    - -
    + ); } diff --git a/src/components/medications/log-injection-site-dialog.tsx b/src/components/medications/log-injection-site-dialog.tsx index 5ea4eeed1..a1f1fb09d 100644 --- a/src/components/medications/log-injection-site-dialog.tsx +++ b/src/components/medications/log-injection-site-dialog.tsx @@ -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 ( - { // Hold the dialog while the PATCH is in flight so a backdrop tap / @@ -93,18 +86,31 @@ export function LogInjectionSiteDialog({ if (submitting) return; if (!next) onSkip(); }} + title={t("medications.logInjectionSiteTitle")} + description={t("medications.logInjectionSiteDescription", { + name: medicationName, + })} + footer={ + <> + + + + } > - - - {t("medications.logInjectionSiteTitle")} - - {t("medications.logInjectionSiteDescription", { - name: medicationName, - })} - - - - {allowed.length === 0 ? ( + {allowed.length === 0 ? (

    {t("medications.logInjectionSiteNoneAvailable")}

    @@ -161,25 +167,6 @@ export function LogInjectionSiteDialog({ )} - - - - - -
    -
    + ); } diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 73ef6af5e..88e83f3c2 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -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( () => medications[0]?.id ?? "", @@ -208,18 +202,39 @@ export function LogIntakeDialog({ } return ( - !busy && onOpenChange(o)}> - - - {t("medications.logIntake.dialogTitle")} - - {t("medications.logIntake.dialogDescription")} - - - + !busy && onOpenChange(o)} + title={t("medications.logIntake.dialogTitle")} + description={t("medications.logIntake.dialogDescription")} + footer={ + <> + + + + } + > {/* v1.16.4 — a real form so Enter in the dose / datetime fields submits; mirrors the intake-edit and dose-history-add dialogs. */}
    { e.preventDefault(); if (busy || medications.length === 0 || !medicationId) return; @@ -326,29 +341,7 @@ export function LogIntakeDialog({ )} - - - - -
    -
    -
    + ); } diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx index 7a2913d31..3954d2760 100644 --- a/src/components/medications/take-all-due-dialog.tsx +++ b/src/components/medications/take-all-due-dialog.tsx @@ -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 ( - { // Refuse to close while the take loop is in flight. if (submitting && !next) return; onOpenChange(next); }} - > - - - {t("medications.takeAllDue.dialogTitle")} - - {t("medications.takeAllDue.dialogDescription")} - - -
      - {dueMedications.map((med) => ( -
    • - - {med.name} - {med.dose ? ( - - {" "} - — {formatDose(med.dose, t)} - - ) : null} - - {med.window ? ( - - {formatTimeWindowRange( - med.window.start, - med.window.end, - locale, - )} - - ) : null} -
    • - ))} -
    - + title={t("medications.takeAllDue.dialogTitle")} + description={t("medications.takeAllDue.dialogDescription")} + footer={ + <>
    + + } + > +
      + {dueMedications.map((med) => ( +
    • + + {med.name} + {med.dose ? ( + + {" "} + — {formatDose(med.dose, t)} + + ) : null} + + {med.window ? ( + + {formatTimeWindowRange( + med.window.start, + med.window.end, + locale, + )} + + ) : null} +
    • + ))} +
    + ); } From 23ded20306c0e7cb8bd5812605ae8f5aacd1b311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 00:23:13 +0200 Subject: [PATCH 10/11] fix(pages): replace bare auth-gate spinners with a layout-reserving skeleton Introduce a shared PageAuthGate (header + tile skeleton) and adopt it on the top-level pages that flashed a lone centered spinner while auth resolved: mood, measurements, achievements, illness, mental-wellbeing, checkups, cycle, and the labs / medications / custom-metrics / illness detail routes. The auth-resolving frame now reserves the page geometry instead of snapping in after a spinner. --- src/app/achievements/page.tsx | 8 +- src/app/checkups/page.tsx | 8 +- src/app/coach/conversations/page.tsx | 5 +- src/app/coach/plans/page.tsx | 9 +- src/app/custom-metrics/[id]/page.tsx | 8 +- src/app/cycle/page.tsx | 16 +- src/app/illness/[id]/page.tsx | 8 +- src/app/illness/page.tsx | 8 +- src/app/labs/[biomarkerId]/page.tsx | 8 +- src/app/measurements/page.tsx | 9 +- src/app/medications/[id]/page.tsx | 16 +- src/app/mental-wellbeing/page.tsx | 8 +- src/app/mood/page.tsx | 9 +- src/app/notifications/__tests__/page.test.tsx | 10 +- src/app/page-client.tsx | 2 +- src/components/cycle/cycle-view.tsx | 9 +- .../medications/dose-history-add-dialog.tsx | 168 +++++++-------- .../medications/dose-history-ledger.tsx | 2 +- .../medications/intake-edit-dialog.tsx | 110 +++++----- .../medications/intake-history-editable.tsx | 4 +- .../medications/intake-import-dialog.tsx | 84 ++++---- .../medications/log-injection-site-dialog.tsx | 108 +++++----- .../medications/log-intake-dialog.tsx | 200 +++++++++--------- .../medications/side-effects-section.tsx | 2 +- .../medications/take-all-due-dialog.tsx | 48 ++--- .../settings/notifications-section.tsx | 5 +- src/components/settings/sharing-section.tsx | 2 +- src/components/ui/page-auth-gate.tsx | 49 +++++ 28 files changed, 465 insertions(+), 458 deletions(-) create mode 100644 src/components/ui/page-auth-gate.tsx diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index dc9724cae..603726acd 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -13,7 +13,6 @@ import { Heart, KeyRound, Languages, - Loader2, Lock, LogIn, Moon, @@ -33,6 +32,7 @@ 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"; @@ -277,11 +277,7 @@ export default function AchievementsPage() { }); if (authLoading || (achievementsEnabled && isLoading)) { - return ( -
    - -
    - ); + return ; } // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 162843a3c..2105fb54b 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,13 +1,13 @@ "use client"; import { useCallback } from "react"; -import { Loader2 } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; +import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; import { queryKeys } from "@/lib/query-keys"; @@ -35,11 +35,7 @@ export default function CheckupsPage() { const pull = usePullToRefresh({ onRefresh: refresh }); if (!mounted || isLoading) { - return ( -
    - -
    - ); + return ; } return ( diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 3c55a37c3..e5df84226 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -136,7 +136,10 @@ function CoachConversationsBody() { // Surface the sibling Plans ledger — otherwise it is reachable // only from the composer's `+` menu. - - - } +