"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; interface ThresholdsApiResponse { effective: Record; overrides: Partial>; } const METRIC_ORDER: ThresholdMetric[] = [ "WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE", "BODY_FAT", "TOTAL_BODY_WATER", "BONE_MASS", "SLEEP_DURATION", "ACTIVITY_STEPS", "BLOOD_GLUCOSE_FASTING", "BLOOD_GLUCOSE_POSTPRANDIAL", "BLOOD_GLUCOSE_RANDOM", "BLOOD_GLUCOSE_BEDTIME", "OXYGEN_SATURATION", ]; const METRIC_LABEL_KEYS: Record = { WEIGHT: "thresholds.metricWeight", BLOOD_PRESSURE_SYS: "thresholds.metricBpSys", BLOOD_PRESSURE_DIA: "thresholds.metricBpDia", PULSE: "thresholds.metricPulse", BODY_FAT: "thresholds.metricBodyFat", SLEEP_DURATION: "thresholds.metricSleep", ACTIVITY_STEPS: "thresholds.metricSteps", BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting", BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial", BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom", BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", TOTAL_BODY_WATER: "thresholds.metricBodyWater", BONE_MASS: "thresholds.metricBoneMass", OXYGEN_SATURATION: "thresholds.metricOxygenSaturation", }; export function ThresholdsEditorSection({ id }: { id: string }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: queryKeys.userThresholds(), queryFn: async () => { const res = await fetch("/api/user/thresholds"); if (!res.ok) throw new Error("failed"); const json = await res.json(); return json.data as ThresholdsApiResponse; }, }); const updateMutation = useMutation({ mutationFn: async ( payload: Partial>, ) => { const res = await fetch("/api/user/thresholds", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.error ?? "save failed"); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); // Every chart/band depends on these thresholds — invalidate everything. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); toast.success(t("thresholds.saveSuccess")); }, onError: (err) => toast.error( err instanceof Error ? err.message : t("thresholds.saveError"), ), }); const resetMutation = useMutation({ mutationFn: async (metric: ThresholdMetric | null) => { const url = metric ? `/api/user/thresholds?metric=${metric}` : "/api/user/thresholds"; const res = await fetch(url, { method: "DELETE" }); if (!res.ok) throw new Error("reset failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); toast.success(t("thresholds.resetSuccess")); }, onError: () => toast.error(t("thresholds.saveError")), }); return (
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*` already provides the title + description for this route, so the card-level title + subtitle were a duplicate of the page header. Keep the icon + reset action so the card still has a visible control affordance. */}
{isLoading || !data ? ( ) : (
{METRIC_ORDER.map((metric) => ( updateMutation.mutate({ [metric]: range })} onReset={() => resetMutation.mutate(metric)} busy={updateMutation.isPending || resetMutation.isPending} /> ))}
)}
); } /** * Skeleton placeholder rendered while `/api/user/thresholds` is in * flight. Reserves one row per `METRIC_ORDER` entry at roughly the * loaded height so the page does not jump when the fetched list * swaps in. The pulsing animation honours `prefers-reduced-motion` * via Tailwind's `motion-reduce:animate-none`. */ function ThresholdsSkeletonList() { return ( ); } interface MetricRowProps { metric: ThresholdMetric; effective: EffectiveRange | undefined; override: { min: number; max: number } | null; onSave: (range: { min: number; max: number }) => void; onReset: () => void; busy: boolean; } function MetricRow({ metric, effective, override, onSave, onReset, busy, }: MetricRowProps) { const { t } = useTranslations(); const fmt = useFormatters(); const bounds = METRIC_BOUNDS[metric]; const hasOverride = override !== null; const [overrideMode, setOverrideMode] = useState(hasOverride); const [minStr, setMinStr] = useState( String(override?.min ?? effective?.default?.greenMin ?? bounds.min), ); const [maxStr, setMaxStr] = useState( String(override?.max ?? effective?.default?.greenMax ?? bounds.max), ); const minNum = parseFloat(minStr); const maxNum = parseFloat(maxStr); const valid = Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= bounds.min && maxNum <= bounds.max && minNum < maxNum; const defaultRange = effective?.default; return (

{t(METRIC_LABEL_KEYS[metric])}

{defaultRange ? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}` : t("thresholds.unsetExplanation")}

{/* v1.4.33 F17 — the switch label used to flip between "Auto" and "Überschrieben" with the current state. The maintainer's audit caught that as confusing: a user looking at a row with "Auto" + the toggle off reads it as "Auto is off, why are there no inputs?" instead of "flip the switch to enter a custom range". Anchor the label on the *action* ("Eigene Werte" / "Custom range") so the affordance is unambiguous; the `thresholds.sourceOverride` badge to the right still announces when the override is active. */} { setOverrideMode(next); if (!next && hasOverride) onReset(); }} disabled={busy} /> {hasOverride && ( {t("thresholds.sourceOverride")} )}
{overrideMode && ( <>
setMinStr(e.target.value)} disabled={busy} />
setMaxStr(e.target.value)} disabled={busy} />
{hasOverride && ( )}

{t("thresholds.outOfBoundsHint", { min: bounds.min, max: bounds.max, unit: bounds.unit, })}

{hasOverride && defaultRange && (override!.min < defaultRange.greenMin * 0.7 || override!.max > defaultRange.greenMax * 1.3) && (
{t("thresholds.overrideWarning")}
)} )}
); }