fix: complete managed record settings

- Expose canonical defaults and reject invalid settings families\n- Localize Guardian controls and refresh dependent caches
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:40:13 +02:00
1 parent e788675b82
commit 247ac8494c
16 files changed
+759 -91

No files matched your search

@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import {
isManagedRecordSettingsFamily,
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
managedModulePreferencesFrom,
parseManagedRecordSettingsPatch,
} from "@/lib/record-settings/configuration";
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
describe("managed record settings configuration contract", () => {
it("enumerates the only target-record fields each settings DTO may write", () => {
@@ -95,4 +99,29 @@ describe("managed record settings configuration contract", () => {
] as const)("accepts a typed %s patch", (family, patch) => {
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
});
it("uses the complete canonical module inventory for a fresh record", () => {
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
WRITABLE_MODULE_KEYS,
);
expect(managedModulePreferencesFrom(null)).toEqual(
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
);
});
it("accepts only the canonical profile gender enum", () => {
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
).not.toThrow();
expect(() =>
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
).toThrow();
});
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
"rejects inherited family name %s",
(family) => {
expect(isManagedRecordSettingsFamily(family)).toBe(false);
},
);
});
@@ -57,5 +57,10 @@ describe("record settings route guard", () => {
for (const family of families) {
expect(source).toContain(`case \"${family}\"`);
}
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
expect(source).toContain('apiError("Settings family not found", 404)');
expect(
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
});
});
+45 -3
View File
@@ -5,6 +5,27 @@ import { describe, expect, it } from "vitest";
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
if (
expected === null ||
typeof expected !== "object" ||
actual === null ||
typeof actual !== "object"
) {
expect(typeof actual).toBe(typeof expected);
return;
}
const expectedRecord = expected as Record<string, unknown>;
const actualRecord = actual as Record<string, unknown>;
expect(Object.keys(actualRecord).sort()).toEqual(
Object.keys(expectedRecord).sort(),
);
for (const key of Object.keys(expectedRecord)) {
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
}
}
describe("managed record settings UI", () => {
it("gates shared deep links and renders integration status without controls", async () => {
const [
@@ -54,10 +75,31 @@ describe("managed record settings UI", () => {
expect(managedSettingsView).not.toContain("/api/auth/");
expect(managedSettingsView).not.toContain("/api/user/");
expect(managedSettingsView).not.toContain("<textarea");
expect(managedSettingsView).not.toContain("Display name");
expect(managedSettingsView).not.toContain("Enable mood reminder");
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
.managedSettings;
for (const bundle of localeBundles) {
expect(bundle).toContain('"sharedRecord"');
expect(bundle).toContain('"unavailableTitle"');
expect(bundle).toContain('"integrationStatusDescription"');
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
expect(sharedRecord).toMatchObject({
unavailableTitle: expect.any(String),
integrationStatusDescription: expect.any(String),
managedSettings: {
profile: {
title: expect.any(String),
displayName: expect.any(String),
},
modules: { title: expect.any(String) },
notifications: { title: expect.any(String) },
thresholds: { title: expect.any(String) },
coach: { title: expect.any(String) },
insights: { title: expect.any(String) },
},
});
expectMatchingMessageKeys(
referenceMessages,
sharedRecord.managedSettings,
);
}
});
});
+16 -3
View File
@@ -14,11 +14,16 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
invalidateUserHealthScore,
invalidateUserInsightsLayout,
invalidateUserMedications,
invalidateUserProfile,
} from "@/lib/cache/invalidate";
import { prisma, toJson } from "@/lib/db";
import { type ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import {
getAllEffectiveRanges,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import {
isManagedRecordSettingsFamily,
managedModulePreferencesFrom,
@@ -117,11 +122,15 @@ export const GET = apiHandler(
};
break;
}
case "thresholds":
case "thresholds": {
const overrides = (record.thresholdsJson ??
{}) as ThresholdOverridesJson;
settings = {
overrides: (record.thresholdsJson ?? {}) as ThresholdOverridesJson,
overrides,
effective: getAllEffectiveRanges(record, overrides),
};
break;
}
case "coach": {
const preferences = parseCoachPrefs(record.coachPrefsJson);
settings = {
@@ -211,6 +220,9 @@ export const PATCH = apiHandler(
},
});
invalidateUserProfile(access.recordId);
if (patch.timezone !== undefined) {
invalidateUserMedications(access.recordId, { evict: true });
}
settings = {
...record,
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
@@ -232,6 +244,7 @@ export const PATCH = apiHandler(
where: { id: access.recordId },
data: { modulePreferencesJson: toJson(modulePreferences) },
});
invalidateUserHealthScore(access.recordId);
settings = { modulePreferences };
changed = Object.keys(patch.modulePreferences);
break;
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
import { ALL_METRICS } from "@/lib/validations/thresholds";
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
const render = (
@@ -8,13 +12,15 @@ const render = (
settings: Record<string, unknown>,
) =>
renderToStaticMarkup(
<ManagedRecordSettingsForm
disabled={false}
family={family}
onSave={vi.fn()}
saveLabel="Save"
settings={settings}
/>,
<I18nProvider initialLocale="en">
<ManagedRecordSettingsForm
disabled={false}
family={family}
onSave={vi.fn()}
saveLabel="Save"
settings={settings}
/>
</I18nProvider>,
);
describe("managed record settings forms", () => {
@@ -62,4 +68,26 @@ describe("managed record settings forms", () => {
expect(html).not.toContain("<textarea");
},
);
it("renders every direct module and threshold metric for a fresh record", () => {
const modules = render("modules", { modulePreferences: {} });
const thresholds = render("thresholds", {
overrides: {},
effective: Object.fromEntries(
ALL_METRICS.map((metric) => [
metric,
{ range: { greenMin: 10, greenMax: 20 } },
]),
),
});
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
expect(modules).toContain(`name="${module}"`);
}
for (const metric of ALL_METRICS) {
expect(thresholds).toContain(`value="${metric}"`);
}
expect(thresholds).toContain('value="10"');
expect(thresholds).toContain('value="20"');
});
});
@@ -7,12 +7,19 @@ 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 { 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 { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCardHeader } from "./_card-header";
@@ -60,6 +67,22 @@ const COACH_DATA_CLUSTERS = [
"mobility",
"environment",
] as const;
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)
@@ -100,6 +123,7 @@ function ProfileSettingsForm({
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
@@ -121,7 +145,7 @@ function ProfileSettingsForm({
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">
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)}
@@ -131,7 +155,7 @@ function ProfileSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-height">
Height (cm)
{t("settings.sharedRecord.managedSettings.profile.height")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
@@ -146,7 +170,7 @@ function ProfileSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
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)}
@@ -157,7 +181,7 @@ function ProfileSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
Gender
{t("settings.sharedRecord.managedSettings.profile.gender")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.gender)}
@@ -165,14 +189,22 @@ function ProfileSettingsForm({
id="managed-gender"
name="gender"
>
<option value="">Not specified</option>
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="OTHER">Other</option>
<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">
Locale
{t("settings.sharedRecord.managedSettings.profile.locale")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asString(settings.locale)}
@@ -180,7 +212,9 @@ function ProfileSettingsForm({
id="managed-locale"
name="locale"
>
<option value="">System default</option>
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
<option key={locale} value={locale}>
{locale}
@@ -189,7 +223,7 @@ function ProfileSettingsForm({
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
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")}
@@ -200,7 +234,7 @@ function ProfileSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-units">
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")}
@@ -208,12 +242,16 @@ function ProfileSettingsForm({
id="managed-units"
name="unitPreference"
>
<option value="metric">Metric</option>
<option value="imperial">Imperial</option>
<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">
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")}
@@ -221,13 +259,19 @@ function ProfileSettingsForm({
id="managed-time-format"
name="timeFormat"
>
<option value="AUTO">Automatic</option>
<option value="H12">12-hour</option>
<option value="H24">24-hour</option>
<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">
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")}
@@ -235,10 +279,18 @@ function ProfileSettingsForm({
id="managed-date-format"
name="dateFormat"
>
<option value="AUTO">Automatic</option>
<option value="DMY">Day / month / year</option>
<option value="MDY">Month / day / year</option>
<option value="YMD">Year / month / day</option>
<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">
@@ -254,10 +306,12 @@ function ModulesSettingsForm({
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.modulePreferences);
const entries = Object.entries(preferences).sort(([left], [right]) =>
left.localeCompare(right),
);
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();
@@ -271,15 +325,19 @@ function ModulesSettingsForm({
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
{entries.map(([key, enabled]) => (
{entries.map(([key, defaultEnabled]) => (
<label className="flex items-center gap-3 text-sm" key={key}>
<input
defaultChecked={enabled === true}
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
name={key}
type="checkbox"
/>
{key}
{t(MODULE_REGISTRY[key].labelKey)}
</label>
))}
<SaveButton disabled={disabled} label={saveLabel} />
@@ -293,6 +351,7 @@ function NotificationsSettingsForm({
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.notificationPreferences);
const medication = asRecord(preferences.medication);
const mood = asRecord(preferences.mood);
@@ -324,10 +383,10 @@ function NotificationsSettingsForm({
name="moodReminderEnabled"
type="checkbox"
/>
Enable mood reminder
{t("settings.sharedRecord.managedSettings.notifications.moodReminder")}
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
Mood 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)}
@@ -340,7 +399,9 @@ function NotificationsSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-low-stock">
Low-stock runway days
{t(
"settings.sharedRecord.managedSettings.notifications.lowStockRunwayDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={
@@ -357,7 +418,9 @@ function NotificationsSettingsForm({
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-reorder-lead">
Reorder lead days
{t(
"settings.sharedRecord.managedSettings.notifications.reorderLeadDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(medication.reorderLeadDays, 10)}
@@ -382,20 +445,30 @@ function ThresholdsSettingsForm({
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const overrides = asRecord(settings.overrides);
const metrics = Object.keys(overrides);
const metric = metrics[0] ?? "WEIGHT";
const firstRange = asRecord(overrides[metric]);
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);
const selectedMetric = asString(form.get("metric"), metric);
onSave({
overrides: {
[selectedMetric]: {
min: formNumber(form, "minimum", asNumber(firstRange.min, 0)),
max: formNumber(form, "maximum", asNumber(firstRange.max, 0)),
[metric]: {
min: formNumber(form, "minimum", minimum),
max: formNumber(form, "maximum", maximum),
},
},
});
@@ -404,40 +477,43 @@ function ThresholdsSettingsForm({
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">
Metric
{t("settings.sharedRecord.managedSettings.thresholds.metric")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={metric}
onChange={(event) => setMetric(event.target.value as ThresholdMetric)}
value={metric}
disabled={disabled}
id="managed-threshold-metric"
name="metric"
>
{[...new Set([metric, ...metrics])].map((entry) => (
{ALL_METRICS.map((entry) => (
<option key={entry} value={entry}>
{entry}
{t(METRIC_LABEL_KEYS[entry])}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
Minimum
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(firstRange.min, 0)}
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">
Maximum
{t("settings.sharedRecord.managedSettings.thresholds.maximum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
defaultValue={asNumber(firstRange.max, 0)}
defaultValue={maximum}
disabled={disabled}
id="managed-threshold-maximum"
key={`${metric}-maximum`}
name="maximum"
required
type="number"
@@ -456,6 +532,7 @@ function CoachSettingsForm({
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));
@@ -493,11 +570,11 @@ function CoachSettingsForm({
name="disableCoach"
type="checkbox"
/>
Disable Coach
{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">
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")}
@@ -505,13 +582,19 @@ function CoachSettingsForm({
id="managed-coach-tone"
name="tone"
>
<option value="warm">Warm</option>
<option value="neutral">Neutral</option>
<option value="concise">Concise</option>
<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">
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")}
@@ -519,13 +602,19 @@ function CoachSettingsForm({
id="managed-coach-verbosity"
name="verbosity"
>
<option value="brief">Brief</option>
<option value="default">Default</option>
<option value="detailed">Detailed</option>
<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">
Default 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")}
@@ -533,10 +622,18 @@ function CoachSettingsForm({
id="managed-coach-window"
name="defaultWindow"
>
<option value="last7days">Last 7 days</option>
<option value="last30days">Last 30 days</option>
<option value="last90days">Last 90 days</option>
<option value="allTime">All time</option>
<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>
@@ -547,10 +644,12 @@ function CoachSettingsForm({
name="showEvidenceByDefault"
type="checkbox"
/>
Show evidence by default
{t("settings.sharedRecord.managedSettings.coach.showEvidence")}
</label>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Excluded metrics</legend>
<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"
@@ -562,12 +661,16 @@ function CoachSettingsForm({
name={`exclude-${metric}`}
type="checkbox"
/>
{metric}
{t("settings.sharedRecord.managedSettings.coach.value", {
value: metric,
})}
</label>
))}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Data clusters</legend>
<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)}
@@ -575,7 +678,7 @@ function CoachSettingsForm({
name="useCustomClusters"
type="checkbox"
/>
Use a custom cluster selection
{t("settings.sharedRecord.managedSettings.coach.customClusters")}
</label>
{COACH_DATA_CLUSTERS.map((cluster) => (
<label
@@ -588,7 +691,9 @@ function CoachSettingsForm({
name={`cluster-${cluster}`}
type="checkbox"
/>
{cluster}
{t("settings.sharedRecord.managedSettings.coach.value", {
value: cluster,
})}
</label>
))}
</fieldset>
@@ -622,6 +727,7 @@ function InsightsSettingsForm({
onSave,
saveLabel,
}: SettingsFormProps) {
const { t } = useTranslations();
const layout = asRecord(settings.layout);
const sections = layoutItems(layout.sections);
const tiles = layoutItems(layout.tiles);
@@ -646,7 +752,9 @@ function InsightsSettingsForm({
const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{kind === "section" ? "Overview sections" : "Navigation tiles"}
{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}>
@@ -657,13 +765,15 @@ function InsightsSettingsForm({
name={`${kind}-visible-${item.id}`}
type="checkbox"
/>
{item.id}
{t("settings.sharedRecord.managedSettings.insights.item", {
id: item.id,
})}
</label>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
Order
{t("settings.sharedRecord.managedSettings.insights.order")}
<input
className="border-input bg-background w-16 rounded-md border px-2 py-1"
defaultValue={item.order}
@@ -770,7 +880,7 @@ export function ManagedRecordSettingsSection({
<SettingsCard aria-busy={query.isLoading}>
<SettingsCardHeader
icon={Settings}
title={t(`settings.sections.${family}.title`)}
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
/>
{query.data ? (
<>
+22 -5
View File
@@ -4,8 +4,12 @@ import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
} from "@/lib/insights-layout";
import { MODULE_REGISTRY } from "@/lib/modules/registry";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
import {
modulePrefsPatchSchema,
WRITABLE_MODULE_KEYS,
} from "@/lib/validations/modules";
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
/**
@@ -54,7 +58,7 @@ const profilePatchSchema = z
displayName: z.string().trim().min(1).max(100).nullable().optional(),
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
dateOfBirth: z.string().date().nullable().optional(),
gender: z.string().trim().min(1).max(64).nullable().optional(),
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
timezone: timezoneSchema.optional(),
unitPreference: z.enum(["metric", "imperial"]).optional(),
@@ -161,7 +165,7 @@ export type ManagedRecordSettingsPatch = {
export function isManagedRecordSettingsFamily(
value: string,
): value is ManagedRecordSettingsFamily {
return value in MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS;
return Object.hasOwn(MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, value);
}
/** Parse one named DTO family. Strict schemas make an actor field a 422. */
@@ -179,15 +183,28 @@ export function safeParseManagedRecordSettingsPatch<
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value);
}
/**
* The same direct module inventory and default posture as `/api/auth/me/modules`.
* Every entry is present for a fresh record so a Guardian can administer the
* whole supported surface instead of seeing only previously persisted keys.
*/
export const MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS = Object.freeze(
Object.fromEntries(
WRITABLE_MODULE_KEYS.map((key) => [key, !MODULE_REGISTRY[key].optIn]),
),
) as Readonly<Record<(typeof WRITABLE_MODULE_KEYS)[number], boolean>>;
/** Preserve only directly-owned module keys from a legacy persisted blob. */
export function managedModulePreferencesFrom(
raw: unknown,
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
const allowed: Record<string, boolean> = {
...MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
};
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
return allowed;
}
const allowed: Record<string, boolean> = {};
for (const [key, value] of Object.entries(raw)) {
const parsed = modulePrefsPatchSchema.safeParse({ [key]: value });
if (parsed.success && value !== undefined) {
+1
View File
@@ -16,6 +16,7 @@ export {
isManagedRecordSettingsFamily,
managedModulePreferencesFrom,
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
parseManagedRecordSettingsPatch,
safeParseManagedRecordSettingsPatch,
type ManagedRecordSettingsFamily,
+1 -1
View File
@@ -38,7 +38,7 @@ import {
* opt into it gets a clean 422 rather than persisting an inert `true` the
* gate would ignore anyway.
*/
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
export const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
(k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
);