mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user