mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
01a05e4265
commit
a432cb2e26
7 files changed
+366
-348
No files matched your search
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user