refactor(settings): split the managed record settings forms

Each family form moves into managed-record/ beside a small kit (the
coercion helpers, the form footer and the switch row); the section keeps
the record-scoped read, the save and the family switch.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:13:55 +02:00
1 parent 7c1ed25915
commit c9917d2de0
13 files changed
+1026 -927

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 114,
"total": 131617,
"count": 113,
"total": 130546,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -28,7 +28,6 @@
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
"components/records/life-circumstances-manager.tsx": 1080,
"components/settings/managed-record-settings-section.tsx": 1071,
"components/timeline/timeline-chart.tsx": 1174,
"components/timeline/timeline-geometry.ts": 1395,
"lib/ai/anthropic-client.ts": 804,
+17 -5
View File
@@ -1,10 +1,25 @@
import { readFile } from "node:fs/promises";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
/** The managed settings section and every family form it composes. */
async function readManagedSettingsView(): Promise<string> {
const folder = fromRoot("src/components/settings/managed-record");
const forms = (await readdir(folder)).filter((name) => name.endsWith(".tsx"));
expect(forms.length).toBeGreaterThanOrEqual(7);
const sources = await Promise.all([
readFile(
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
"utf8",
),
...forms.map((name) => readFile(resolve(folder, name), "utf8")),
]);
return sources.join("\n");
}
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
if (
expected === null ||
@@ -40,10 +55,7 @@ describe("managed record settings UI", () => {
fromRoot("src/components/settings/managed-integration-status.tsx"),
"utf8",
),
readFile(
fromRoot("src/components/settings/managed-record-settings-section.tsx"),
"utf8",
),
readManagedSettingsView(),
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
readFile(fromRoot("src/app/settings/layout/[module]/page.tsx"), "utf8"),
readFile(
@@ -144,7 +144,7 @@ const RULES: ResolvedFieldRule[] = [
allow: {
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
"src/components/settings/managed-record/coach-form.tsx":
"the managed record's own settings form: the field's default is the record's stored opt-out",
"src/hooks/use-auth.ts":
"the `/me` payload normaliser: coerces the field against a stale payload, decides nothing",
@@ -5,10 +5,8 @@ import { I18nProvider } from "@/lib/i18n/context";
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
import { ALL_METRICS } from "@/lib/validations/thresholds";
import {
managedModulesPatch,
ManagedRecordSettingsForm,
} from "../managed-record-settings-section";
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
import { managedModulesPatch } from "../managed-record/modules-form";
const LOCALES = [
"de",
@@ -2,7 +2,7 @@
* The option lists and label keys of the managed-record settings forms:
* which Coach metrics and data clusters a guardian can exclude, and the
* bundle key each option, insights section and threshold metric renders
* with. Data only; the forms live in `managed-record-settings-section.tsx`.
* with. Data only; the forms live in `managed-record/`.
*/
import type { InsightsSectionId } from "@/lib/insights-layout";
import type { ThresholdMetric } from "@/lib/analytics/effective-range";
@@ -1,14 +1,13 @@
"use client";
import { useState, type FormEvent, type ReactNode } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { normalizeInsightsTileId } from "@/lib/insights-layout";
import { MODULE_REGISTRY } from "@/lib/modules/registry";
import {
aiInputDependentKeys,
invalidateKeys,
@@ -16,42 +15,19 @@ import {
} 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 { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
import { SettingsCardHeader } from "./_card-header";
import { CoachSettingsForm } from "./managed-record/coach-form";
import type { SettingsFormProps } from "./managed-record/form-kit";
import { InsightsSettingsForm } from "./managed-record/insights-form";
import { ModulesSettingsForm } from "./managed-record/modules-form";
import { NotificationsSettingsForm } from "./managed-record/notifications-form";
import { ProfileSettingsForm } from "./managed-record/profile-form";
import { ThresholdsSettingsForm } from "./managed-record/thresholds-form";
import { SettingsCard } from "./settings-card";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { locales } from "@/lib/i18n/config";
import {
COACH_EXCLUDE_METRICS,
COACH_DATA_CLUSTERS,
COACH_EXCLUDE_METRIC_LABEL_KEYS,
COACH_DATA_CLUSTER_LABEL_KEYS,
INSIGHTS_SECTION_LABEL_KEYS,
METRIC_LABEL_KEYS,
} from "./managed-record-settings-labels";
interface ManagedRecordSettingsResponse {
recordId: string;
@@ -63,885 +39,6 @@ type ManagedSettingsFamily = Exclude<
ManagedRecordSettingsFamily,
"integrations"
>;
type SettingsFormProps = {
settings: Record<string, unknown>;
disabled: boolean;
onSave: (patch: unknown) => void;
saveLabel: string;
/** Save/validation error, rendered ABOVE the action row per §12. */
error?: ReactNode;
};
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");
}
/**
* The form footer, §12: the error line sits ABOVE the action row, and the
* action row (`SettingsCardActions`) is the last thing in the form. `span`
* lets a grid form put the footer in a full-width cell.
*/
function FormFooter({
disabled,
label,
error,
span,
}: {
disabled: boolean;
label: string;
error?: ReactNode;
span?: string;
}) {
return (
<div className={span ? `${span} space-y-3` : "space-y-3"}>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<SettingsCardActions>
<Button disabled={disabled} type="submit">
{label}
</Button>
</SettingsCardActions>
</div>
);
}
/**
* A managed-settings toggle row — a `<Switch>` (the same primitive the owner
* module section uses) paired with a hidden mirror input so the FormData
* submit still reads `name === "on"`. Uncontrolled at the DOM level, controlled
* in React so the switch and its mirror stay in lockstep.
*/
function ManagedSwitchRow({
name,
label,
defaultChecked,
disabled,
className,
}: {
name: string;
label: string;
defaultChecked: boolean;
disabled: boolean;
className?: string;
}) {
const id = `managed-switch-${name}`;
const [on, setOn] = useState(defaultChecked);
return (
<div
className={className ?? "flex items-center justify-between gap-3 text-sm"}
>
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Switch
id={id}
checked={on}
onCheckedChange={setOn}
disabled={disabled}
aria-label={label}
className="shrink-0"
/>
<input name={name} type="hidden" value={on ? "on" : "off"} />
</div>
);
}
function ProfileSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="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
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
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")}
{/* v1.25.4 — the profile date must not depend on the browser's date
parsing; DateField displays in the user's order and submits ISO
through its hidden mirror. */}
<DateField
defaultValue={asString(settings.dateOfBirth)}
disabled={disabled}
id="managed-date-of-birth"
name="dateOfBirth"
autoComplete="bday"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
{t("settings.sharedRecord.managedSettings.profile.gender")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
{t("settings.sharedRecord.managedSettings.profile.locale")}
<NativeSelect
defaultValue={asString(settings.locale)}
disabled={disabled}
id="managed-locale"
name="locale"
>
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
{t("settings.sharedRecord.managedSettings.profile.timezone")}
<Input
defaultValue={asString(settings.timezone, DEFAULT_TIMEZONE)}
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")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
{t("settings.sharedRecord.managedSettings.profile.timeFormat")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
{t("settings.sharedRecord.managedSettings.profile.dateFormat")}
<NativeSelect
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>
</NativeSelect>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
/**
* What a Modules save actually sends.
*
* The direct modules ride along whether or not they moved — they are one blob
* the route merges, and a key that did not change writes the value it already
* had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module
* whose column the cycle gate honours OVER the sex-derived default, so sending
* the switch's current position on every save freezes the derivation. A record
* created with no recorded sex derives cycle OFF; a guardian who turns Labs off
* and saves would write that OFF as an explicit answer, and the record's sex
* being set to FEMALE afterwards would then leave cycle off with nothing on
* screen to say why. It also put `cycleTrackingEnabled` in the record's audit
* trail on every save of any module.
*
* So it is sent only when it differs from the value the form opened on, which
* is what the managed-record edit form's `managedProfileEditPatch` already does
* for its own five fields.
*
* Exported and pure because it is the decision this form makes, and a decision
* about what NOT to send is invisible in the rendered markup.
*/
export function managedModulesPatch(
settings: Record<string, unknown>,
form: FormData,
): {
modulePreferences: Record<string, boolean>;
cycleTrackingEnabled?: boolean;
} {
const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on";
// The same expression the switch is seeded from, so "differs from what the
// form opened on" is one statement rather than two that can drift.
const loaded = settings.cycleTrackingEnabled === true;
return {
modulePreferences: Object.fromEntries(
Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [
key,
form.get(key) === "on",
]),
),
...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}),
};
}
function ModulesSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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();
onSave(managedModulesPatch(settings, new FormData(event.currentTarget)));
}
return (
<form className="space-y-3" onSubmit={submit}>
{entries.map(([key, defaultEnabled]) => (
<ManagedSwitchRow
key={key}
name={key}
label={t(MODULE_REGISTRY[key].labelKey)}
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
/>
))}
{/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of
them: it is a DELEGATED module, so its state lives in the record's own
cycle profile rather than in the module blob, and it had no row here
at all. A guardian could not turn it off for a record whose sex says
it should be on, which is the case the issue was opened about. */}
<ManagedSwitchRow
name="cycleTrackingEnabled"
label={t(MODULE_REGISTRY.cycle.labelKey)}
defaultChecked={settings.cycleTrackingEnabled === true}
disabled={disabled}
/>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
function NotificationsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<ManagedSwitchRow
name="moodReminderEnabled"
label={t(
"settings.sharedRecord.managedSettings.notifications.moodReminder",
)}
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
className="flex items-center justify-between gap-3 text-sm sm:col-span-2"
/>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
{t("settings.sharedRecord.managedSettings.notifications.reminderHour")}
<Input
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
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
defaultValue={asNumber(medication.reorderLeadDays, 10)}
disabled={disabled}
id="managed-reorder-lead"
max="60"
min="0"
name="reorderLeadDays"
type="number"
/>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
function ThresholdsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="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")}
<NativeSelect
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>
))}
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
<Input
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
defaultValue={maximum}
disabled={disabled}
id="managed-threshold-maximum"
key={`${metric}-maximum`}
name="maximum"
required
type="number"
/>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-3"
/>
</form>
);
}
function CoachSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
const savedWindow = asString(preferences.defaultWindow, "allTime");
const [lookback, setLookback] = useState<CoachDefaultWindow>(
(COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow)
? (savedWindow as CoachDefaultWindow)
: "allTime",
);
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",
),
defaultWindow: asString(form.get("defaultWindow"), "allTime"),
...(form.get("useCustomClusters") === "on"
? {
dataClusters: COACH_DATA_CLUSTERS.filter(
(cluster) => form.get(`cluster-${cluster}`) === "on",
),
}
: {}),
},
});
}
return (
<form className="space-y-4" onSubmit={submit}>
<ManagedSwitchRow
name="disableCoach"
label={t("settings.sharedRecord.managedSettings.coach.disable")}
defaultChecked={settings.disableCoach === true}
disabled={disabled}
/>
<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")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
{t("settings.sharedRecord.managedSettings.coach.verbosity")}
<NativeSelect
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>
</NativeSelect>
</label>
{/* The same lookback options and wording as the Coach's own picker:
the choice is a limit on every Coach read. */}
<div className="grid gap-1 text-sm">
<label htmlFor="managed-coach-window">
{t("insights.coach.lookback.label")}
</label>
<NativeSelect
value={lookback}
onChange={(event) =>
setLookback(event.currentTarget.value as CoachDefaultWindow)
}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
aria-describedby="managed-coach-window-detail"
>
{COACH_HISTORY_WINDOWS.map((window) => (
<option key={window} value={window}>
{t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
)}
</option>
))}
</NativeSelect>
<p
id="managed-coach-window-detail"
className="text-muted-foreground text-xs"
>
{t(
`insights.coach.lookback.${lookbackText(lookback).detailKey}`,
lookbackText(lookback).detailParams,
)}
{HISTORY_REACH_DAYS[lookback] !== null
? ` ${t("insights.coach.lookback.note")}`
: null}
</p>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.excludedMetrics")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{COACH_EXCLUDE_METRICS.map((metric) => (
<ManagedSwitchRow
key={metric}
name={`exclude-${metric}`}
label={coachMetricLabel(t, metric)}
defaultChecked={excluded.has(metric)}
disabled={disabled}
/>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.dataClusters")}
</legend>
<ManagedSwitchRow
name="useCustomClusters"
label={t(
"settings.sharedRecord.managedSettings.coach.customClusters",
)}
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
/>
<div className="grid gap-2 sm:grid-cols-2">
{COACH_DATA_CLUSTERS.map((cluster) => (
<ManagedSwitchRow
key={cluster}
name={`cluster-${cluster}`}
label={coachClusterLabel(t, cluster)}
defaultChecked={dataClusters.has(cluster)}
disabled={disabled}
/>
))}
</div>
</fieldset>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</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,
error,
}: 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}>
<ManagedSwitchRow
className="flex flex-1 items-center justify-between gap-2 text-sm"
name={`${kind}-visible-${item.id}`}
label={insightItemLabel(t, kind, item.id)}
defaultChecked={item.visible}
disabled={disabled}
/>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
{t("settings.sharedRecord.managedSettings.insights.order")}
<Input
className="w-16"
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="space-y-4" onSubmit={submit}>
{renderItems("section", sections)}
{renderItems("tile", tiles)}
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
export function ManagedRecordSettingsForm({
family,
@@ -0,0 +1,218 @@
import { useState, type FormEvent } from "react";
import { NativeSelect } from "@/components/ui/native-select";
import {
COACH_HISTORY_WINDOWS,
HISTORY_REACH_DAYS,
lookbackText,
} from "@/lib/ai/coach/history-reach";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachDefaultWindow } from "@/lib/validations/coach-prefs";
import {
COACH_DATA_CLUSTER_LABEL_KEYS,
COACH_DATA_CLUSTERS,
COACH_EXCLUDE_METRIC_LABEL_KEYS,
COACH_EXCLUDE_METRICS,
} from "../managed-record-settings-labels";
import {
asRecord,
asString,
asStringArray,
FormFooter,
ManagedSwitchRow,
ownStringValue,
type SettingsFormProps,
} from "./form-kit";
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");
}
export function CoachSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.preferences);
const excluded = new Set(asStringArray(preferences.excludeMetrics));
const dataClusters = new Set(asStringArray(preferences.dataClusters));
const savedWindow = asString(preferences.defaultWindow, "allTime");
const [lookback, setLookback] = useState<CoachDefaultWindow>(
(COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow)
? (savedWindow as CoachDefaultWindow)
: "allTime",
);
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",
),
defaultWindow: asString(form.get("defaultWindow"), "allTime"),
...(form.get("useCustomClusters") === "on"
? {
dataClusters: COACH_DATA_CLUSTERS.filter(
(cluster) => form.get(`cluster-${cluster}`) === "on",
),
}
: {}),
},
});
}
return (
<form className="space-y-4" onSubmit={submit}>
<ManagedSwitchRow
name="disableCoach"
label={t("settings.sharedRecord.managedSettings.coach.disable")}
defaultChecked={settings.disableCoach === true}
disabled={disabled}
/>
<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")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
{t("settings.sharedRecord.managedSettings.coach.verbosity")}
<NativeSelect
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>
</NativeSelect>
</label>
{/* The same lookback options and wording as the Coach's own picker:
the choice is a limit on every Coach read. */}
<div className="grid gap-1 text-sm">
<label htmlFor="managed-coach-window">
{t("insights.coach.lookback.label")}
</label>
<NativeSelect
value={lookback}
onChange={(event) =>
setLookback(event.currentTarget.value as CoachDefaultWindow)
}
disabled={disabled}
id="managed-coach-window"
name="defaultWindow"
aria-describedby="managed-coach-window-detail"
>
{COACH_HISTORY_WINDOWS.map((window) => (
<option key={window} value={window}>
{t(
`insights.coach.lookback.option.${window}`,
lookbackText(window).optionParams,
)}
</option>
))}
</NativeSelect>
<p
id="managed-coach-window-detail"
className="text-muted-foreground text-xs"
>
{t(
`insights.coach.lookback.${lookbackText(lookback).detailKey}`,
lookbackText(lookback).detailParams,
)}
{HISTORY_REACH_DAYS[lookback] !== null
? ` ${t("insights.coach.lookback.note")}`
: null}
</p>
</div>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.excludedMetrics")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{COACH_EXCLUDE_METRICS.map((metric) => (
<ManagedSwitchRow
key={metric}
name={`exclude-${metric}`}
label={coachMetricLabel(t, metric)}
defaultChecked={excluded.has(metric)}
disabled={disabled}
/>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.dataClusters")}
</legend>
<ManagedSwitchRow
name="useCustomClusters"
label={t(
"settings.sharedRecord.managedSettings.coach.customClusters",
)}
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
/>
<div className="grid gap-2 sm:grid-cols-2">
{COACH_DATA_CLUSTERS.map((cluster) => (
<ManagedSwitchRow
key={cluster}
name={`cluster-${cluster}`}
label={coachClusterLabel(t, cluster)}
defaultChecked={dataClusters.has(cluster)}
disabled={disabled}
/>
))}
</div>
</fieldset>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -0,0 +1,128 @@
import { useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "../_card-actions";
/** What every managed-record family form receives from the section. */
export type SettingsFormProps = {
settings: Record<string, unknown>;
disabled: boolean;
onSave: (patch: unknown) => void;
saveLabel: string;
/** Save/validation error, rendered ABOVE the action row per §12. */
error?: ReactNode;
};
export function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
export function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
export function formNumber(
form: FormData,
name: string,
fallback: number,
): number {
const value = Number(form.get(name));
return Number.isFinite(value) ? value : fallback;
}
export 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;
}
/**
* The form footer, §12: the error line sits ABOVE the action row, and the
* action row (`SettingsCardActions`) is the last thing in the form. `span`
* lets a grid form put the footer in a full-width cell.
*/
export function FormFooter({
disabled,
label,
error,
span,
}: {
disabled: boolean;
label: string;
error?: ReactNode;
span?: string;
}) {
return (
<div className={span ? `${span} space-y-3` : "space-y-3"}>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<SettingsCardActions>
<Button disabled={disabled} type="submit">
{label}
</Button>
</SettingsCardActions>
</div>
);
}
/**
* A managed-settings toggle row — a `<Switch>` (the same primitive the owner
* module section uses) paired with a hidden mirror input so the FormData
* submit still reads `name === "on"`. Uncontrolled at the DOM level, controlled
* in React so the switch and its mirror stay in lockstep.
*/
export function ManagedSwitchRow({
name,
label,
defaultChecked,
disabled,
className,
}: {
name: string;
label: string;
defaultChecked: boolean;
disabled: boolean;
className?: string;
}) {
const id = `managed-switch-${name}`;
const [on, setOn] = useState(defaultChecked);
return (
<div
className={className ?? "flex items-center justify-between gap-3 text-sm"}
>
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Switch
id={id}
checked={on}
onCheckedChange={setOn}
disabled={disabled}
aria-label={label}
className="shrink-0"
/>
<input name={name} type="hidden" value={on ? "on" : "off"} />
</div>
);
}
@@ -0,0 +1,136 @@
import type { FormEvent } from "react";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { normalizeInsightsTileId } from "@/lib/insights-layout";
import { INSIGHTS_SECTION_LABEL_KEYS } from "../managed-record-settings-labels";
import {
asNumber,
asRecord,
asString,
FormFooter,
formNumber,
ManagedSwitchRow,
ownStringValue,
type SettingsFormProps,
} from "./form-kit";
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");
}
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),
},
]
: [];
});
}
export function InsightsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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}>
<ManagedSwitchRow
className="flex flex-1 items-center justify-between gap-2 text-sm"
name={`${kind}-visible-${item.id}`}
label={insightItemLabel(t, kind, item.id)}
defaultChecked={item.visible}
disabled={disabled}
/>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
{t("settings.sharedRecord.managedSettings.insights.order")}
<Input
className="w-16"
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="space-y-4" onSubmit={submit}>
{renderItems("section", sections)}
{renderItems("tile", tiles)}
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -0,0 +1,105 @@
import type { FormEvent } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { MODULE_REGISTRY } from "@/lib/modules/registry";
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
import {
asRecord,
FormFooter,
ManagedSwitchRow,
type SettingsFormProps,
} from "./form-kit";
/**
* What a Modules save actually sends.
*
* The direct modules ride along whether or not they moved — they are one blob
* the route merges, and a key that did not change writes the value it already
* had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module
* whose column the cycle gate honours OVER the sex-derived default, so sending
* the switch's current position on every save freezes the derivation. A record
* created with no recorded sex derives cycle OFF; a guardian who turns Labs off
* and saves would write that OFF as an explicit answer, and the record's sex
* being set to FEMALE afterwards would then leave cycle off with nothing on
* screen to say why. It also put `cycleTrackingEnabled` in the record's audit
* trail on every save of any module.
*
* So it is sent only when it differs from the value the form opened on, which
* is what the managed-record edit form's `managedProfileEditPatch` already does
* for its own five fields.
*
* Exported and pure because it is the decision this form makes, and a decision
* about what NOT to send is invisible in the rendered markup.
*/
export function managedModulesPatch(
settings: Record<string, unknown>,
form: FormData,
): {
modulePreferences: Record<string, boolean>;
cycleTrackingEnabled?: boolean;
} {
const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on";
// The same expression the switch is seeded from, so "differs from what the
// form opened on" is one statement rather than two that can drift.
const loaded = settings.cycleTrackingEnabled === true;
return {
modulePreferences: Object.fromEntries(
Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [
key,
form.get(key) === "on",
]),
),
...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}),
};
}
export function ModulesSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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();
onSave(managedModulesPatch(settings, new FormData(event.currentTarget)));
}
return (
<form className="space-y-3" onSubmit={submit}>
{entries.map(([key, defaultEnabled]) => (
<ManagedSwitchRow
key={key}
name={key}
label={t(MODULE_REGISTRY[key].labelKey)}
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
/>
))}
{/* Cycle sits below the rest because it is not one of
them: it is a DELEGATED module, so its state lives in the record's own
cycle profile rather than in the module blob, and it had no row here
at all. A guardian could not turn it off for a record whose sex says
it should be on, which is the case #939 was opened about. */}
<ManagedSwitchRow
name="cycleTrackingEnabled"
label={t(MODULE_REGISTRY.cycle.labelKey)}
defaultChecked={settings.cycleTrackingEnabled === true}
disabled={disabled}
/>
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -0,0 +1,108 @@
import type { FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import {
asNumber,
asRecord,
FormFooter,
formNumber,
ManagedSwitchRow,
type SettingsFormProps,
} from "./form-kit";
export function NotificationsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<ManagedSwitchRow
name="moodReminderEnabled"
label={t(
"settings.sharedRecord.managedSettings.notifications.moodReminder",
)}
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
className="flex items-center justify-between gap-3 text-sm sm:col-span-2"
/>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
{t("settings.sharedRecord.managedSettings.notifications.reminderHour")}
<Input
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
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
defaultValue={asNumber(medication.reorderLeadDays, 10)}
disabled={disabled}
id="managed-reorder-lead"
max="60"
min="0"
name="reorderLeadDays"
type="number"
/>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
@@ -0,0 +1,191 @@
import type { FormEvent } from "react";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { locales } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { asString, FormFooter, type SettingsFormProps } from "./form-kit";
export function ProfileSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="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
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
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")}
{/* The profile date must not depend on the browser's date
parsing; DateField displays in the user's order and submits ISO
through its hidden mirror. */}
<DateField
defaultValue={asString(settings.dateOfBirth)}
disabled={disabled}
id="managed-date-of-birth"
name="dateOfBirth"
autoComplete="bday"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
{t("settings.sharedRecord.managedSettings.profile.gender")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
{t("settings.sharedRecord.managedSettings.profile.locale")}
<NativeSelect
defaultValue={asString(settings.locale)}
disabled={disabled}
id="managed-locale"
name="locale"
>
<option value="">
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
</option>
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
{t("settings.sharedRecord.managedSettings.profile.timezone")}
<Input
defaultValue={asString(settings.timezone, DEFAULT_TIMEZONE)}
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")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
{t("settings.sharedRecord.managedSettings.profile.timeFormat")}
<NativeSelect
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>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
{t("settings.sharedRecord.managedSettings.profile.dateFormat")}
<NativeSelect
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>
</NativeSelect>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
@@ -0,0 +1,107 @@
import { useState, type FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import {
METRIC_BOUNDS,
type ThresholdMetric,
} from "@/lib/analytics/effective-range";
import { useTranslations } from "@/lib/i18n/context";
import { ALL_METRICS } from "@/lib/validations/thresholds";
import { METRIC_LABEL_KEYS } from "../managed-record-settings-labels";
import {
asNumber,
asRecord,
FormFooter,
formNumber,
type SettingsFormProps,
} from "./form-kit";
export function ThresholdsSettingsForm({
settings,
disabled,
onSave,
saveLabel,
error,
}: 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="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")}
<NativeSelect
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>
))}
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
<Input
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
defaultValue={maximum}
disabled={disabled}
id="managed-threshold-maximum"
key={`${metric}-maximum`}
name="maximum"
required
type="number"
/>
</label>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-3"
/>
</form>
);
}