"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; 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 { SettingsInfoTile } from "./_info-tile"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { SettingsCard } from "@/components/settings/settings-card"; 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 () => { return apiGet("/api/user/thresholds"); }, }); const updateMutation = useMutation({ mutationFn: async ( payload: Partial>, ) => { const res = await apiFetchRaw("/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() }); // The dashboard bands + score context read the same thresholds through // the daily reads, which are unmounted on Settings — force the refetch. void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.saveSuccess")); }, onError: (err) => showErrorToast( err instanceof Error ? err.message : t("thresholds.saveError"), { cause: err }, ), }); const resetMutation = useMutation({ mutationFn: async (metric: ThresholdMetric | null) => { // `metric === null` is the reset-everything arm behind the confirm // dialog at the foot of the card. The server asks for the same decision // in the body, so the dialog's answer is what gets sent — the wide form // is not reachable by dropping the query parameter. const res = metric ? await apiFetchRaw(`/api/user/thresholds?metric=${metric}`, { method: "DELETE", }) : await apiFetchRaw("/api/user/thresholds", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: "RESET_THRESHOLDS" }), }); if (!res.ok) throw new Error("reset failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); // Same daily-reads truth as the save arm above. void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, onError: (failure) => showErrorToast(t("thresholds.saveError"), { cause: failure }), }); return ( {/* The card carries a real title. It used to render a naked icon and a reset button, on the grounds that the page header already said the same thing — which left one card in the tree with no heading at all. The page subtitle is the one that goes. */} {isLoading || !data ? ( ) : (
{METRIC_ORDER.map((metric) => ( updateMutation.mutate({ [metric]: range })} onReset={() => resetMutation.mutate(metric)} busy={updateMutation.isPending || resetMutation.isPending} /> ))}
)} {data && Object.keys(data.overrides).length > 0 ? ( } label={t("thresholds.resetAllAction")} title={t("thresholds.resetAllTitle")} body={t("thresholds.resetAllBody")} confirmLabel={t("thresholds.resetAllConfirm")} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate(null)} destructive /> ) : null}
); } /** * 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 { preference, glucoseUnit } = useUnitDisplay(); const canonicalBounds = METRIC_BOUNDS[metric]; // v1.32.27 — the row reads and writes in the user's preferred unit // while `User.thresholdsJson` stays canonical SI. Everything the row // receives (`override`, `effective.default`, `METRIC_BOUNDS`) is // canonical, so it converts on the way into the fields and inverts on // the way back out in `onSave`. Guardrails round inward so a value // typed at the displayed limit still passes the server's canonical // check. The glucose thresholds read in the reader's glucose unit the // same way. Metrics without a transform — and every metric on the default // preferences — take the adapter's identity path untouched. const units = resolveTargetUnitAdapter( metric, canonicalBounds.unit, preference, glucoseUnit, ); const bounds = { ...units.bounds(canonicalBounds), unit: units.unit }; /** Canonical seed → the string the field shows, falling back to the bound. */ const seedString = (canonical: number | undefined, fallback: number) => String(canonical == null ? fallback : units.toDisplay(canonical)); const hasOverride = override !== null; const [overrideMode, setOverrideMode] = useState(hasOverride); const [confirmSwitchOff, setConfirmSwitchOff] = useState(false); const [minStr, setMinStr] = useState( seedString(override?.min ?? effective?.default?.greenMin, bounds.min), ); const [maxStr, setMaxStr] = useState( seedString(override?.max ?? effective?.default?.greenMax, bounds.max), ); // v1.30.1 — the row is keyed on the stable `metric`, so a per-row // reset (or "reset all") that lands a NEW `override` (typically // `null`) never re-runs the `useState` initializers above: the // component instance survives the refetch. Pre-fix the switch and // min/max fields kept showing the stale override after the server // confirmed it was cleared, while the "Überschrieben" badge (driven // straight off the `hasOverride` prop, not state) vanished — a // contradictory row until a hard reload. Re-derive an identity string // from the override prop and resync local state during render // whenever it actually changes; typing in the fields never changes // `overrideIdentity`, so this never fights a live edit. const overrideIdentity = override ? `${override.min}:${override.max}` : "unset"; const [syncedIdentity, setSyncedIdentity] = useState(overrideIdentity); if (overrideIdentity !== syncedIdentity) { setSyncedIdentity(overrideIdentity); setOverrideMode(hasOverride); setMinStr( seedString(override?.min ?? effective?.default?.greenMin, bounds.min), ); setMaxStr( seedString(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; // A bound reads at its own precision: "120–129 mmHg", "8,000–15,000 steps", // "61.3–82.5 kg". A fixed one decimal printed "120.0–129.0" and // "8,000.0–15,000.0" for metrics that are whole numbers by nature. const formatBound = (value: number) => { const rounded = Math.round(value * 10) / 10; return fmt.number(rounded, Number.isInteger(rounded) ? 0 : 1); }; return (

{t(METRIC_LABEL_KEYS[metric])}

{defaultRange ? `${t("thresholds.defaultLabel")}: ${formatBound(units.toDisplay(defaultRange.greenMin))}–${formatBound(units.toDisplay(defaultRange.greenMax))} ${bounds.unit}` : t("thresholds.unsetExplanation")}

{/* v1.4.33 — 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. */} {/* Flipping this off DELETES the stored override — the same irreversible write the Reset control performs, reached by a control that reads as a display toggle. It gets the same confirmation, and a dismissal leaves the switch on. */} { if (!next && hasOverride) { setConfirmSwitchOff(true); return; } setOverrideMode(next); }} disabled={busy} /> { setOverrideMode(false); onReset(); }} destructive /> {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")} )} )}
); }