{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,