mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
- Show only guardian settings for managed records\n- Keep record-scoped settings and exit context visible
1023 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|