Files
HealthLog/src/components/settings/managed-record-settings-section.tsx
T
Marc-André Bombeck f69e1d7dec feat: add managed settings navigation
- Show only guardian settings for managed records\n- Keep record-scoped settings and exit context visible
2026-08-06 04:23:17 +02:00

1023 lines
35 KiB
TypeScript

"use client";
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
normalizeInsightsTileId,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { MODULE_REGISTRY } from "@/lib/modules/registry";
import { queryKeys } from "@/lib/query-keys";
import {
assertRecordSettingsResponseForRecord,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
safeParseManagedRecordSettingsPatch,
type ManagedRecordSettingsFamily,
} from "@/lib/record-settings";
import {
METRIC_BOUNDS,
type ThresholdMetric,
} from "@/lib/analytics/effective-range";
import { ALL_METRICS } from "@/lib/validations/thresholds";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCardHeader } from "./_card-header";
import { SettingsCard } from "./settings-card";
interface ManagedRecordSettingsResponse {
recordId: string;
family: ManagedRecordSettingsFamily;
settings: Record<string, unknown>;
}
type ManagedSettingsFamily = Exclude<
ManagedRecordSettingsFamily,
"integrations"
>;
type SettingsFormProps = {
settings: Record<string, unknown>;
disabled: boolean;
onSave: (patch: unknown) => void;
saveLabel: string;
};
const COACH_EXCLUDE_METRICS = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
"hrv",
"sleep",
"resting_hr",
"steps",
"medications",
"anthropometrics",
] as const;
const COACH_DATA_CLUSTERS = [
"cardio",
"body",
"activity",
"workouts",
"sleep",
"mood",
"glucose",
"medication",
"mobility",
"environment",
] as const;
const COACH_EXCLUDE_METRIC_LABEL_KEYS: Record<
(typeof COACH_EXCLUDE_METRICS)[number],
string
> = {
bp: "settings.sharedRecord.managedSettings.coach.metrics.bp",
weight: "settings.sharedRecord.managedSettings.coach.metrics.weight",
pulse: "settings.sharedRecord.managedSettings.coach.metrics.pulse",
mood: "settings.sharedRecord.managedSettings.coach.metrics.mood",
compliance: "settings.sharedRecord.managedSettings.coach.metrics.compliance",
hrv: "settings.sharedRecord.managedSettings.coach.metrics.hrv",
sleep: "settings.sharedRecord.managedSettings.coach.metrics.sleep",
resting_hr: "settings.sharedRecord.managedSettings.coach.metrics.restingHr",
steps: "settings.sharedRecord.managedSettings.coach.metrics.steps",
medications:
"settings.sharedRecord.managedSettings.coach.metrics.medications",
anthropometrics:
"settings.sharedRecord.managedSettings.coach.metrics.anthropometrics",
};
const COACH_DATA_CLUSTER_LABEL_KEYS: Record<
(typeof COACH_DATA_CLUSTERS)[number],
string
> = {
cardio: "settings.sharedRecord.managedSettings.coach.clusters.cardio",
body: "settings.sharedRecord.managedSettings.coach.clusters.body",
activity: "settings.sharedRecord.managedSettings.coach.clusters.activity",
workouts: "settings.sharedRecord.managedSettings.coach.clusters.workouts",
sleep: "settings.sharedRecord.managedSettings.coach.clusters.sleep",
mood: "settings.sharedRecord.managedSettings.coach.clusters.mood",
glucose: "settings.sharedRecord.managedSettings.coach.clusters.glucose",
medication: "settings.sharedRecord.managedSettings.coach.clusters.medication",
mobility: "settings.sharedRecord.managedSettings.coach.clusters.mobility",
environment:
"settings.sharedRecord.managedSettings.coach.clusters.environment",
};
const INSIGHTS_SECTION_LABEL_KEYS: Record<InsightsSectionId, string> = {
"wellness-scores":
"settings.sharedRecord.managedSettings.insights.sectionLabels.wellnessScores",
"daily-briefing":
"settings.sharedRecord.managedSettings.insights.sectionLabels.dailyBriefing",
vitals: "settings.sharedRecord.managedSettings.insights.sectionLabels.vitals",
trends: "settings.sharedRecord.managedSettings.insights.sectionLabels.trends",
"period-review":
"settings.sharedRecord.managedSettings.insights.sectionLabels.periodReview",
"cycle-summary":
"settings.sharedRecord.managedSettings.insights.sectionLabels.cycleSummary",
signals:
"settings.sharedRecord.managedSettings.insights.sectionLabels.signals",
"rhythm-events":
"settings.sharedRecord.managedSettings.insights.sectionLabels.rhythmEvents",
"health-status":
"settings.sharedRecord.managedSettings.insights.sectionLabels.healthStatus",
breathing:
"settings.sharedRecord.managedSettings.insights.sectionLabels.breathing",
"labs-changes":
"settings.sharedRecord.managedSettings.insights.sectionLabels.labsChanges",
ecg: "settings.sharedRecord.managedSettings.insights.sectionLabels.ecg",
};
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",
};
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function formNumber(form: FormData, name: string, fallback: number): number {
const value = Number(form.get(name));
return Number.isFinite(value) ? value : fallback;
}
function ownStringValue(inventory: object, id: string): string | undefined {
if (!Object.hasOwn(inventory, id)) return undefined;
const value = Reflect.get(inventory, id);
return typeof value === "string" ? value : undefined;
}
function coachMetricLabel(
t: ReturnType<typeof useTranslations>["t"],
metric: (typeof COACH_EXCLUDE_METRICS)[number],
): string {
const labelKey = ownStringValue(COACH_EXCLUDE_METRIC_LABEL_KEYS, metric);
return labelKey
? t(labelKey)
: t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
function coachClusterLabel(
t: ReturnType<typeof useTranslations>["t"],
cluster: (typeof COACH_DATA_CLUSTERS)[number],
): string {
const labelKey = ownStringValue(COACH_DATA_CLUSTER_LABEL_KEYS, cluster);
return labelKey
? t(labelKey)
: t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
function insightItemLabel(
t: ReturnType<typeof useTranslations>["t"],
kind: "section" | "tile",
id: string,
): string {
if (kind === "section") {
const labelKey = ownStringValue(INSIGHTS_SECTION_LABEL_KEYS, id);
return labelKey
? t(labelKey)
: t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
const normalizedId = normalizeInsightsTileId(id);
if (normalizedId === "overview") return t("insights.navOverview");
if (!Object.hasOwn(SUB_PAGE_TABS, normalizedId)) {
return t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
const labelKey =
SUB_PAGE_TABS[normalizedId as keyof typeof SUB_PAGE_TABS]?.labelKey;
return typeof labelKey === "string"
? t(labelKey)
: t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
function SaveButton({ disabled, label }: { disabled: boolean; label: string }) {
return (
<Button disabled={disabled} type="submit">
{label}
</Button>
);
}
function ProfileSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const displayName = asString(form.get("displayName")).trim();
const heightValue = asString(form.get("heightCm")).trim();
onSave({
displayName: displayName || null,
heightCm: heightValue === "" ? null : Number(heightValue),
dateOfBirth: asString(form.get("dateOfBirth")) || null,
gender: asString(form.get("gender")) || null,
locale: asString(form.get("locale")) || null,
timezone: asString(form.get("timezone")),
unitPreference: asString(form.get("unitPreference")),
timeFormat: asString(form.get("timeFormat")),
dateFormat: asString(form.get("dateFormat")),
});
}
return (
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-display-name">
{t("settings.sharedRecord.managedSettings.profile.displayName")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.displayName)}
disabled={disabled}
id="managed-display-name"
name="displayName"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-height">
{t("settings.sharedRecord.managedSettings.profile.height")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
settings.heightCm === null ? "" : String(settings.heightCm ?? "")
}
disabled={disabled}
id="managed-height"
max="300"
min="30"
name="heightCm"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
{t("settings.sharedRecord.managedSettings.profile.dateOfBirth")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.dateOfBirth)}
disabled={disabled}
id="managed-date-of-birth"
name="dateOfBirth"
type="date"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
{t("settings.sharedRecord.managedSettings.profile.gender")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.gender)}
disabled={disabled}
id="managed-gender"
name="gender"
>
<option value="">
{t("settings.sharedRecord.managedSettings.profile.notSpecified")}
</option>
<option value="MALE">
{t("settings.sharedRecord.managedSettings.profile.male")}
</option>
<option value="FEMALE">
{t("settings.sharedRecord.managedSettings.profile.female")}
</option>
<option value="OTHER">
{t("settings.sharedRecord.managedSettings.profile.other")}
</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
{t("settings.sharedRecord.managedSettings.profile.locale")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.locale)}
disabled={disabled}
id="managed-locale"
name="locale"
>
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
{t("settings.sharedRecord.managedSettings.profile.timezone")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.timezone, "Europe/Berlin")}
disabled={disabled}
id="managed-timezone"
name="timezone"
required
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-units">
{t("settings.sharedRecord.managedSettings.profile.units")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.unitPreference, "metric")}
disabled={disabled}
id="managed-units"
name="unitPreference"
>
<option value="metric">
{t("settings.sharedRecord.managedSettings.profile.metric")}
</option>
<option value="imperial">
{t("settings.sharedRecord.managedSettings.profile.imperial")}
</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
{t("settings.sharedRecord.managedSettings.profile.timeFormat")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.timeFormat, "AUTO")}
disabled={disabled}
id="managed-time-format"
name="timeFormat"
>
<option value="AUTO">
{t("settings.sharedRecord.managedSettings.profile.automatic")}
</option>
<option value="H12">
{t("settings.sharedRecord.managedSettings.profile.hour12")}
</option>
<option value="H24">
{t("settings.sharedRecord.managedSettings.profile.hour24")}
</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
{t("settings.sharedRecord.managedSettings.profile.dateFormat")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.dateFormat, "AUTO")}
disabled={disabled}
id="managed-date-format"
name="dateFormat"
>
<option value="AUTO">
{t("settings.sharedRecord.managedSettings.profile.automatic")}
</option>
<option value="DMY">
{t("settings.sharedRecord.managedSettings.profile.dateDmy")}
</option>
<option value="MDY">
{t("settings.sharedRecord.managedSettings.profile.dateMdy")}
</option>
<option value="YMD">
{t("settings.sharedRecord.managedSettings.profile.dateYmd")}
</option>
</select>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
function ModulesSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.modulePreferences);
const entries = Object.entries(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS) as [
keyof typeof MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
boolean,
][];
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
onSave({
modulePreferences: Object.fromEntries(
entries.map(([key]) => [key, form.get(key) === "on"]),
),
});
}
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
{entries.map(([key, defaultEnabled]) => (
<label className="flex items-center gap-3 text-sm" key={key}>
<input
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
name={key}
type="checkbox"
/>
{t(MODULE_REGISTRY[key].labelKey)}
</label>
))}
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
function NotificationsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.notificationPreferences);
const medication = asRecord(preferences.medication);
const mood = asRecord(preferences.mood);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
onSave({
moodReminderEnabled: form.get("moodReminderEnabled") === "on",
notificationPreferences: {
medication: {
lowStockRunwayDays:
form.get("lowStockRunwayDays") === ""
? null
: formNumber(form, "lowStockRunwayDays", 7),
reorderLeadDays: formNumber(form, "reorderLeadDays", 10),
},
mood: { reminderHour: formNumber(form, "reminderHour", 22) },
},
});
}
return (
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm sm:col-span-2">
<input
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
name="moodReminderEnabled"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.notifications.moodReminder")}
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
{t("settings.sharedRecord.managedSettings.notifications.reminderHour")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(mood.reminderHour, 22)}
disabled={disabled}
id="managed-reminder-hour"
max="23"
min="0"
name="reminderHour"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-low-stock">
{t(
"settings.sharedRecord.managedSettings.notifications.lowStockRunwayDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
medication.lowStockRunwayDays === null
? ""
: asNumber(medication.lowStockRunwayDays, 7)
}
disabled={disabled}
id="managed-low-stock"
max="60"
min="1"
name="lowStockRunwayDays"
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reorder-lead">
{t(
"settings.sharedRecord.managedSettings.notifications.reorderLeadDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(medication.reorderLeadDays, 10)}
disabled={disabled}
id="managed-reorder-lead"
max="60"
min="0"
name="reorderLeadDays"
type="number"
/>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
function ThresholdsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const overrides = asRecord(settings.overrides);
const effective = asRecord(settings.effective);
const [metric, setMetric] = useState<ThresholdMetric>(ALL_METRICS[0]);
const overrideRange = asRecord(overrides[metric]);
const defaultRange = asRecord(asRecord(effective[metric]).range);
const bounds = METRIC_BOUNDS[metric];
const minimum = asNumber(
overrideRange.min,
asNumber(defaultRange.greenMin, bounds.min),
);
const maximum = asNumber(
overrideRange.max,
asNumber(defaultRange.greenMax, bounds.max),
);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
onSave({
overrides: {
[metric]: {
min: formNumber(form, "minimum", minimum),
max: formNumber(form, "maximum", maximum),
},
},
});
}
return (
<form className="mt-4 grid gap-4 sm:grid-cols-3" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-metric">
{t("settings.sharedRecord.managedSettings.thresholds.metric")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
onChange={(event) => setMetric(event.target.value as ThresholdMetric)}
value={metric}
disabled={disabled}
id="managed-threshold-metric"
name="metric"
>
{ALL_METRICS.map((entry) => (
<option key={entry} value={entry}>
{t(METRIC_LABEL_KEYS[entry])}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={minimum}
disabled={disabled}
id="managed-threshold-minimum"
key={`${metric}-minimum`}
name="minimum"
required
type="number"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-maximum">
{t("settings.sharedRecord.managedSettings.thresholds.maximum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={maximum}
disabled={disabled}
id="managed-threshold-maximum"
key={`${metric}-maximum`}
name="maximum"
required
type="number"
/>
</label>
<div className="sm:col-span-3">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
</form>
);
}
function CoachSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
onSave({
disableCoach: form.get("disableCoach") === "on",
preferences: {
tone: asString(form.get("tone"), "warm"),
verbosity: asString(form.get("verbosity"), "default"),
excludeMetrics: COACH_EXCLUDE_METRICS.filter(
(metric) => form.get(`exclude-${metric}`) === "on",
),
showEvidenceByDefault: form.get("showEvidenceByDefault") === "on",
defaultWindow: asString(form.get("defaultWindow"), "allTime"),
...(form.get("useCustomClusters") === "on"
? {
dataClusters: COACH_DATA_CLUSTERS.filter(
(cluster) => form.get(`cluster-${cluster}`) === "on",
),
}
: {}),
},
});
}
return (
<form className="mt-4 space-y-4" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={settings.disableCoach === true}
disabled={disabled}
name="disableCoach"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.disable")}
</label>
<div className="grid gap-4 sm:grid-cols-3">
<label className="grid gap-1 text-sm" htmlFor="managed-coach-tone">
{t("settings.sharedRecord.managedSettings.coach.tone")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.tone, "warm")}
disabled={disabled}
id="managed-coach-tone"
name="tone"
>
<option value="warm">
{t("settings.sharedRecord.managedSettings.coach.warm")}
</option>
<option value="neutral">
{t("settings.sharedRecord.managedSettings.coach.neutral")}
</option>
<option value="concise">
{t("settings.sharedRecord.managedSettings.coach.concise")}
</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
{t("settings.sharedRecord.managedSettings.coach.verbosity")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.verbosity, "default")}
disabled={disabled}
id="managed-coach-verbosity"
name="verbosity"
>
<option value="brief">
{t("settings.sharedRecord.managedSettings.coach.brief")}
</option>
<option value="default">
{t("settings.sharedRecord.managedSettings.coach.default")}
</option>
<option value="detailed">
{t("settings.sharedRecord.managedSettings.coach.detailed")}
</option>
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(preferences.defaultWindow, "allTime")}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
>
<option value="last7days">
{t("settings.sharedRecord.managedSettings.coach.last7days")}
</option>
<option value="last30days">
{t("settings.sharedRecord.managedSettings.coach.last30days")}
</option>
<option value="last90days">
{t("settings.sharedRecord.managedSettings.coach.last90days")}
</option>
<option value="allTime">
{t("settings.sharedRecord.managedSettings.coach.allTime")}
</option>
</select>
</label>
</div>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={preferences.showEvidenceByDefault === true}
disabled={disabled}
name="showEvidenceByDefault"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.showEvidence")}
</label>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.excludedMetrics")}
</legend>
{COACH_EXCLUDE_METRICS.map((metric) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={metric}
>
<input
defaultChecked={excluded.has(metric)}
disabled={disabled}
name={`exclude-${metric}`}
type="checkbox"
/>
{coachMetricLabel(t, metric)}
</label>
))}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.dataClusters")}
</legend>
<label className="mb-2 flex items-center gap-2 text-sm">
<input
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
name="useCustomClusters"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.customClusters")}
</label>
{COACH_DATA_CLUSTERS.map((cluster) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={cluster}
>
<input
defaultChecked={dataClusters.has(cluster)}
disabled={disabled}
name={`cluster-${cluster}`}
type="checkbox"
/>
{coachClusterLabel(t, cluster)}
</label>
))}
</fieldset>
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
type LayoutItem = { id: string; visible: boolean; order: number };
function layoutItems(value: unknown): LayoutItem[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const item = asRecord(entry);
const id = asString(item.id);
return id
? [
{
id,
visible: item.visible === true,
order: asNumber(item.order, 0),
},
]
: [];
});
}
function InsightsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const layout = asRecord(settings.layout);
const sections = layoutItems(layout.sections);
const tiles = layoutItems(layout.tiles);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const toPatch = (kind: "section" | "tile", item: LayoutItem) => ({
id: item.id,
visible: form.get(`${kind}-visible-${item.id}`) === "on",
order: formNumber(form, `${kind}-order-${item.id}`, item.order),
});
onSave({
layout: {
version: asNumber(layout.version, 2),
sections: sections.map((section) => toPatch("section", section)),
tiles: tiles.map((tile) => toPatch("tile", tile)),
},
});
}
const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{kind === "section"
? t("settings.sharedRecord.managedSettings.insights.sections")
: t("settings.sharedRecord.managedSettings.insights.tiles")}
</legend>
{items.map((item) => (
<div className="flex items-center gap-3 text-sm" key={item.id}>
<label className="flex flex-1 items-center gap-2">
<input
defaultChecked={item.visible}
disabled={disabled}
name={`${kind}-visible-${item.id}`}
type="checkbox"
/>
{insightItemLabel(t, kind, item.id)}
</label>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
{t("settings.sharedRecord.managedSettings.insights.order")}
<input
className="border-input bg-background w-16 rounded-md border px-2 py-1"
defaultValue={item.order}
disabled={disabled}
id={`${kind}-order-${item.id}`}
min="0"
name={`${kind}-order-${item.id}`}
type="number"
/>
</label>
</div>
))}
</fieldset>
);
return (
<form className="mt-4 space-y-5" onSubmit={submit}>
{renderItems("section", sections)}
{renderItems("tile", tiles)}
<SaveButton disabled={disabled} label={saveLabel} />
</form>
);
}
export function ManagedRecordSettingsForm({
family,
...props
}: SettingsFormProps & { family: ManagedSettingsFamily }) {
switch (family) {
case "profile":
return <ProfileSettingsForm {...props} />;
case "modules":
return <ModulesSettingsForm {...props} />;
case "notifications":
return <NotificationsSettingsForm {...props} />;
case "thresholds":
return <ThresholdsSettingsForm {...props} />;
case "coach":
return <CoachSettingsForm {...props} />;
case "insights":
return <InsightsSettingsForm {...props} />;
}
}
/**
* Managed settings use typed target-record controls only. The actor's
* Settings components never mount here, so their actor-scoped queries and
* mutations cannot fire while a Guardian administers a profile.
*/
export function ManagedRecordSettingsSection({
family,
}: {
family: ManagedSettingsFamily;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const recordId = user?.accountAccess?.active?.accountId ?? null;
const [validationFailed, setValidationFailed] = useState(false);
const query = useQuery({
queryKey: queryKeys.recordSettingsFamily(recordId ?? "", family),
enabled: recordId !== null,
queryFn: async () => {
const response = await apiGet<ManagedRecordSettingsResponse>(
`/api/record-settings/${family}`,
);
assertRecordSettingsResponseForRecord(response, recordId);
if (response.family !== family) {
throw new Error(
"Managed record settings response did not match its key",
);
}
return response;
},
});
const save = useMutation({
mutationFn: async (patch: unknown) => {
const response = await apiPatch<ManagedRecordSettingsResponse>(
`/api/record-settings/${family}`,
patch,
);
assertRecordSettingsResponseForRecord(response, recordId);
if (response.family !== family) {
throw new Error(
"Managed record settings response did not match its key",
);
}
return response;
},
onSuccess: (response) => {
if (recordId === null) return;
queryClient.setQueryData(
queryKeys.recordSettingsFamily(recordId, family),
response,
);
},
});
if (query.isError) {
return <QueryErrorCard onRetry={() => void query.refetch()} />;
}
return (
<SettingsCard
aria-busy={query.isLoading}
data-record-id={recordId ?? undefined}
data-record-settings-family={family}
>
<SettingsCardHeader
icon={Settings}
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
/>
{query.data ? (
<>
<ManagedRecordSettingsForm
disabled={save.isPending}
family={family}
onSave={(patch) => {
const parsed = safeParseManagedRecordSettingsPatch(family, patch);
if (!parsed.success) {
setValidationFailed(true);
return;
}
setValidationFailed(false);
save.mutate(parsed.data);
}}
saveLabel={t("common.save")}
settings={query.data.settings}
/>
{save.isError || validationFailed ? (
<p className="text-destructive mt-3 text-sm" role="alert">
{t("common.networkError")}
</p>
) : null}
</>
) : null}
</SettingsCard>
);
}