From 3b5f06d6b33fb39b2f1270d3f6a87594f291ceb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 21:51:26 +0200 Subject: [PATCH] refactor(insights): extract useInsightStatus hook for sub-page status queries Five insights sub-pages (blutdruck / bmi / gewicht / puls / stimmung) each declared the same 13-line useQuery block plus an identical StatusData interface. The hook routes through the existing queryKeys factory so the cache keys stop drifting on the next typo. Medikamente keeps its bespoke query because the compliance-status payload carries a different shape (per-medication breakdown). Closes W10 simplifier S12. --- src/app/insights/blutdruck/page.tsx | 26 ++------- src/app/insights/bmi/page.tsx | 23 ++------ src/app/insights/gewicht/page.tsx | 24 ++------- src/app/insights/puls/page.tsx | 23 ++------ src/app/insights/stimmung/page.tsx | 22 ++------ src/hooks/use-insight-status.ts | 82 +++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 100 deletions(-) create mode 100644 src/hooks/use-insight-status.ts diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx index 19f02840e..654857b5e 100644 --- a/src/app/insights/blutdruck/page.tsx +++ b/src/app/insights/blutdruck/page.tsx @@ -1,10 +1,10 @@ "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 { useInsightStatus } from "@/hooks/use-insight-status"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -31,31 +31,13 @@ const HealthChart = dynamic( { 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 { t } = 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 { data: status, isLoading: isStatusLoading } = + useInsightStatus("blood-pressure"); const bpTargets = user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index c226e4285..50f5f2c2c 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -1,11 +1,11 @@ "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 { 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"; @@ -30,13 +30,6 @@ const HealthChart = dynamic( { 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 }, @@ -47,20 +40,10 @@ const BMI_BANDS = [ export default function InsightsBmiPage() { const { isAuthenticated, user } = useAuth(); - const { t, locale } = useTranslations(); + const { t } = 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, - }); + const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi"); if (!user?.heightCm) { return ( diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx index 237f2261f..e043830bf 100644 --- a/src/app/insights/gewicht/page.tsx +++ b/src/app/insights/gewicht/page.tsx @@ -1,10 +1,10 @@ "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 { useInsightStatus } from "@/hooks/use-insight-status"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -28,29 +28,13 @@ const HealthChart = dynamic( { 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 { t } = 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 { data: status, isLoading: isStatusLoading } = + useInsightStatus("weight"); const weightBands = user?.heightCm ? buildWeightBandsFromHeight(user.heightCm, { diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx index 5cc456f9a..626991684 100644 --- a/src/app/insights/puls/page.tsx +++ b/src/app/insights/puls/page.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useInsightStatus } from "@/hooks/use-insight-status"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -34,33 +35,17 @@ const HealthChart = dynamic( { ssr: false }, ); -interface PulseStatusData { - hasProvider: boolean; - text: string | null; - cached: boolean; - updatedAt: string | null; -} - interface AnalyticsData { summaries: Record; } export default function InsightsPulsPage() { const { isAuthenticated, user } = useAuth(); - const { t, locale } = useTranslations(); + const { t } = 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 { data: status, isLoading: isStatusLoading } = + useInsightStatus("pulse"); // v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics` // bundle the mother page reads. Sharing the cache key keeps the diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx index 6126606ab..b9e2647e3 100644 --- a/src/app/insights/stimmung/page.tsx +++ b/src/app/insights/stimmung/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { Smile } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +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"; @@ -31,33 +32,16 @@ const MoodChart = dynamic( { 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 { t } = 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, - }); + const { data: status, isLoading: isStatusLoading } = useInsightStatus("mood"); // Reuse the mother-page comprehensive query — TanStack Query // dedups so this is a free cache read for the common case. diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts new file mode 100644 index 000000000..6dd0e92a9 --- /dev/null +++ b/src/hooks/use-insight-status.ts @@ -0,0 +1,82 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * Shape returned by every `/api/insights/-status` endpoint. + * + * - `hasProvider` — whether the user has configured an AI provider; the + * sub-page hides the status card entirely when false. + * - `text` — the rendered status string; null when the provider has + * not yet produced one. + * - `cached` — whether the payload came from the per-locale cache or a + * fresh provider round-trip. + * - `updatedAt` — ISO timestamp of the cached payload's creation, or + * null when the route has never produced a value. + */ +export interface InsightStatusData { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; +} + +/** + * Metric slugs the sub-pages render. Each slug is paired with the + * Insights-status endpoint and the matching `queryKeys.*` factory so + * the cache keys stay aligned across the whole app — a hard-coded + * `["insights", "weight-status", locale]` array typoed once is exactly + * the class of bug `queryKeys` was introduced to defend against. + */ +type InsightStatusMetric = + | "blood-pressure" + | "weight" + | "pulse" + | "bmi" + | "mood" + | "medication-compliance"; + +const QUERY_KEY_FACTORY: Record< + InsightStatusMetric, + (locale: string) => readonly unknown[] +> = { + "blood-pressure": queryKeys.insightsBpStatus, + weight: queryKeys.insightsWeightStatus, + pulse: queryKeys.insightsPulseStatus, + bmi: queryKeys.insightsBmiStatus, + mood: queryKeys.insightsMoodStatus, + "medication-compliance": queryKeys.insightsMedicationComplianceStatus, +}; + +/** + * Shared loader for the insight-status payload backing + * `/insights/` sub-pages. Five sub-pages previously copy-pasted + * the same 13-line useQuery block plus an identical `XxxStatusData` + * interface; the hook collapses them onto one helper plus the existing + * `queryKeys` factory so the next "add a metric" PR touches one map + * entry instead of an entire page module. + * + * Returns the `useQuery` result narrowed to the `InsightStatusData` + * payload — callers stay free to use `data` / `isLoading` / refetch + * helpers without an extra unwrap. + */ +export function useInsightStatus(metric: InsightStatusMetric) { + const { isAuthenticated } = useAuth(); + const { locale } = useTranslations(); + + return useQuery({ + queryKey: QUERY_KEY_FACTORY[metric](locale), + queryFn: async (): Promise => { + const res = await fetch(`/api/insights/${metric}-status?locale=${locale}`); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: InsightStatusData }; + return json.data; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); +}