"use client"; import { useState, type FormEvent, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Settings } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { normalizeInsightsTileId, type InsightsSectionId, } from "@/lib/insights-layout"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; import { aiInputDependentKeys, invalidateKeys, queryKeys, } from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord, MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, safeParseManagedRecordSettingsPatch, type ManagedRecordSettingsFamily, } from "@/lib/record-settings"; import { METRIC_BOUNDS, type ThresholdMetric, } from "@/lib/analytics/effective-range"; import { ALL_METRICS } from "@/lib/validations/thresholds"; import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; import { Button } from "@/components/ui/button"; import { 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 { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; import { SettingsCardActions } from "./_card-actions"; import { SettingsCardHeader } from "./_card-header"; import { SettingsCard } from "./settings-card"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; interface ManagedRecordSettingsResponse { recordId: string; family: ManagedRecordSettingsFamily; settings: Record; } 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; }; const COACH_EXCLUDE_METRICS = [ "bp", "weight", "pulse", "mood", "compliance", "hrv", "sleep", "resting_hr", "steps", "medications", "anthropometrics", ] as const; const COACH_DATA_CLUSTERS = [ "cardio", "body", "activity", "workouts", "sleep", "mood", "glucose", "medication", "mobility", "environment", ] as const; const COACH_EXCLUDE_METRIC_LABEL_KEYS: Record< (typeof COACH_EXCLUDE_METRICS)[number], string > = { bp: "settings.sharedRecord.managedSettings.coach.metrics.bp", weight: "settings.sharedRecord.managedSettings.coach.metrics.weight", pulse: "settings.sharedRecord.managedSettings.coach.metrics.pulse", mood: "settings.sharedRecord.managedSettings.coach.metrics.mood", compliance: "settings.sharedRecord.managedSettings.coach.metrics.compliance", hrv: "settings.sharedRecord.managedSettings.coach.metrics.hrv", sleep: "settings.sharedRecord.managedSettings.coach.metrics.sleep", resting_hr: "settings.sharedRecord.managedSettings.coach.metrics.restingHr", steps: "settings.sharedRecord.managedSettings.coach.metrics.steps", medications: "settings.sharedRecord.managedSettings.coach.metrics.medications", anthropometrics: "settings.sharedRecord.managedSettings.coach.metrics.anthropometrics", }; const COACH_DATA_CLUSTER_LABEL_KEYS: Record< (typeof COACH_DATA_CLUSTERS)[number], string > = { cardio: "settings.sharedRecord.managedSettings.coach.clusters.cardio", body: "settings.sharedRecord.managedSettings.coach.clusters.body", activity: "settings.sharedRecord.managedSettings.coach.clusters.activity", workouts: "settings.sharedRecord.managedSettings.coach.clusters.workouts", sleep: "settings.sharedRecord.managedSettings.coach.clusters.sleep", mood: "settings.sharedRecord.managedSettings.coach.clusters.mood", glucose: "settings.sharedRecord.managedSettings.coach.clusters.glucose", medication: "settings.sharedRecord.managedSettings.coach.clusters.medication", mobility: "settings.sharedRecord.managedSettings.coach.clusters.mobility", environment: "settings.sharedRecord.managedSettings.coach.clusters.environment", }; const INSIGHTS_SECTION_LABEL_KEYS: Record = { "wellness-scores": "settings.sharedRecord.managedSettings.insights.sectionLabels.wellnessScores", "daily-briefing": "settings.sharedRecord.managedSettings.insights.sectionLabels.dailyBriefing", vitals: "settings.sharedRecord.managedSettings.insights.sectionLabels.vitals", trends: "settings.sharedRecord.managedSettings.insights.sectionLabels.trends", "period-review": "settings.sharedRecord.managedSettings.insights.sectionLabels.periodReview", "cycle-summary": "settings.sharedRecord.managedSettings.insights.sectionLabels.cycleSummary", signals: "settings.sharedRecord.managedSettings.insights.sectionLabels.signals", "rhythm-events": "settings.sharedRecord.managedSettings.insights.sectionLabels.rhythmEvents", "health-status": "settings.sharedRecord.managedSettings.insights.sectionLabels.healthStatus", breathing: "settings.sharedRecord.managedSettings.insights.sectionLabels.breathing", "labs-changes": "settings.sharedRecord.managedSettings.insights.sectionLabels.labsChanges", ecg: "settings.sharedRecord.managedSettings.insights.sectionLabels.ecg", }; const METRIC_LABEL_KEYS: Record = { WEIGHT: "thresholds.metricWeight", BLOOD_PRESSURE_SYS: "thresholds.metricBpSys", BLOOD_PRESSURE_DIA: "thresholds.metricBpDia", PULSE: "thresholds.metricPulse", BODY_FAT: "thresholds.metricBodyFat", SLEEP_DURATION: "thresholds.metricSleep", ACTIVITY_STEPS: "thresholds.metricSteps", BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting", BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial", BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom", BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime", TOTAL_BODY_WATER: "thresholds.metricBodyWater", BONE_MASS: "thresholds.metricBoneMass", OXYGEN_SATURATION: "thresholds.metricOxygenSaturation", }; function asRecord(value: unknown): Record { 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)); 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 (
{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, ...props }: SettingsFormProps & { family: ManagedSettingsFamily }) { switch (family) { case "profile": return ; case "modules": return ; case "notifications": return ; case "thresholds": return ; case "coach": return ; case "insights": return ; } } /** * Managed settings use typed target-record controls only. The actor's * Settings components never mount here, so their actor-scoped queries and * mutations cannot fire while a Guardian administers a profile. */ export function ManagedRecordSettingsSection({ family, }: { family: ManagedSettingsFamily; }) { const { t } = useTranslations(); const { user } = useAuth(); const queryClient = useQueryClient(); const recordId = user?.accountAccess?.active?.accountId ?? null; const [validationFailed, setValidationFailed] = useState(false); const query = useQuery({ queryKey: queryKeys.recordSettingsFamily(recordId ?? "", family), enabled: recordId !== null, queryFn: async () => { const response = await apiGet( `/api/record-settings/${family}`, ); assertRecordSettingsResponseForRecord(response, recordId); if (response.family !== family) { throw new Error( "Managed record settings response did not match its key", ); } return response; }, }); const save = useMutation({ mutationFn: async (patch: unknown) => { const response = await apiPatch( `/api/record-settings/${family}`, patch, ); assertRecordSettingsResponseForRecord(response, recordId); if (response.family !== family) { throw new Error( "Managed record settings response did not match its key", ); } return response; }, onSuccess: (response) => { if (recordId === null) return; queryClient.setQueryData( queryKeys.recordSettingsFamily(recordId, family), response, ); // The record's module map, Hide Coach and profile (gender gates the // cycle module) are inputs of the `/me` answer for this record, its AI // capabilities included. void invalidateKeys(queryClient, aiInputDependentKeys); }, }); if (query.isError) { return void query.refetch()} />; } return ( {query.data ? ( { const parsed = safeParseManagedRecordSettingsPatch(family, patch); if (!parsed.success) { setValidationFailed(true); return; } setValidationFailed(false); save.mutate(parsed.data); }} saveLabel={t("common.save")} settings={query.data.settings} /> ) : null} ); }