refactor(insights): consolidate sub-page data-fetch and empty state

This commit is contained in:
Marc-André Bombeck committed 2026-05-16 01:58:16 +02:00
1 parent 0e7c97c567
commit 8c89ddaca7
9 files changed
+277 -186

No files matched your search

+17 -36
View File
@@ -1,22 +1,20 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
@@ -33,56 +31,39 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
* v1.4.27 MB6 — the previous `/measurements/new` href hit a 404; the
* measurements page now consumes `?add=<TYPE>` and auto-opens the
* dialog with the matching default type.
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — the analytics fetch and the
* empty-state render were lifted into `useInsightsAnalytics()` and
* `<MetricEmptyState>`; the page module is now Apple-Health-lean.
*/
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
export default function InsightsBlutdruckPage() {
const { isAuthenticated, user } = useAuth();
const { user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { compareBaseline } = useInsightsLayoutPrefs(user != null);
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("blood-pressure");
const { data: analytics } = 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 AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
if (
isAuthenticated &&
analytics &&
!hasMetricData("BLOOD_PRESSURE_SYS", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
if (isEmpty) {
return (
<SubPageShell title={t("insights.bloodPressureSectionTitle")}>
<EmptyState
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
>
<MetricEmptyState
icon={<HeartPulse className="size-6" />}
title={t("insights.emptyState.bloodPressure.title")}
description={t("insights.emptyState.bloodPressure.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/measurements?add=BLOOD_PRESSURE">
{t("insights.emptyState.bloodPressure.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+20 -35
View File
@@ -1,11 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
@@ -13,13 +13,8 @@ import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
/**
* v1.4.25 W4 — `/insights/bmi`.
@@ -29,6 +24,11 @@ interface AnalyticsData {
* underlying WEIGHT series is reused. When the user has no height set
* the chart can't compute; we surface the same plain empty-state the
* mother page used.
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + the
* weight-not-logged-yet branch consume the shared hook + primitive.
* The "height not set" branch keeps its bespoke `<EmptyState>` because
* the CTA targets `/settings/account`, not the measurement onboarding.
*/
const BMI_BANDS = [
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
@@ -45,17 +45,7 @@ export default function InsightsBmiPage() {
const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
const { data: analytics } = 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 AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const { isEmpty } = useInsightsAnalytics("BMI");
// v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings
// exist yet, the existing "set your height" branch can never compute
@@ -63,32 +53,24 @@ export default function InsightsBmiPage() {
// `/measurements?add=WEIGHT` instead so the user logs the weight
// first. v1.4.27 MB6 — query-param replaces the dead
// `/measurements/new` route.
if (
isAuthenticated &&
analytics &&
!hasMetricData("BMI", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
if (isEmpty) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
<EmptyState
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
>
<MetricEmptyState
icon={<Ruler className="size-6" />}
title={t("insights.emptyState.bmi.title")}
description={t("insights.emptyState.bmi.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/measurements?add=WEIGHT">
{t("insights.emptyState.bmi.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
coachPrefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
/>
</SubPageShell>
);
@@ -96,7 +78,10 @@ export default function InsightsBmiPage() {
if (!user?.heightCm) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
>
<EmptyState
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
+14 -35
View File
@@ -1,26 +1,20 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
/**
* v1.4.25 W4 — `/insights/gewicht`.
@@ -30,6 +24,9 @@ interface AnalyticsData {
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
* trend lines + comparison overlay independently from the dashboard
* weight card.
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
*/
export default function InsightsGewichtPage() {
const { isAuthenticated, user } = useAuth();
@@ -39,44 +36,26 @@ export default function InsightsGewichtPage() {
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("weight");
const { data: analytics } = 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 AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const { isEmpty } = useInsightsAnalytics("WEIGHT");
if (
isAuthenticated &&
analytics &&
!hasMetricData("WEIGHT", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
if (isEmpty) {
return (
<SubPageShell title={t("insights.weightSectionTitle")}>
<EmptyState
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
>
<MetricEmptyState
icon={<Scale className="size-6" />}
title={t("insights.emptyState.weight.title")}
description={t("insights.emptyState.weight.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/measurements?add=WEIGHT">
{t("insights.emptyState.weight.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
coachPrefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
/>
</SubPageShell>
);
+16 -35
View File
@@ -1,26 +1,24 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import type { DataSummary } from "@/lib/analytics/trends";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4 — `/insights/puls`.
@@ -31,11 +29,12 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
* comparison-overlay independently from the dashboard pulse card; the
* MeasurementType filter is `PULSE` (the same field used elsewhere
* in the codebase).
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* branch consume `useInsightsAnalytics()` + `<MetricEmptyState>`. The
* VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so
* the hook exposes the unwrapped payload alongside `isEmpty`.
*/
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
export default function InsightsPulsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
@@ -48,17 +47,7 @@ export default function InsightsPulsPage() {
// bundle the mother page reads. Sharing the cache key keeps the
// payload single-fetch on tab navigation (React-Query unwraps from
// the same key).
const { data: analytics } = 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 AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
@@ -67,32 +56,24 @@ export default function InsightsPulsPage() {
// `/measurements?add=PULSE` rather than a chart skeleton over a
// blank target band. v1.4.27 MB6 — query-param replaces the dead
// `/measurements/new` route.
if (
isAuthenticated &&
analytics &&
!hasMetricData("PULSE", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
if (isEmpty) {
return (
<SubPageShell title={t("insights.pulseSectionTitle")}>
<EmptyState
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
>
<MetricEmptyState
icon={<Heart className="size-6" />}
title={t("insights.emptyState.pulse.title")}
description={t("insights.emptyState.pulse.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/measurements?add=PULSE">
{t("insights.emptyState.pulse.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+14 -37
View File
@@ -1,18 +1,15 @@
"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 { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import type { DataSummary } from "@/lib/analytics/trends";
import { hasMetricData } from "@/lib/insights/metric-availability";
/**
* v1.4.25 W4c — `/insights/schlaf`.
@@ -26,53 +23,33 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
* Apple-Health / Withings sleep rows yet), the page short-circuits
* to an empty-state CTA pointing at `/settings/data-sources` so the
* user can connect a sleep source.
*
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
* render consume the shared hook + primitive.
*/
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
export default function InsightsSchlafPage() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data: analytics } = 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 AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
if (
isAuthenticated &&
analytics &&
!hasMetricData("SLEEP_DURATION", {
summaries: analytics.summaries,
hasMood: false,
hasMedication: false,
})
) {
if (isEmpty) {
return (
<SubPageShell title={t("insights.sleep.title")}>
<EmptyState
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
>
<MetricEmptyState
icon={<Moon className="size-6" />}
title={t("insights.emptyState.sleep.title")}
description={t("insights.emptyState.sleep.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/settings/data-sources">
{t("insights.emptyState.sleep.cta")}
</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
coachPrefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
/>
</SubPageShell>
);
+13 -8
View File
@@ -10,10 +10,10 @@ import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -64,22 +64,27 @@ export default function InsightsStimmungPage() {
// `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated
// mood-logging surface) — short-circuits the user to the quickest
// path to log their first entry.
//
// v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared
// `<MetricEmptyState>` primitive. The mood data path stays on
// `/api/insights/comprehensive` because the `moodSummary.count`
// signal is event-driven, not sensor-aggregated.
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell title={t("insights.moodSectionTitle")}>
<EmptyState
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
>
<MetricEmptyState
icon={<Smile className="size-6" />}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
ctaSize="lg"
action={
cta={
<Button size="sm" asChild>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
/>
<CoachLaunchButton
prefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
@@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.4.28 R3d (BK-F-M1 + BK-MED-4) — shared empty-state primitive for
* the insights sub-pages.
*
* Pre-fix, every sub-page repeated the same five-line incantation:
*
* <EmptyState icon={…} title={…} description={…} ctaSize="lg" action={…} />
* <CoachLaunchButton prefill={…} />
*
* The action slot was always a `<Button asChild><Link href="…">…</Link></Button>`
* pointing into `/measurements?add=<TYPE>` (or `/mood` / `/medications`
* for the event-driven metrics). Some sub-pages forgot to mount the
* `<CoachLaunchButton>` underneath; BK-MED-4 wanted every sub-page to
* expose a Coach launch on the empty state because the conversational
* affordance is exactly the right onboarding nudge when the user hasn't
* logged data yet.
*
* This primitive consolidates both responsibilities. Consumers pass:
*
* - the `icon` + `title` + `description` strings (already localised),
* - the primary CTA node (typically a `<Button asChild>` link),
* - the optional Coach `prefill` so a tap on "Ask the Coach" seeds
* the next turn with the metric-specific context.
*
* The Coach launch always renders (BK-MED-4). Pass `coachPrefill={null}`
* to use the default prompt; pass a string to seed a metric-specific
* onboarding question.
*/
export interface MetricEmptyStateProps {
/** Icon node (typically a `lucide-react` glyph). */
icon: ReactNode;
/** Empty-state headline (already localised). */
title: string;
/** One-line scaffold beneath the headline (already localised). */
description: string;
/** Primary CTA — usually a `<Button asChild><Link/></Button>`. */
cta: ReactNode;
/**
* Optional Coach prefill. `null` falls back to the shared default
* prompt the `<CoachLaunchButton>` ships. Pass a metric-specific
* sentence ("I haven't recorded any blood pressure yet — …") to
* seed the first Coach turn with onboarding context.
*/
coachPrefill?: string | null;
}
export function MetricEmptyState({
icon,
title,
description,
cta,
coachPrefill,
}: MetricEmptyStateProps) {
return (
<>
<EmptyState
icon={icon}
title={title}
description={description}
ctaSize="lg"
action={cta}
/>
<CoachLaunchButton prefill={coachPrefill ?? undefined} />
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
"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 type { SubPageAnalyticsData } from "@/types/analytics";
/**
* v1.4.28 R3d (BK-F-H1) — shared React-Query wrapper for the analytics
* payload behind every insights sub-page.
*
* The five hard-data sub-pages (`puls`, `blutdruck`, `gewicht`, `bmi`,
* `schlaf`) each used to declare:
*
* - a private `AnalyticsData` interface,
* - a `useQuery({ queryKey: ["analytics"], staleTime: 60_000 })` block,
* - an `if (isAuthenticated && analytics && !hasMetricData(...)) { … }`
* short-circuit that rendered a sub-page-specific empty state.
*
* Those three are now collapsed onto this hook:
*
* - the query reuses the same `["analytics"]` cache key as the
* dashboard + the insights mother page, so navigating between
* surfaces is a free cache hit;
* - `isEmpty` runs `hasMetricData()` with the caller's metric so
* each sub-page checks the right gate without re-importing the
* helper;
* - the typed result keeps the existing `data` slot so any caller
* that still needs a richer field (today: none — the sub-pages
* only read `summaries`) can fall back to direct access.
*
* The mood and medication sub-pages still need richer payloads
* (`/api/insights/comprehensive`) so they don't consume this hook —
* those keep their bespoke fetch but adopt the same `<MetricEmptyState>`
* primitive for the empty-state render so the visual contract is one.
*/
export interface UseInsightsAnalyticsResult {
data: SubPageAnalyticsData | undefined;
isLoading: boolean;
/**
* True when the analytics payload arrived AND the metric has no
* observations. Sub-pages render the empty-state primitive when this
* flips true. False while the query is still in-flight (we don't
* want to flash the empty state before the data lands).
*/
isEmpty: boolean;
error: Error | null;
}
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,
});
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
!hasMetricData(metric, {
summaries: query.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.
hasMood: false,
hasMedication: false,
});
return {
data: query.data,
isLoading: query.isLoading,
isEmpty,
error: query.error as Error | null,
};
}
+23
View File
@@ -0,0 +1,23 @@
/**
* v1.4.28 R3d (BK-F-M1) — shared shape for the trimmed `/api/analytics`
* payload that every insights sub-page consumes.
*
* Pre-fix, each of the seven `/insights/<metric>/page.tsx` modules
* declared its own `interface AnalyticsData { summaries: Record<...> }`
* inline. They were structurally identical so the TanStack-Query cache
* unified anyway, but maintaining seven copies meant every iOS-contract
* addition (e.g. a new `MeasurementType`) showed up only after the
* matching sub-page was touched. Hoisting the interface here is a
* single-place change.
*
* NOTE: the dashboard's `AnalyticsData` (in `src/app/page.tsx`) is a
* wider type — it carries `bpInTargetPct*` aggregates the sub-pages
* don't use. The dashboard keeps its own declaration; we'd lose more
* than we gain by trying to merge them.
*/
import type { DataSummary } from "@/lib/analytics/trends";
export interface SubPageAnalyticsData {
/** `Record<MeasurementType, DataSummary>` — sentinel value for "not loaded yet" is `undefined`. */
summaries: Record<string, DataSummary>;
}