fix(notifications): disambiguate inbox vs channel-config naming

Both the `/notifications` inbox and the Settings -> Notifications
section used to read as plain "Notifications" in every nav surface, so
the user-card dropdown and the settings sidebar looked like two pointers
at the same destination. Rename the inbox to "Notification Center" and
the settings section to "Notification channels"; add a small breadcrumb
to either surface so the user always knows which page they landed on
and can jump to the sibling in one tap.

Locale parity across de / en / es / fr / it / pl.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:49:31 +02:00
1 parent 3a5f7debdb
commit 523ee0c7d1
19 files changed
+337 -112

No files matched your search

+7 -4
View File
@@ -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}",
+7 -4
View File
@@ -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}",
+7 -4
View File
@@ -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}",
+7 -4
View File
@@ -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}",
+7 -4
View File
@@ -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}",
+7 -4
View File
@@ -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}",
+41 -1
View File
@@ -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 (
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
<li className="text-foreground font-medium">
{t("notifications.breadcrumbInbox")}
</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li>
<Link
href="/settings/notifications"
className="hover:text-foreground underline-offset-2 hover:underline"
>
{t("notifications.breadcrumbChannels")}
</Link>
</li>
</ol>
</nav>
);
}
/** 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 (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
@@ -154,6 +191,7 @@ export default function NotificationsPage() {
if (isError) {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
@@ -176,6 +214,7 @@ export default function NotificationsPage() {
if (activeChannels.length === 0) {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
@@ -204,6 +243,7 @@ export default function NotificationsPage() {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
+9 -11
View File
@@ -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(),
@@ -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 `<CoachDrawer>` 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<string, DataSummary>;
}
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
+9 -11
View File
@@ -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;
@@ -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<AnalyticsData>({
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<Array<{ id: string }>>({
queryKey: ["medications"],
@@ -136,9 +136,12 @@ describe("<SettingsShell>", () => {
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 &amp; Tokens");
@@ -153,14 +156,19 @@ describe("<SettingsShell>", () => {
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 &amp; Tokens");
expect(html).toContain("Erweitert");
@@ -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. */}
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
<li>{t("notifications.breadcrumbSettings")}</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li className="text-foreground font-medium">
{t("notifications.breadcrumbChannels")}
</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li>
<Link
href="/notifications"
className="hover:text-foreground underline-offset-2 hover:underline"
>
{t("notifications.breadcrumbInbox")}
</Link>
</li>
</ol>
</nav>
<header className="space-y-1">
<h1
id="settings-section-notifications-title"
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
METRIC_BOUNDS,
type ThresholdMetric,
@@ -87,7 +88,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
onSuccess: () => {
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"));
},
+3 -2
View File
@@ -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);
+12 -18
View File
@@ -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<SubPageAnalyticsData> => {
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,
+12
View File
@@ -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", () => {
+133
View File
@@ -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<string, DataSummary>;
// 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<string, unknown>;
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<AnalyticsRawPayload> {
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<AnalyticsRawPayload, Error> {
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,
});
}
+11 -1
View File
@@ -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,