refactor(settings): consistent naming + i18n key namespace

Phase B6 commit 2 of 4.

* `thresholds-settings-section.tsx` (route wrapper) renamed to
  `thresholds-section.tsx` so the filename + default export match
  the slug — every other section in `src/components/settings/`
  follows the `<slug>-section.tsx` `<SlugSection>` convention.
* The historic inner editor at `thresholds-section.tsx`
  (`<ThresholdsSection>`) is moved out to
  `thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`)
  so the names stop clashing.
* Rewrites three muddy section descriptions in EN + DE:
  - account: lists every concern the section actually covers
  - api: drops the misleading "headless clients" / "Drittanbieter"
    framing for "your own scripts and apps"
  - advanced: post-B7 there's no Import surface here — describes
    the danger zone honestly.

No user-facing setting removed, no slug renamed, no i18n key removed.
Settings smoke tests still green; the route mount goes through
`<ThresholdsSection>` exactly as before, just from the renamed file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:26:57 +02:00
1 parent 01a05e4265
commit a432cb2e26
7 files changed
+366 -348

No files matched your search

+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+2 -2
View File
@@ -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,
@@ -67,8 +67,14 @@ vi.mock("@/hooks/use-auth", () => ({
vi.mock("@/components/settings/dashboard-layout-section", () => ({
DashboardLayoutSection: () => <div data-testid="dashboard-layout" />,
}));
vi.mock("@/components/settings/thresholds-section", () => ({
ThresholdsSection: () => <div data-testid="thresholds" />,
// 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 `<slug>-section.tsx` filename. The wrapper itself is now
// `<ThresholdsSection>`. This stub mocks the inner editor so the
// SSR smoke test for `<DashboardSection>` (which never imports thresholds)
// stays unaffected.
vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
@@ -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<ThresholdMetric, EffectiveRange>;
overrides: Partial<Record<ThresholdMetric, { min: number; max: number }>>;
}
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<ThresholdMetric, string> = {
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<Record<ThresholdMetric, { min: number; max: number }>>,
) => {
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 (
<div
id={id}
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SlidersHorizontal className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("thresholds.title")}</h2>
</div>
{data && Object.keys(data.overrides).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate(null)}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("thresholds.resetAllAction")}
</Button>
)}
</div>
<p className="text-muted-foreground text-sm">
{t("thresholds.subtitle")}
</p>
{isLoading || !data ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
) : (
<div className="space-y-3">
{METRIC_ORDER.map((metric) => (
<MetricRow
key={metric}
metric={metric}
effective={data.effective[metric]}
override={data.overrides[metric] ?? null}
onSave={(range) => updateMutation.mutate({ [metric]: range })}
onReset={() => resetMutation.mutate(metric)}
busy={updateMutation.isPending || resetMutation.isPending}
/>
))}
</div>
)}
</div>
);
}
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 (
<div className="border-border space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
<p className="text-muted-foreground text-xs">
{defaultRange
? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}`
: t("thresholds.unsetExplanation")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor={`override-${metric}`} className="text-xs">
{overrideMode
? t("thresholds.overrideModeLabel")
: t("thresholds.autoModeLabel")}
</Label>
<Switch
id={`override-${metric}`}
checked={overrideMode}
onCheckedChange={(next) => {
setOverrideMode(next);
if (!next && hasOverride) onReset();
}}
disabled={busy}
/>
{hasOverride && (
<Badge variant="outline" className="text-xs">
{t("thresholds.sourceOverride")}
</Badge>
)}
</div>
</div>
{overrideMode && (
<>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<div className="space-y-1">
<Label htmlFor={`min-${metric}`} className="text-xs">
{t("thresholds.minLabel")}{" "}
{t("thresholds.unitSuffix", { unit: bounds.unit })}
</Label>
<Input
id={`min-${metric}`}
type="number"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
value={minStr}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`max-${metric}`} className="text-xs">
{t("thresholds.maxLabel")}{" "}
{t("thresholds.unitSuffix", { unit: bounds.unit })}
</Label>
<Input
id={`max-${metric}`}
type="number"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
value={maxStr}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
/>
</div>
<div className="flex items-end gap-2">
<Button
onClick={() => valid && onSave({ min: minNum, max: maxNum })}
disabled={busy || !valid}
size="sm"
>
{t("common.save")}
</Button>
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={onReset}
disabled={busy}
>
{t("thresholds.resetAction")}
</Button>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("thresholds.outOfBoundsHint", {
min: bounds.min,
max: bounds.max,
unit: bounds.unit,
})}
</p>
{hasOverride &&
defaultRange &&
(override!.min < defaultRange.greenMin * 0.7 ||
override!.max > defaultRange.greenMax * 1.3) && (
<div className="border-dracula-orange/50 bg-dracula-orange/5 flex items-start gap-2 rounded-md border-l-2 p-2 text-xs">
<AlertTriangle className="text-dracula-orange mt-0.5 h-3 w-3 shrink-0" />
<span>{t("thresholds.overrideWarning")}</span>
</div>
)}
</>
)}
</div>
);
}
+31 -309
View File
@@ -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<ThresholdMetric, EffectiveRange>;
overrides: Partial<Record<ThresholdMetric, { min: number; max: number }>>;
}
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<ThresholdMetric, string> = {
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 }) {
/**
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
*
* v1.4.16 phase B6: file renamed from the historic
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`) so the
* filename + default export match the slug — every other section in
* `src/components/settings/` follows the `<slug>-section.tsx`
* `<SlugSection>` convention. The inner editor card was simultaneously
* moved out of `thresholds-section.tsx` (where it used to live as
* `<ThresholdsSection>`) into `thresholds-editor-section.tsx`
* (`<ThresholdsEditorSection>`) 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<Record<ThresholdMetric, { min: number; max: number }>>,
) => {
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 (
<div
id={id}
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
<section
aria-labelledby="settings-section-thresholds-title"
className="space-y-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SlidersHorizontal className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("thresholds.title")}</h2>
</div>
{data && Object.keys(data.overrides).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate(null)}
disabled={resetMutation.isPending}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t("thresholds.resetAllAction")}
</Button>
)}
</div>
<p className="text-muted-foreground text-sm">
{t("thresholds.subtitle")}
</p>
<header className="space-y-1">
<h1
id="settings-section-thresholds-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.thresholds.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.thresholds.description")}
</p>
</header>
{isLoading || !data ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
) : (
<div className="space-y-3">
{METRIC_ORDER.map((metric) => (
<MetricRow
key={metric}
metric={metric}
effective={data.effective[metric]}
override={data.overrides[metric] ?? null}
onSave={(range) => updateMutation.mutate({ [metric]: range })}
onReset={() => resetMutation.mutate(metric)}
busy={updateMutation.isPending || resetMutation.isPending}
/>
))}
</div>
)}
</div>
);
}
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 (
<div className="border-border space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
<p className="text-muted-foreground text-xs">
{defaultRange
? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}`
: t("thresholds.unsetExplanation")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor={`override-${metric}`} className="text-xs">
{overrideMode
? t("thresholds.overrideModeLabel")
: t("thresholds.autoModeLabel")}
</Label>
<Switch
id={`override-${metric}`}
checked={overrideMode}
onCheckedChange={(next) => {
setOverrideMode(next);
if (!next && hasOverride) onReset();
}}
disabled={busy}
/>
{hasOverride && (
<Badge variant="outline" className="text-xs">
{t("thresholds.sourceOverride")}
</Badge>
)}
</div>
</div>
{overrideMode && (
<>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<div className="space-y-1">
<Label htmlFor={`min-${metric}`} className="text-xs">
{t("thresholds.minLabel")}{" "}
{t("thresholds.unitSuffix", { unit: bounds.unit })}
</Label>
<Input
id={`min-${metric}`}
type="number"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
value={minStr}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-1">
<Label htmlFor={`max-${metric}`} className="text-xs">
{t("thresholds.maxLabel")}{" "}
{t("thresholds.unitSuffix", { unit: bounds.unit })}
</Label>
<Input
id={`max-${metric}`}
type="number"
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
min={bounds.min}
max={bounds.max}
value={maxStr}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
/>
</div>
<div className="flex items-end gap-2">
<Button
onClick={() => valid && onSave({ min: minNum, max: maxNum })}
disabled={busy || !valid}
size="sm"
>
{t("common.save")}
</Button>
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={onReset}
disabled={busy}
>
{t("thresholds.resetAction")}
</Button>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("thresholds.outOfBoundsHint", {
min: bounds.min,
max: bounds.max,
unit: bounds.unit,
})}
</p>
{hasOverride &&
defaultRange &&
(override!.min < defaultRange.greenMin * 0.7 ||
override!.max > defaultRange.greenMax * 1.3) && (
<div className="border-dracula-orange/50 bg-dracula-orange/5 flex items-start gap-2 rounded-md border-l-2 p-2 text-xs">
<AlertTriangle className="text-dracula-orange mt-0.5 h-3 w-3 shrink-0" />
<span>{t("thresholds.overrideWarning")}</span>
</div>
)}
</>
)}
</div>
<ThresholdsEditorSection id="thresholds" />
</section>
);
}
@@ -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 (
<section
aria-labelledby="settings-section-thresholds-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-thresholds-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.thresholds.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.thresholds.description")}
</p>
</header>
<ThresholdsSection id="thresholds" />
</section>
);
}