From c9917d2de0c2e8c7fe28a5b4956e6b478fc6a122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:13:55 +0200 Subject: [PATCH] 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. --- .../baselines/source-file-lines.json | 5 +- src/__tests__/record-settings-ui.test.tsx | 22 +- .../resolved-field-consumer-guard.test.ts | 2 +- .../managed-record-settings-section.test.tsx | 6 +- .../managed-record-settings-labels.ts | 2 +- .../managed-record-settings-section.tsx | 923 +----------------- .../settings/managed-record/coach-form.tsx | 218 +++++ .../settings/managed-record/form-kit.tsx | 128 +++ .../settings/managed-record/insights-form.tsx | 136 +++ .../settings/managed-record/modules-form.tsx | 105 ++ .../managed-record/notifications-form.tsx | 108 ++ .../settings/managed-record/profile-form.tsx | 191 ++++ .../managed-record/thresholds-form.tsx | 107 ++ 13 files changed, 1026 insertions(+), 927 deletions(-) create mode 100644 src/components/settings/managed-record/coach-form.tsx create mode 100644 src/components/settings/managed-record/form-kit.tsx create mode 100644 src/components/settings/managed-record/insights-form.tsx create mode 100644 src/components/settings/managed-record/modules-form.tsx create mode 100644 src/components/settings/managed-record/notifications-form.tsx create mode 100644 src/components/settings/managed-record/profile-form.tsx create mode 100644 src/components/settings/managed-record/thresholds-form.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 3960d69f7..98162b7e5 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index abea892d3..f5a890036 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -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 { + 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( diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index 111d0a380..e5cac26a3 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -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", diff --git a/src/components/settings/__tests__/managed-record-settings-section.test.tsx b/src/components/settings/__tests__/managed-record-settings-section.test.tsx index 06d646cd3..fe973f5c4 100644 --- a/src/components/settings/__tests__/managed-record-settings-section.test.tsx +++ b/src/components/settings/__tests__/managed-record-settings-section.test.tsx @@ -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", diff --git a/src/components/settings/managed-record-settings-labels.ts b/src/components/settings/managed-record-settings-labels.ts index 7e4a54d4c..c581be5d4 100644 --- a/src/components/settings/managed-record-settings-labels.ts +++ b/src/components/settings/managed-record-settings-labels.ts @@ -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"; diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 219578aed..62577dbb0 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -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; - 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 { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -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["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["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["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 ( -
- {error ? ( -

- {error} -

- ) : null} - - - -
- ); -} - -/** - * A managed-settings toggle row — a `` (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 ( -
- - - -
- ); -} - -function ProfileSettingsForm({ - settings, - disabled, - onSave, - saveLabel, - error, -}: SettingsFormProps) { - const { t } = useTranslations(); - function submit(event: FormEvent) { - 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 ( -
- - - - - - - - - - - - ); -} - -/** - * 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, - form: FormData, -): { - modulePreferences: Record; - 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) { - event.preventDefault(); - onSave(managedModulesPatch(settings, new FormData(event.currentTarget))); - } - - return ( -
- {entries.map(([key, defaultEnabled]) => ( - - ))} - {/* 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. */} - - - - ); -} - -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) { - 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 ( -
- - - - - - - ); -} - -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(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) { - event.preventDefault(); - const form = new FormData(event.currentTarget); - onSave({ - overrides: { - [metric]: { - min: formNumber(form, "minimum", minimum), - max: formNumber(form, "maximum", maximum), - }, - }, - }); - } - - return ( -
- - - - - - ); -} - -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( - (COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow) - ? (savedWindow as CoachDefaultWindow) - : "allTime", - ); - - function submit(event: FormEvent) { - 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 ( -
- -
- - - {/* The same lookback options and wording as the Coach's own picker: - the choice is a limit on every Coach read. */} -
- - - 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) => ( - - ))} - -

- {t( - `insights.coach.lookback.${lookbackText(lookback).detailKey}`, - lookbackText(lookback).detailParams, - )} - {HISTORY_REACH_DAYS[lookback] !== null - ? ` ${t("insights.coach.lookback.note")}` - : null} -

