mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
3a5f7debdb
commit
523ee0c7d1
19 files changed
+337
-112
No files matched your search
+7
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 & 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 & 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"));
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user