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, + }); +}