mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user