diff --git a/messages/de.json b/messages/de.json index d286e63b6..6de2f0c33 100644 --- a/messages/de.json +++ b/messages/de.json @@ -972,7 +972,7 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, Anmeldemethoden und Konto-Löschung." + "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour." }, "integrations": { "title": "Integrationen", @@ -996,11 +996,11 @@ }, "api": { "title": "API & Tokens", - "description": "Bearer-Tokens für Drittanbieter-Clients." + "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall." }, "advanced": { "title": "Erweitert", - "description": "Import, Daten löschen." + "description": "Account-weiter Notausgang — unwiderruflich alle Gesundheitsdaten löschen, Account bleibt erhalten." }, "export": { "title": "Export", diff --git a/messages/en.json b/messages/en.json index d9ea62c98..4e4f09ab7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -972,7 +972,7 @@ "sections": { "account": { "title": "Account", - "description": "Profile, sign-in methods, and account deletion." + "description": "Profile, password, passkeys, and your onboarding tour." }, "integrations": { "title": "Integrations", @@ -996,11 +996,11 @@ }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for headless clients." + "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." }, "advanced": { "title": "Advanced", - "description": "Import, danger zone." + "description": "Account-wide danger zone — irreversibly wipe every health record while keeping your account." }, "export": { "title": "Export", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 8f8b6c399..4b2571f3b 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -11,7 +11,7 @@ import { ExportSection } from "@/components/settings/export-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; -import { ThresholdsSettingsSection } from "@/components/settings/thresholds-settings-section"; +import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -44,7 +44,7 @@ const SECTION_COMPONENTS: Record< integrations: IntegrationsSection, notifications: NotificationsSection, dashboard: DashboardSection, - thresholds: ThresholdsSettingsSection, + thresholds: ThresholdsSection, api: ApiSection, export: ExportSection, advanced: AdvancedSection, diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 51e6ff0db..90e5cec30 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -67,8 +67,14 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@/components/settings/dashboard-layout-section", () => ({ DashboardLayoutSection: () =>
, })); -vi.mock("@/components/settings/thresholds-section", () => ({ - ThresholdsSection: () =>
, +// v1.4.16 phase B6: the inner editor moved from `thresholds-section.tsx` to +// `thresholds-editor-section.tsx` so the route-level wrapper can claim the +// canonical `-section.tsx` filename. The wrapper itself is now +// ``. This stub mocks the inner editor so the +// SSR smoke test for `` (which never imports thresholds) +// stays unaffected. +vi.mock("@/components/settings/thresholds-editor-section", () => ({ + ThresholdsEditorSection: () =>
, })); import { I18nProvider } from "@/lib/i18n/context"; diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx new file mode 100644 index 000000000..b2a09558a --- /dev/null +++ b/src/components/settings/thresholds-editor-section.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + SlidersHorizontal, + RotateCcw, + Loader2, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +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: ["user", "thresholds"], + 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: ["user", "thresholds"] }); + // Every chart/band depends on these thresholds — invalidate everything. + queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + 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: ["user", "thresholds"] }); + queryClient.invalidateQueries({ queryKey: ["analytics"] }); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + toast.success(t("thresholds.resetSuccess")); + }, + onError: () => toast.error(t("thresholds.saveError")), + }); + + return ( +
+
+
+ +

{t("thresholds.title")}

+
+ {data && Object.keys(data.overrides).length > 0 && ( + + )} +
+

+ {t("thresholds.subtitle")} +

+ + {isLoading || !data ? ( +
+ + {t("common.loading")} +
+ ) : ( +
+ {METRIC_ORDER.map((metric) => ( + updateMutation.mutate({ [metric]: range })} + onReset={() => resetMutation.mutate(metric)} + busy={updateMutation.isPending || resetMutation.isPending} + /> + ))} +
+ )} +
+ ); +} + +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")} +

+
+
+ + { + 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")} +
+ )} + + )} +
+ ); +} diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index cfb591cd4..68cd4d93b 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -1,319 +1,41 @@ "use client"; -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - SlidersHorizontal, - RotateCcw, - Loader2, - AlertTriangle, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { - METRIC_BOUNDS, - type ThresholdMetric, - type EffectiveRange, -} from "@/lib/analytics/effective-range"; +import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section"; +import { useTranslations } from "@/lib/i18n/context"; -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 ThresholdsSection({ id }: { id: string }) { +/** + * `` — route-level wrapper for `/settings/thresholds`. + * + * v1.4.16 phase B6: file renamed from the historic + * `thresholds-settings-section.tsx` (``) so the + * filename + default export match the slug — every other section in + * `src/components/settings/` follows the `-section.tsx` + * `` convention. The inner editor card was simultaneously + * moved out of `thresholds-section.tsx` (where it used to live as + * ``) into `thresholds-editor-section.tsx` + * (``) so the names stop clashing. + */ +export function ThresholdsSection() { const { t } = useTranslations(); - const queryClient = useQueryClient(); - - const { data, isLoading } = useQuery({ - queryKey: ["user", "thresholds"], - 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: ["user", "thresholds"] }); - // Every chart/band depends on these thresholds — invalidate everything. - queryClient.invalidateQueries({ queryKey: ["analytics"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - 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: ["user", "thresholds"] }); - queryClient.invalidateQueries({ queryKey: ["analytics"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - toast.success(t("thresholds.resetSuccess")); - }, - onError: () => toast.error(t("thresholds.saveError")), - }); return ( -
-
-
- -

{t("thresholds.title")}

-
- {data && Object.keys(data.overrides).length > 0 && ( - - )} -
-

- {t("thresholds.subtitle")} -

+
+

+ {t("settings.sections.thresholds.title")} +

+

+ {t("settings.sections.thresholds.description")} +

+
- {isLoading || !data ? ( -
- - {t("common.loading")} -
- ) : ( -
- {METRIC_ORDER.map((metric) => ( - updateMutation.mutate({ [metric]: range })} - onReset={() => resetMutation.mutate(metric)} - busy={updateMutation.isPending || resetMutation.isPending} - /> - ))} -
- )} -
- ); -} - -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")} -

-
-
- - { - 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")} -
- )} - - )} -
+ + ); } diff --git a/src/components/settings/thresholds-settings-section.tsx b/src/components/settings/thresholds-settings-section.tsx deleted file mode 100644 index e55f745da..000000000 --- a/src/components/settings/thresholds-settings-section.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { ThresholdsSection } from "@/components/settings/thresholds-section"; -import { useTranslations } from "@/lib/i18n/context"; - -export function ThresholdsSettingsSection() { - const { t } = useTranslations(); - - return ( -
-
-

- {t("settings.sections.thresholds.title")} -

-

- {t("settings.sections.thresholds.description")} -

-
- - -
- ); -}