diff --git a/messages/de.json b/messages/de.json index 4abc23b6f..566b80169 100644 --- a/messages/de.json +++ b/messages/de.json @@ -170,7 +170,7 @@ "admin": "Admin-Konsole", "logout": "Abmelden", "login": "Anmelden", - "notifications": "Benachrichtigungen", + "notifications": "Benachrichtigungs-Center", "theme": "Darstellung", "themeSystem": "System", "themeDark": "Dunkel", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog und weitere verbundene Dienste." }, "notifications": { - "title": "Benachrichtigungen", + "title": "Benachrichtigungs-Kanäle", "description": "Live-Übersicht aller konfigurierten Kanäle." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert." }, "notifications": { - "title": "Benachrichtigungen", + "title": "Benachrichtigungs-Center", + "breadcrumbInbox": "Benachrichtigungs-Center", + "breadcrumbChannels": "Benachrichtigungs-Kanäle", + "breadcrumbSettings": "Einstellungen", "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.", - "goToSettings": "Zu den Einstellungen", + "goToSettings": "Zu den Kanal-Einstellungen", "channelDisabled": "Deaktiviert", "admin": { "deployFailedTitle": "Bereitstellung fehlgeschlagen: {application}", diff --git a/messages/en.json b/messages/en.json index 05c96729c..422389975 100644 --- a/messages/en.json +++ b/messages/en.json @@ -170,7 +170,7 @@ "admin": "Admin Console", "logout": "Sign out", "login": "Sign in", - "notifications": "Notifications", + "notifications": "Notification Center", "theme": "Appearance", "themeSystem": "System", "themeDark": "Dark", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Notifications", + "title": "Notification channels", "description": "Live overview of every configured channel." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Notifications", + "title": "Notification Center", + "breadcrumbInbox": "Notification Center", + "breadcrumbChannels": "Notification channels", + "breadcrumbSettings": "Settings", "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.", - "goToSettings": "Go to Settings", + "goToSettings": "Go to channel settings", "channelDisabled": "Disabled", "admin": { "deployFailedTitle": "Deployment failed: {application}", diff --git a/messages/es.json b/messages/es.json index 072956e69..d46361032 100644 --- a/messages/es.json +++ b/messages/es.json @@ -170,7 +170,7 @@ "admin": "Admin Console", "logout": "Sign out", "login": "Iniciar sesión", - "notifications": "Notificaciones", + "notifications": "Centro de notificaciones", "theme": "Apariencia", "themeSystem": "Sistema", "themeDark": "Oscuro", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Notificaciones", + "title": "Canales de notificación", "description": "Live overview of every configured channel." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Notificaciones", + "title": "Centro de notificaciones", + "breadcrumbInbox": "Centro de notificaciones", + "breadcrumbChannels": "Canales de notificación", + "breadcrumbSettings": "Ajustes", "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.", - "goToSettings": "Go to Settings", + "goToSettings": "Go to channel settings", "channelDisabled": "Desactivado", "admin": { "deployFailedTitle": "Despliegue fallido: {application}", diff --git a/messages/fr.json b/messages/fr.json index 1f32a3c87..c4d03f817 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -170,7 +170,7 @@ "admin": "Admin Console", "logout": "Sign out", "login": "Se connecter", - "notifications": "Notifications", + "notifications": "Centre de notifications", "theme": "Apparence", "themeSystem": "Système", "themeDark": "Sombre", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Notifications", + "title": "Canaux de notification", "description": "Live overview of every configured channel." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Notifications", + "title": "Centre de notifications", + "breadcrumbInbox": "Centre de notifications", + "breadcrumbChannels": "Canaux de notification", + "breadcrumbSettings": "Réglages", "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.", - "goToSettings": "Go to Settings", + "goToSettings": "Go to channel settings", "channelDisabled": "Désactivé", "admin": { "deployFailedTitle": "Échec du déploiement : {application}", diff --git a/messages/it.json b/messages/it.json index aa96d2f21..18fa14418 100644 --- a/messages/it.json +++ b/messages/it.json @@ -170,7 +170,7 @@ "admin": "Admin Console", "logout": "Sign out", "login": "Accedi", - "notifications": "Notifiche", + "notifications": "Centro notifiche", "theme": "Aspetto", "themeSystem": "Sistema", "themeDark": "Scuro", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Notifiche", + "title": "Canali di notifica", "description": "Live overview of every configured channel." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Notifiche", + "title": "Centro notifiche", + "breadcrumbInbox": "Centro notifiche", + "breadcrumbChannels": "Canali di notifica", + "breadcrumbSettings": "Impostazioni", "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.", - "goToSettings": "Go to Settings", + "goToSettings": "Go to channel settings", "channelDisabled": "Disabilitato", "admin": { "deployFailedTitle": "Distribuzione fallita: {application}", diff --git a/messages/pl.json b/messages/pl.json index f815e202e..7b60dab1d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -170,7 +170,7 @@ "admin": "Admin Console", "logout": "Sign out", "login": "Zaloguj się", - "notifications": "Powiadomienia", + "notifications": "Centrum powiadomień", "theme": "Wygląd", "themeSystem": "Systemowy", "themeDark": "Ciemny", @@ -1506,7 +1506,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Powiadomienia", + "title": "Kanały powiadomień", "description": "Live overview of every configured channel." }, "dashboard": { @@ -2713,12 +2713,15 @@ "disabledBody": "The administrator has disabled bug reports on this server." }, "notifications": { - "title": "Powiadomienia", + "title": "Centrum powiadomień", + "breadcrumbInbox": "Centrum powiadomień", + "breadcrumbChannels": "Kanały powiadomień", + "breadcrumbSettings": "Ustawienia", "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.", - "goToSettings": "Go to Settings", + "goToSettings": "Go to channel settings", "channelDisabled": "Wyłączone", "admin": { "deployFailedTitle": "Wdrożenie nie powiodło się: {application}", diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 1d4ab703c..3f9e44d84 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { Bell, Loader2, Settings, AlertCircle } from "lucide-react"; +import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react"; import { EVENT_DEFAULT_ENABLED, type EventType, @@ -33,6 +33,42 @@ interface PreferencesData { eventTypes: string[]; } +/** + * Breadcrumb strip for the notification surfaces. v1.4.33 IW7 introduced + * this so the inbox (`/notifications`) and the channel-config section + * (`/settings/notifications`) never read as the same page — the two names + * used to collide as plain "Notifications" in both nav labels. The crumb + * sits above the h1 on every render branch of this page (loading, + * unauthenticated, error, empty, populated) so the user always sees the + * disambiguation, even when the body content is a fallback state. + */ +function NotificationsBreadcrumb({ + t, +}: { + t: (key: string) => string; +}) { + return ( + + ); +} + /** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */ function eventTranslationKey(eventType: string): string { return ( @@ -137,6 +173,7 @@ export default function NotificationsPage() { if (!isAuthenticated) { return (
+

{t("notifications.title")}

@@ -154,6 +191,7 @@ export default function NotificationsPage() { if (isError) { return (
+

{t("notifications.title")} @@ -176,6 +214,7 @@ export default function NotificationsPage() { if (activeChannels.length === 0) { return (
+

{t("notifications.title")} @@ -204,6 +243,7 @@ export default function NotificationsPage() { return (
+

{t("notifications.title")} diff --git a/src/app/page.tsx b/src/app/page.tsx index 20ef15191..2d52cc45a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -62,6 +62,7 @@ const MedicationComplianceChart = dynamic( ); import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { DataSummary } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { @@ -202,17 +203,14 @@ export default function DashboardPage() { refetchOnWindowFocus: false, } as const; - const { data } = useQuery({ - queryKey: queryKeys.analytics(), - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); + // v1.4.33 IW2 — the dashboard tile-strip reads `bpInTargetPct*` + + // `glucoseByContext` (thick-only fields) so it stays on the default + // thick slice. The shared hook still centralises the cache settings + // (60s staleTime, no refetch on mount / window focus) so the + // tile-strip dedups with the Insights mother page on the same + // queryKey + slice combination. + const analyticsQuery = useAnalyticsQuery(); + const data = analyticsQuery.data as AnalyticsData | undefined; const { data: layoutData } = useQuery({ queryKey: queryKeys.dashboardWidgets(), diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index a2d96c27d..9e7c65ffe 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -8,8 +8,8 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { InsightInputs } from "@/lib/insights/metric-availability"; -import type { DataSummary } from "@/lib/analytics/trends"; /** * v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`. @@ -22,19 +22,16 @@ import type { DataSummary } from "@/lib/analytics/trends"; * same cache entry without extra network traffic. * * v1.4.27 F19 — also owns the analytics + comprehensive reads that - * power the tab-strip availability gate. Both queries share their - * cache keys with the sub-page consumers (`["analytics"]` and - * `["insights", "comprehensive"]`) so the cost stays one fetch per - * route, dedup-shared across consumers. + * power the tab-strip availability gate. The analytics consumer goes + * through the shared `useAnalyticsQuery({ slice: "summaries" })` hook + * (v1.4.33 IW2); the comprehensive consumer still owns its bespoke + * fetch but shares its cache key with the sub-page consumers so the + * payload lands once per route, dedup-shared across consumers. * * CRITICAL — the `` does NOT mount here. It lives only in * `src/app/insights/page.tsx` body so navigating into a sub-page * unmounts the drawer. */ -interface AnalyticsPayload { - summaries?: Record; -} - interface ComprehensivePayload { moodSummary: { count: number } | null; medications: Array<{ id: string }>; @@ -59,18 +56,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { isAuthenticated && flags.enabled && flags.briefing; const advisor = useInsightsAdvisorQuery(advisorEnabled); - // Shared analytics fetch — sub-pages consume the same cache key. - const analyticsQuery = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsPayload; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + // Shared analytics fetch — the layout shell only reads + // `summaries[METRIC].count` for the tab-strip availability gate, so + // it lands on IW1's slim `?slice=summaries` branch (2 SQL passes, + // no correlations / health-score / bp-in-target tail). + const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); // Shared comprehensive fetch — mood + medication signals for the // event-driven gating branches. Sub-pages read the same key so the diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index dd46e17a8..f9d1fe494 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -1,12 +1,12 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Moon } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; @@ -67,16 +67,14 @@ export function SleepOverview() { const { t, locale } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { data, isLoading } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed to load analytics"); - const json = await res.json(); - return json.data as SleepAnalyticsResponse; - }, - enabled: isAuthenticated, - }); + // v1.4.33 IW2 — sleep overview reads `sleepStages` (a thick-only + // field) alongside `summaries.SLEEP_DURATION`, so it stays on the + // default thick slice. The shared hook still centralises the cache + // settings (60s staleTime, no refetch on mount / window focus) so + // the consumer behaves identically to every other analytics mount. + const analyticsQuery = useAnalyticsQuery(); + const data = analyticsQuery.data as SleepAnalyticsResponse | undefined; + const isLoading = analyticsQuery.isLoading; const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null; const sleepStages = data?.sleepStages ?? null; diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 31dc0c70a..123e599fa 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -18,6 +18,7 @@ import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { buildChecklist, checklistProgress, @@ -184,25 +185,18 @@ export function GettingStartedChecklist() { // Light-weight queries: each fetch is small and cached by tanstack. // We rely on the React Query cache the dashboard already uses for // analytics, so this won't fire a second request when the dashboard - // and the checklist mount together. Critically, every queryFn here - // must return `json.data` (the unwrapped shape) so it matches the - // canonical consumer (dashboard / medications page / notifications - // page / integrations section). Returning `res.json()` would write - // the full `{data, error}` envelope into the shared cache, which then - // makes the canonical consumers' `data?.summaries` / `data?.length` - // reads silently return undefined — that's how the v1.4.2 dashboard - // ended up rendering only the mood tile. - const analyticsQuery = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data; - }, + // and the checklist mount together. v1.4.33 IW2 routes the checklist + // onto IW1's slim `?slice=summaries` branch — the only field + // consumed is `summaries[METRIC].count`, which the slim slice fills + // directly. The cache slot is distinct from the dashboard's thick + // slice but each consumer's payload is byte-correct for its own + // reads, and the shared hook keeps the legacy `?.data` unwrap so the + // canonical consumers still see `{summaries, …}` not `{data: …}`. + const analyticsQuery = useAnalyticsQuery({ + slice: "summaries", enabled: !!user, }); - const analyticsData = analyticsQuery.data; + const analyticsData = analyticsQuery.data as AnalyticsData | undefined; const { data: medsData } = useQuery>({ queryKey: ["medications"], diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index e6f26321f..74bb76f67 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -136,9 +136,12 @@ describe("", () => { const html = renderShell({ active: "account", locale: "en" }); expect(html).toContain("Account"); expect(html).toContain("Integrations"); - expect(html).toContain("Notifications"); + // v1.4.33 IW7 — section renamed from "Notifications" to + // "Notification channels" so it doesn't collide with the inbox at + // `/notifications` ("Notification Center"). + expect(html).toContain("Notification channels"); expect(html).toContain("Dashboard"); - expect(html).toContain("AI Insights"); + expect(html).toContain("Insights"); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. expect(html).toContain("API & Tokens"); @@ -153,14 +156,19 @@ describe("", () => { const html = renderShell({ active: "account", locale: "de" }); expect(html).toContain("Konto"); expect(html).toContain("Integrationen"); - expect(html).toContain("Benachrichtigungen"); + // v1.4.33 IW7 — section renamed from "Benachrichtigungen" to + // "Benachrichtigungs-Kanäle" so it doesn't collide with the inbox at + // `/notifications` ("Benachrichtigungs-Center"). + expect(html).toContain("Benachrichtigungs-Kanäle"); // v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now // "Dashboard" (matching the term users see in the main nav). The // per-metric overrides moved out into their own "Persönliche Zielwerte" // section, which is the new entry below the Dashboard one. expect(html).toContain("Dashboard"); expect(html).toContain("Persönliche Zielwerte"); - expect(html).toContain("KI-Auswertungen"); + // v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per + // the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy). + expect(html).toContain("Auswertungen"); // API & Tokens is identical in both locales (proper noun + ampersand) expect(html).toContain("API & Tokens"); expect(html).toContain("Erweitert"); diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 0b2bc37eb..aa6317538 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,6 +1,8 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; @@ -40,6 +42,32 @@ export function NotificationsSection() { aria-labelledby="settings-section-notifications-title" className="space-y-6" > + {/* v1.4.33 IW7 — disambiguate the channel-config screen from the + inbox at `/notifications`. Both surfaces used to be named plain + "Notifications", so the crumb spells out where the user is and + offers a one-tap jump to the inbox. */} +

{ queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); // Every chart/band depends on these thresholds — invalidate everything. - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("thresholds.saveSuccess")); }, @@ -107,7 +108,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("thresholds.resetSuccess")); }, diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 157284086..ed3ffd7b8 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -216,7 +217,7 @@ function TargetEditSheetBody({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.saveSuccess")); onOpenChange(false); @@ -248,7 +249,7 @@ function TargetEditSheetBody({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts index 9c490c838..8debe56f9 100644 --- a/src/hooks/use-insights-analytics.ts +++ b/src/hooks/use-insights-analytics.ts @@ -1,10 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; - import { useAuth } from "@/hooks/use-auth"; import { hasMetricData, type InsightMetric } from "@/lib/insights/metric-availability"; -import { queryKeys } from "@/lib/query-keys"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { SubPageAnalyticsData } from "@/types/analytics"; /** @@ -53,24 +51,20 @@ export function useInsightsAnalytics( metric: InsightMetric, ): UseInsightsAnalyticsResult { const { isAuthenticated } = useAuth(); - - const query = useQuery({ - queryKey: queryKeys.analytics(), - queryFn: async (): Promise => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: SubPageAnalyticsData }; - return json.data; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + // v1.4.33 IW2 — sub-pages only read `summaries[METRIC].count` for the + // gating short-circuit, so they ride on the slim slice. The mother + // page keeps the default thick slice for `correlations` / + // `healthScore` / `bpInTargetPct*`; both consumers live in the same + // queryKey tree (root `["analytics"]`) but the slim slice has its own + // cache slot under `["analytics", "summaries"]`. + const query = useAnalyticsQuery({ slice: "summaries" }); + const data = query.data as SubPageAnalyticsData | undefined; const isEmpty = Boolean(isAuthenticated) && - query.data !== undefined && + data !== undefined && !hasMetricData(metric, { - summaries: query.data.summaries, + summaries: data.summaries, // Sub-pages that go through this hook are sensor-backed only // (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_SYS / SLEEP_DURATION). // Mood + medication run their own gate. @@ -79,7 +73,7 @@ export function useInsightsAnalytics( }); return { - data: query.data, + data, isLoading: query.isLoading, isEmpty, error: query.error as Error | null, diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 104922644..949299f3f 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -22,6 +22,18 @@ describe("queryKeys factory", () => { "en", ]); }); + + // v1.4.33 IW2 — slice param routes the slim consumer onto its own + // cache slot but leaves `["analytics"]` (the root) as a prefix so + // mutation invalidations sweep both shapes. + it("threads the slim slice through queryKeys.analytics", () => { + expect(queryKeys.analytics("summaries")).toEqual(["analytics", "summaries"]); + }); + + it("keeps the no-arg call byte-identical to the legacy literal", () => { + expect(queryKeys.analytics()).toEqual(["analytics"]); + expect(queryKeys.analytics(undefined)).toEqual(["analytics"]); + }); }); describe("dependent-key bundles", () => { diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts new file mode 100644 index 000000000..a7397e4c5 --- /dev/null +++ b/src/lib/queries/use-analytics-query.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import type { DataSummary } from "@/lib/analytics/trends"; +import type { SubPageAnalyticsData } from "@/types/analytics"; + +/** + * v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`. + * + * Pre-fix, seven mount sites each declared `useQuery({ queryKey: + * ["analytics"], … })` with inconsistent `staleTime` / `enabled` / + * `refetchOnMount` options. TanStack treats the first-mount wins → the + * earliest consumer's options govern the shared cache cell, but every + * route-change re-mount still spins up a fresh subscription tree and + * any consumer without an `enabled: isAuthenticated` short-circuit + * re-issues the network on a back-forward swing. The audit + * (`.planning/round-v1433-audit-perf.md` §1) ranks this as the top-1 + * ROI win. + * + * The shared hook: + * - centralises the queryKey via `queryKeys.analytics(slice)`, + * - sets `staleTime: 60_000` so a route swing within a minute is a + * free cache hit, + * - sets `refetchOnMount: false` + `refetchOnWindowFocus: false` so + * route transitions never trigger a refetch storm, + * - defaults to `enabled: isAuthenticated` so the unauthenticated + * surfaces (registration, sign-in) don't spin up a 401-bound + * request, + * - lets the caller opt into the slim `?slice=summaries` server + * branch shipped in IW1 / C1 when the consumer only needs the + * per-type DataSummary headlines (sub-pages, gating helpers, + * onboarding checklist). + * + * The dashboard's mother query keeps the default slice because it also + * reads `bpInTargetPct*` and `glucoseByContext` — those slots stay + * stubbed on the slim path per the IW1 follow-up. + */ + +/** + * Strict structural subset shared between the slim slice and the thick + * default payload. Consumers that only read `summaries` should type + * the hook return as `AnalyticsSlicePayload` (or import + * `SubPageAnalyticsData` directly — same shape, kept aliased for + * call-site clarity). + */ +export type AnalyticsSlicePayload = SubPageAnalyticsData; + +/** + * Discriminator for the server-side slice. `undefined` (the default) + * routes onto the thick payload; `"summaries"` routes onto IW1's + * `?slice=summaries` branch. + */ +export type AnalyticsSlice = "summaries" | undefined; + +/** + * The unwrapped `data` field returned by the API. Typed as a wide + * shape so the dashboard's `bpInTargetPct*` / `glucoseByContext` + * consumers can narrow without a runtime cast. The slim slice fills + * the optional thick fields with `null` / empty so call-site reads + * remain undefined-safe. + */ +export interface AnalyticsRawPayload { + summaries: Record; + // Thick-only fields — present on the default slice, stubbed on the + // slim branch. Optional so the type covers both shapes. + bpInTargetPct?: number | null; + bpInTargetPct7d?: number | null; + bpInTargetPct30d?: number | null; + bpInTargetPctAllTime?: number | null; + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: number | null; + glucoseByContext?: Record; + correlations?: unknown; + healthScore?: unknown; + sleepStages?: unknown; + // Keep the contract open — additive server fields don't need a + // type bump every release. + [key: string]: unknown; +} + +export interface UseAnalyticsQueryOptions { + /** + * `"summaries"` — hit IW1's slim slice (2 SQL passes, no + * correlations / health-score / bp-in-target / sleep-stages). + * Omit (or pass `undefined`) for the thick default payload. + */ + slice?: AnalyticsSlice; + /** + * Optional override for the auth gate. Defaults to + * `isAuthenticated`. The Insights mother page can pass extra + * disabled-flag composition without losing the auth gate. + */ + enabled?: boolean; +} + +async function fetchAnalytics( + slice: AnalyticsSlice, +): Promise { + const url = + slice === "summaries" ? "/api/analytics?slice=summaries" : "/api/analytics"; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to load analytics (${res.status})`); + } + const json = (await res.json()) as { data: AnalyticsRawPayload }; + return json.data; +} + +/** + * Shared hook. Returns the raw `data` payload typed as + * `AnalyticsRawPayload`. Consumers can narrow at the call site (e.g. + * `const summaries = query.data?.summaries;`). + */ +export function useAnalyticsQuery( + options: UseAnalyticsQueryOptions = {}, +): UseQueryResult { + const { isAuthenticated } = useAuth(); + const slice = options.slice; + const enabled = + options.enabled !== undefined ? options.enabled : isAuthenticated; + + return useQuery({ + queryKey: queryKeys.analytics(slice), + queryFn: () => fetchAnalytics(slice), + enabled, + staleTime: 60_000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index fa5838635..3d016bb68 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -16,7 +16,17 @@ export const queryKeys = { measurements: () => ["measurements"] as const, moodEntries: () => ["mood-entries"] as const, - analytics: () => ["analytics"] as const, + /** + * v1.4.33 IW2 — the analytics queryKey now optionally carries a + * `slice` discriminator so the dashboard tile-strip can subscribe to + * the slim `?slice=summaries` server slice (IW1 / C1) without + * colliding with the thick-payload consumers on the Insights tree. + * Calling `queryKeys.analytics()` without a slice keeps the legacy + * shape `["analytics"]` so mutation invalidations and the bulk-key + * lists below stay byte-identical. + */ + analytics: (slice?: "summaries") => + (slice ? (["analytics", slice] as const) : (["analytics"] as const)), moodAnalytics: () => ["mood-analytics"] as const, insightsRoot: () => ["insights"] as const,