mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
refactor(insights): consolidate sub-page data-fetch and empty state
This commit is contained in:
1 parent
0e7c97c567
commit
8c89ddaca7
9 files changed
+277
-186
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user