mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(insights): scaffold 6 metric sub-pages with shared layout shell
Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).
The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.
Shared infrastructure:
- src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
+ SUB_PAGE_SLUGS canonical order.
- src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
layout payload so every sub-page picks up the comparisonBaseline
without re-fetching.
- src/components/insights/sub-page-shell.tsx: title + optional badge
+ focusable h1 (a11y — screen-reader focus restoration on tab
navigation).
- src/components/insights/insights-layout-shell.tsx: the layout's
client shell that mounts the strip + threads the regenerate handler.
The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
This commit is contained in:
1 parent
2668020d92
commit
38bbba110a
13 files changed
+1187
-109
No files matched your search
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/blutdruck`.
|
||||
*
|
||||
* Routed Blood-Pressure sub-page. The mother page hosts the overview
|
||||
* (hero + briefing + advisor); this page concentrates on BP-only depth:
|
||||
* the BP chart with its chart-cog overlays, the per-section AI
|
||||
* assessment, and the chart's target zones tied to the user's age.
|
||||
*
|
||||
* Empty-state rule (Marc directive): if the user has no BP data, the
|
||||
* chart's own empty-state takes over — no extra "no data" CTAs stack
|
||||
* on top of the chart skeleton.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface BloodPressureStatusData {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export default function InsightsBlutdruckPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "blood-pressure-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/blood-pressure-status?locale=${locale}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as BloodPressureStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const bpTargets =
|
||||
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
|
||||
const bpTargetZones = bpTargets
|
||||
? [
|
||||
{
|
||||
min: bpTargets.sysLow,
|
||||
max: bpTargets.sysHigh,
|
||||
color: "#ff79c6",
|
||||
opacity: 0.21,
|
||||
label: t("charts.systolic"),
|
||||
textColor: "#ff79c6",
|
||||
lineOpacity: 0.24,
|
||||
},
|
||||
{
|
||||
min: bpTargets.diaLow,
|
||||
max: bpTargets.diaHigh,
|
||||
color: "#8be9fd",
|
||||
opacity: 0.21,
|
||||
label: t("charts.diastolic"),
|
||||
textColor: "#8be9fd",
|
||||
lineOpacity: 0.24,
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.bloodPressureSectionTitle")}
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<HeartPulse className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Ruler } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
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 { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/bmi`.
|
||||
*
|
||||
* Routed BMI sub-page. BMI is derived from `WEIGHT / (height/100)^2`,
|
||||
* so the chart sets `valueMode="bmi"` on `<HealthChart>` and the
|
||||
* 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.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface BmiStatusData {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const BMI_BANDS = [
|
||||
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
|
||||
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
|
||||
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
|
||||
];
|
||||
|
||||
export default function InsightsBmiPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "bmi-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/insights/bmi-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as BmiStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (!user?.heightCm) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.bmiSectionTitle")}>
|
||||
<EmptyState
|
||||
icon={<Ruler className="size-6" />}
|
||||
title={t("insights.bmiEmptyTitle")}
|
||||
description={t("insights.bmiEmptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/account">
|
||||
{t("insights.bmiEmptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={BMI_BANDS}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Ruler className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Scale } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/gewicht`.
|
||||
*
|
||||
* Routed Weight sub-page. Renders the weight chart with the user's
|
||||
* height-derived green/orange/red bands plus the per-section AI
|
||||
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
|
||||
* trend lines + comparison overlay independently from the dashboard
|
||||
* weight card.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface WeightStatusData {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export default function InsightsGewichtPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "weight-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/insights/weight-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as WeightStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const weightBands = user?.heightCm
|
||||
? buildWeightBandsFromHeight(user.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.weightSectionTitle")}
|
||||
description={t("insights.subPage.gewichtDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Scale className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
|
||||
*
|
||||
* Hosts the sticky `<InsightsTabStrip>` so the metric pills + (mother-
|
||||
* page-only) regenerate affordance stay reachable while a user drills
|
||||
* into a sub-page. The actual page body still owns its own data fetches
|
||||
* — the layout is a thin presentational frame.
|
||||
*
|
||||
* Critical design rule (Marc directive 2026-05-11):
|
||||
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
|
||||
* `src/app/insights/page.tsx` body so navigating into a sub-page
|
||||
* unmounts the drawer (matches the Apple Health convention that AI/
|
||||
* coach surfaces only live on the overview, never on a metric page).
|
||||
*/
|
||||
export default function InsightsLayout({ children }: { children: ReactNode }) {
|
||||
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Loader2, Pill } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/medikamente`.
|
||||
*
|
||||
* Routed Medication-Compliance sub-page. Mirrors the per-medication
|
||||
* grid from the v1.4.24 mother page (compliance bars + heatmap +
|
||||
* per-med AI sentence) plus the section-level AI assessment.
|
||||
*
|
||||
* No `<HealthChart>` here — medication compliance is event-driven, so
|
||||
* the data path runs through `/api/insights/comprehensive` +
|
||||
* `/api/medications/[id]/compliance` instead of the measurement-series
|
||||
* Prisma path.
|
||||
*
|
||||
* Empty-state: zero medications → big "Add your first medication" CTA.
|
||||
*/
|
||||
|
||||
interface MedicationEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER";
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
streak: number;
|
||||
taken7: number;
|
||||
skipped7: number;
|
||||
missed7: number;
|
||||
}
|
||||
|
||||
interface ComprehensiveMedicationData {
|
||||
medications: MedicationEntry[];
|
||||
}
|
||||
|
||||
interface MedicationComplianceStatusData {
|
||||
hasProvider: boolean;
|
||||
summary: string | null;
|
||||
medications: Array<{ medicationId: string; text: string }>;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface MedicationDailyData {
|
||||
expected: number;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
onTime?: number;
|
||||
late?: number;
|
||||
veryLate?: number;
|
||||
}
|
||||
|
||||
interface MedicationComplianceDailyResponse {
|
||||
dailyCompliance: Record<string, MedicationDailyData>;
|
||||
}
|
||||
|
||||
export default function InsightsMedikamentePage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveMedicationData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "medication-compliance-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const medications = comprehensive?.medications ?? [];
|
||||
const medicationSummaryById = new Map(
|
||||
(status?.medications ?? []).map((entry) => [
|
||||
entry.medicationId,
|
||||
entry.text,
|
||||
]),
|
||||
);
|
||||
|
||||
if (isComprehensiveLoading) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.medicationCompliance")}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (medications.length === 0) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.medicationCompliance")}>
|
||||
<EmptyState
|
||||
icon={<Pill className="size-6" />}
|
||||
title={t("insights.subPage.medikamenteEmptyTitle")}
|
||||
description={t("insights.subPage.medikamenteEmptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/medications">
|
||||
{t("insights.subPage.medikamenteEmptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.medicationCompliance")}
|
||||
description={t("insights.subPage.medikamenteDescription")}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
medications.length >= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4"
|
||||
}
|
||||
>
|
||||
{medications.map((med) => {
|
||||
const medicationSummary = medicationSummaryById.get(med.id);
|
||||
return (
|
||||
<Card key={med.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="text-dracula-orange h-4 w-4" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{med.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{med.streak > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("insights.dayStreak", { count: med.streak })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{med.dose}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance7d")}</span>
|
||||
<span className="font-medium">{med.compliance7}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance7} className="h-2" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance30d")}</span>
|
||||
<span className="font-medium">{med.compliance30}%</span>
|
||||
</div>
|
||||
<Progress value={med.compliance30} className="h-2" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>
|
||||
<span className="text-green-500">{med.taken7}</span>{" "}
|
||||
{t("insights.taken")}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-orange-500">{med.skipped7}</span>{" "}
|
||||
{t("insights.skipped")}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-red-500">{med.missed7}</span>{" "}
|
||||
{t("insights.missed")}
|
||||
</span>
|
||||
</div>
|
||||
<MedicationComplianceCalendar medicationId={med.id} />
|
||||
{medicationSummary ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
{medicationSummary}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Pill className="h-5 w-5" />}
|
||||
text={status?.summary ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicationComplianceCalendar({
|
||||
medicationId,
|
||||
}: {
|
||||
medicationId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["compliance-chart-inline", medicationId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/compliance`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceDailyResponse;
|
||||
},
|
||||
enabled: !!medicationId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.dailyCompliance) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
||||
{t("insights.complianceNoData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ComplianceHeatmap dailyCompliance={data.dailyCompliance} stretch />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/puls`.
|
||||
*
|
||||
* Routed Pulse sub-page. Renders the pulse chart with the personalized
|
||||
* Karvonen-derived target band plus the per-section AI assessment.
|
||||
* Note: `chartKey="pulse"` so the chart-cog can override the
|
||||
* comparison-overlay independently from the dashboard pulse card; the
|
||||
* MeasurementType filter is `PULSE` (the same field used elsewhere
|
||||
* in the codebase).
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface PulseStatusData {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export default function InsightsPulsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "pulse-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/insights/pulse-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as PulseStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
|
||||
const pulseTarget = getPersonalizedPulseTarget(
|
||||
pulseAge,
|
||||
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
|
||||
);
|
||||
const pulseBands = [
|
||||
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
|
||||
].filter((band) => band.max > band.min);
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
title={t("charts.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Heart className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Smile } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
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 { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/stimmung`.
|
||||
*
|
||||
* Routed Mood sub-page. Unlike the mother page (which hid the mood
|
||||
* section entirely when no mood data existed), a dedicated sub-page
|
||||
* cannot just blank itself — we'd land on an empty white page. Instead,
|
||||
* the sub-page surfaces a clear empty-state CTA into `/mood` so the
|
||||
* user can log their first entry, matching the Apple Health "No data"
|
||||
* convention (research §1.1).
|
||||
*/
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface MoodStatusData {
|
||||
hasProvider: boolean;
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface ComprehensiveMoodData {
|
||||
moodSummary: { count: number } | null;
|
||||
}
|
||||
|
||||
export default function InsightsStimmungPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: ["insights", "mood-status", locale],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/insights/mood-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MoodStatusData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Reuse the mother-page comprehensive query — TanStack Query
|
||||
// dedups so this is a free cache read for the common case.
|
||||
const { data: comprehensive } = useQuery({
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveMoodData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const moodCount = comprehensive?.moodSummary?.count ?? 0;
|
||||
|
||||
if (isAuthenticated && comprehensive && moodCount === 0) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.moodSectionTitle")}>
|
||||
<EmptyState
|
||||
icon={<Smile className="size-6" />}
|
||||
title={t("insights.subPage.stimmungEmptyTitle")}
|
||||
description={t("insights.subPage.stimmungEmptyDescription")}
|
||||
action={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/mood">
|
||||
{t("insights.subPage.stimmungEmptyAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.moodSectionTitle")}
|
||||
description={t("insights.subPage.stimmungDescription")}
|
||||
>
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Smile className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user