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")}
-
+
- {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 (
-
- );
-}