-
-
-
- - {t("settings.sharedRecord.managedSettings.coach.excludedMetrics")} - -
- {COACH_EXCLUDE_METRICS.map((metric) => ( - - ))} -
-
-
- - {t("settings.sharedRecord.managedSettings.coach.dataClusters")} - - -
- {COACH_DATA_CLUSTERS.map((cluster) => ( - - ))} -
-
- - - ); -} - -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) { - 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[]) => ( -
- - {kind === "section" - ? t("settings.sharedRecord.managedSettings.insights.sections") - : t("settings.sharedRecord.managedSettings.insights.tiles")} - - {items.map((item) => ( -
- - -
- ))} -
- ); - - return ( -
- {renderItems("section", sections)} - {renderItems("tile", tiles)} - - - ); -} export function ManagedRecordSettingsForm({ family, diff --git a/src/components/settings/managed-record/coach-form.tsx b/src/components/settings/managed-record/coach-form.tsx new file mode 100644 index 000000000..a6bfd415c --- /dev/null +++ b/src/components/settings/managed-record/coach-form.tsx @@ -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["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["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( + (COACH_HISTORY_WINDOWS as readonly string[]).includes(savedWindow) + ? (savedWindow as CoachDefaultWindow) + : "allTime", + ); + + function submit(event: FormEvent) { + 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 ( +
+ +
+ + + {/* The same lookback options and wording as the Coach's own picker: + the choice is a limit on every Coach read. */} +
+ + + 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) => ( + + ))} + +

+ {t( + `insights.coach.lookback.${lookbackText(lookback).detailKey}`, + lookbackText(lookback).detailParams, + )} + {HISTORY_REACH_DAYS[lookback] !== null + ? ` ${t("insights.coach.lookback.note")}` + : null} +

+
+
+
+ + {t("settings.sharedRecord.managedSettings.coach.excludedMetrics")} + +
+ {COACH_EXCLUDE_METRICS.map((metric) => ( + + ))} +
+
+
+ + {t("settings.sharedRecord.managedSettings.coach.dataClusters")} + + +
+ {COACH_DATA_CLUSTERS.map((cluster) => ( + + ))} +
+
+ + + ); +} diff --git a/src/components/settings/managed-record/form-kit.tsx b/src/components/settings/managed-record/form-kit.tsx new file mode 100644 index 000000000..ded5cf854 --- /dev/null +++ b/src/components/settings/managed-record/form-kit.tsx @@ -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; + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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 ( +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+ ); +} + +/** + * A managed-settings toggle row — a `` (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 ( +
+ + + +
+ ); +} diff --git a/src/components/settings/managed-record/insights-form.tsx b/src/components/settings/managed-record/insights-form.tsx new file mode 100644 index 000000000..f96d03df8 --- /dev/null +++ b/src/components/settings/managed-record/insights-form.tsx @@ -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["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) { + 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[]) => ( +
+ + {kind === "section" + ? t("settings.sharedRecord.managedSettings.insights.sections") + : t("settings.sharedRecord.managedSettings.insights.tiles")} + + {items.map((item) => ( +
+ + +
+ ))} +
+ ); + + return ( +
+ {renderItems("section", sections)} + {renderItems("tile", tiles)} + + + ); +} diff --git a/src/components/settings/managed-record/modules-form.tsx b/src/components/settings/managed-record/modules-form.tsx new file mode 100644 index 000000000..c2e77d1bd --- /dev/null +++ b/src/components/settings/managed-record/modules-form.tsx @@ -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, + form: FormData, +): { + modulePreferences: Record; + 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) { + event.preventDefault(); + onSave(managedModulesPatch(settings, new FormData(event.currentTarget))); + } + + return ( +
+ {entries.map(([key, defaultEnabled]) => ( + + ))} + {/* 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. */} + + + + ); +} diff --git a/src/components/settings/managed-record/notifications-form.tsx b/src/components/settings/managed-record/notifications-form.tsx new file mode 100644 index 000000000..474f86b43 --- /dev/null +++ b/src/components/settings/managed-record/notifications-form.tsx @@ -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) { + 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 ( +
+ + + + + + + ); +} diff --git a/src/components/settings/managed-record/profile-form.tsx b/src/components/settings/managed-record/profile-form.tsx new file mode 100644 index 000000000..068d63f18 --- /dev/null +++ b/src/components/settings/managed-record/profile-form.tsx @@ -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) { + 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 ( +
+ + + + + + + + + + + + ); +} diff --git a/src/components/settings/managed-record/thresholds-form.tsx b/src/components/settings/managed-record/thresholds-form.tsx new file mode 100644 index 000000000..0aaed7468 --- /dev/null +++ b/src/components/settings/managed-record/thresholds-form.tsx @@ -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(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) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + onSave({ + overrides: { + [metric]: { + min: formNumber(form, "minimum", minimum), + max: formNumber(form, "maximum", maximum), + }, + }, + }); + } + + return ( +
+ + + + + + ); +}