refactor(records): move the life circumstances field primitives into their own file

The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 17:59:54 +01:00
1 parent 41f90bac3f
commit d3401b5b89
4 files changed
+334 -318

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": 84,
"total": 94309,
"total": 94069,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -17,7 +17,7 @@
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
"components/records/life-circumstances-manager.tsx": 1080,
"components/records/life-circumstances-manager.tsx": 828,
"components/timeline/timeline-chart.tsx": 1093,
"components/timeline/timeline-geometry.ts": 1288,
"lib/ai/anthropic-client.ts": 804,
@@ -1,16 +1,16 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111894,
"total": 111909,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/login/route.ts :: apiHandler(arg)": 260,
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
@@ -187,7 +187,7 @@
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
"components/records/life-circumstances-manager.tsx :: SectionBody": 482,
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
@@ -207,7 +207,7 @@
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227,
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
@@ -300,7 +300,7 @@
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238,
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
@@ -0,0 +1,316 @@
/**
* The small field primitives the life circumstances form is built from.
* Each one owns only its own input state; the form owns the answers.
*/
import { useState, type ReactNode } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useFormatters } from "@/lib/i18n/context";
export type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
export function Field({
id,
label,
children,
}: {
id: string;
label: string;
children: ReactNode;
}) {
return (
<div className="space-y-1.5">
{/* Block, so the colon follows a label that wraps instead of standing
alone at the far edge as a flex item. */}
<Label htmlFor={id} className="block leading-snug">
{label}
</Label>
{children}
</div>
);
}
export function ChoiceField({
id,
label,
value,
options,
onChange,
t,
disabled,
}: {
id: string;
label: string;
value: string;
options: ReadonlyArray<{ value: string; label: string }>;
onChange: (value: string) => void;
t: Translate;
disabled: boolean;
}) {
return (
<Field id={id} label={label}>
<NativeSelect
id={id}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{t("records.life.noAnswer")}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</NativeSelect>
</Field>
);
}
export function BoolField({
id,
label,
value,
onChange,
t,
disabled,
}: {
id: string;
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
t: Translate;
disabled: boolean;
}) {
return (
<ChoiceField
id={id}
label={label}
value={value === null ? "" : value ? "yes" : "no"}
options={[
{ value: "yes", label: t("records.life.yes") },
{ value: "no", label: t("records.life.no") },
]}
onChange={(next) => onChange(next === "" ? null : next === "yes")}
t={t}
disabled={disabled}
/>
);
}
export function NumberField({
id,
label,
value,
min,
max,
step = 1,
onChange,
disabled,
}: {
id: string;
label: string;
value: number | null;
min: number;
max: number;
step?: number;
onChange: (value: number | null) => void;
disabled: boolean;
}) {
// What the person is typing. A year passes through "1", "19" and "198" on
// its way to "1985", so clamping each keystroke turned the first digit into
// the minimum and the field could not be typed into. A value is committed
// once it is in range, and leaving the field puts the committed one back.
const [draft, setDraft] = useState<string | null>(null);
return (
<Field id={id} label={label}>
<Input
id={id}
type="number"
inputMode={step < 1 ? "decimal" : "numeric"}
min={min}
max={max}
step={step}
value={draft ?? (value === null ? "" : String(value))}
disabled={disabled}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
if (raw === "") return onChange(null);
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
onChange(parsed);
}
}}
onBlur={() => setDraft(null)}
/>
</Field>
);
}
/**
* A month typed as YYYY-MM. Only a complete month is committed, so the field
* keeps what is being typed on its own: holding it to the committed value
* threw away every keystroke until the text was a whole month, and only a
* paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
* the committed month back over anything that is not one.
*/
export function QuitMonthInput({
id,
value,
onChange,
disabled,
}: {
id: string;
value: string | null;
onChange: (value: string | null) => void;
disabled: boolean;
}) {
const [draft, setDraft] = useState<string | null>(null);
return (
<Input
id={id}
placeholder="YYYY-MM"
inputMode="numeric"
maxLength={7}
value={draft ?? value ?? ""}
disabled={disabled}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
const month = completeMonth(raw);
if (month !== undefined) onChange(month);
}}
onBlur={() => {
if (draft !== null) {
const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
if (short) onChange(`${short[1]}-0${short[2]}`);
}
setDraft(null);
}}
/>
);
}
/** A whole month, null for an emptied field, undefined while still typing. */
function completeMonth(raw: string): string | null | undefined {
const text = raw.trim();
if (text === "") return null;
return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
}
export function MultiField<T extends string>({
id,
label,
hint,
options,
value,
onChange,
max,
disabled,
}: {
id: string;
label: string;
hint?: string;
options: ReadonlyArray<{ value: T; label: string }>;
value: readonly T[];
onChange: (value: T[]) => void;
max?: number;
disabled: boolean;
}) {
return (
<fieldset className="space-y-2" data-slot={id}>
<legend className="text-sm font-medium">{label}</legend>
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
{options.map((option) => {
const checked = value.includes(option.value);
const full = max !== undefined && value.length >= max && !checked;
return (
<label
key={option.value}
className="flex min-h-11 min-w-0 items-center gap-2 text-sm sm:min-h-9"
>
<Checkbox
className="shrink-0"
checked={checked}
disabled={disabled || full}
onCheckedChange={(next) =>
onChange(
next === true
? [...value, option.value]
: value.filter((item) => item !== option.value),
)
}
/>
{option.label}
</label>
);
})}
</div>
</fieldset>
);
}
/** Month and year of "valid since", as `YYYY-MM`. */
export function MonthField({
id,
value,
onChange,
t,
disabled,
}: {
id: string;
value: string;
onChange: (value: string) => void;
t: Translate;
disabled: boolean;
}) {
const fmt = useFormatters();
const [year, month] = value.split("-");
const thisYear = new Date().getFullYear();
const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
return (
<fieldset className="space-y-1.5">
<legend className="text-sm font-medium">
{t("records.life.validSince")}
</legend>
<div className="grid grid-cols-2 gap-4">
<NativeSelect
id={`${id}-month`}
aria-label={t("records.life.month")}
value={month}
disabled={disabled}
onChange={(event) => onChange(`${year}-${event.target.value}`)}
>
{Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, "0");
return (
<option key={m} value={m}>
{fmt.monthShort(new Date(Date.UTC(2026, i, 15)))}
</option>
);
})}
</NativeSelect>
<NativeSelect
id={`${id}-year`}
aria-label={t("records.life.year")}
value={year}
disabled={disabled}
onChange={(event) => onChange(`${event.target.value}-${month}`)}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</NativeSelect>
</div>
</fieldset>
);
}
@@ -19,13 +19,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2 } from "lucide-react";
import { AuditCPanel } from "@/components/records/audit-c-panel";
import {
BoolField,
ChoiceField,
Field,
MonthField,
MultiField,
NumberField,
QuitMonthInput,
type Translate,
} from "@/components/records/life-circumstances-fields";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -57,10 +63,6 @@ import {
type Section = LifeCircumstancesDto["sections"][number];
type Fact = Section["facts"][number];
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
const EMPTY = {
TOBACCO_DETAIL: {
@@ -172,308 +174,6 @@ async function writeKind(
}
}
// ── Small field primitives ───────────────────────────────────────────────────
function Field({
id,
label,
children,
}: {
id: string;
label: string;
children: ReactNode;
}) {
return (
<div className="space-y-1.5">
{/* Block, so the colon follows a label that wraps instead of standing
alone at the far edge as a flex item. */}
<Label htmlFor={id} className="block leading-snug">
{label}
</Label>
{children}
</div>
);
}
function ChoiceField({
id,
label,
value,
options,
onChange,
t,
disabled,
}: {
id: string;
label: string;
value: string;
options: ReadonlyArray<{ value: string; label: string }>;
onChange: (value: string) => void;
t: Translate;
disabled: boolean;
}) {
return (
<Field id={id} label={label}>
<NativeSelect
id={id}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{t("records.life.noAnswer")}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</NativeSelect>
</Field>
);
}
function BoolField({
id,
label,
value,
onChange,
t,
disabled,
}: {
id: string;
label: string;
value: boolean | null;
onChange: (value: boolean | null) => void;
t: Translate;
disabled: boolean;
}) {
return (
<ChoiceField
id={id}
label={label}
value={value === null ? "" : value ? "yes" : "no"}
options={[
{ value: "yes", label: t("records.life.yes") },
{ value: "no", label: t("records.life.no") },
]}
onChange={(next) => onChange(next === "" ? null : next === "yes")}
t={t}
disabled={disabled}
/>
);
}
function NumberField({
id,
label,
value,
min,
max,
step = 1,
onChange,
disabled,
}: {
id: string;
label: string;
value: number | null;
min: number;
max: number;
step?: number;
onChange: (value: number | null) => void;
disabled: boolean;
}) {
// What the person is typing. A year passes through "1", "19" and "198" on
// its way to "1985", so clamping each keystroke turned the first digit into
// the minimum and the field could not be typed into. A value is committed
// once it is in range, and leaving the field puts the committed one back.
const [draft, setDraft] = useState<string | null>(null);
return (
<Field id={id} label={label}>
<Input
id={id}
type="number"
inputMode={step < 1 ? "decimal" : "numeric"}
min={min}
max={max}
step={step}
value={draft ?? (value === null ? "" : String(value))}
disabled={disabled}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
if (raw === "") return onChange(null);
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
onChange(parsed);
}
}}
onBlur={() => setDraft(null)}
/>
</Field>
);
}
/**
* A month typed as YYYY-MM. Only a complete month is committed, so the field
* keeps what is being typed on its own: holding it to the committed value
* threw away every keystroke until the text was a whole month, and only a
* paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
* the committed month back over anything that is not one.
*/
function QuitMonthInput({
id,
value,
onChange,
disabled,
}: {
id: string;
value: string | null;
onChange: (value: string | null) => void;
disabled: boolean;
}) {
const [draft, setDraft] = useState<string | null>(null);
return (
<Input
id={id}
placeholder="YYYY-MM"
inputMode="numeric"
maxLength={7}
value={draft ?? value ?? ""}
disabled={disabled}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
const month = completeMonth(raw);
if (month !== undefined) onChange(month);
}}
onBlur={() => {
if (draft !== null) {
const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
if (short) onChange(`${short[1]}-0${short[2]}`);
}
setDraft(null);
}}
/>
);
}
/** A whole month, null for an emptied field, undefined while still typing. */
function completeMonth(raw: string): string | null | undefined {
const text = raw.trim();
if (text === "") return null;
return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
}
function MultiField<T extends string>({
id,
label,
hint,
options,
value,
onChange,
max,
disabled,
}: {
id: string;
label: string;
hint?: string;
options: ReadonlyArray<{ value: T; label: string }>;
value: readonly T[];
onChange: (value: T[]) => void;
max?: number;
disabled: boolean;
}) {
return (
<fieldset className="space-y-2" data-slot={id}>
<legend className="text-sm font-medium">{label}</legend>
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
{options.map((option) => {
const checked = value.includes(option.value);
const full = max !== undefined && value.length >= max && !checked;
return (
<label
key={option.value}
className="flex min-h-11 min-w-0 items-center gap-2 text-sm sm:min-h-9"
>
<Checkbox
className="shrink-0"
checked={checked}
disabled={disabled || full}
onCheckedChange={(next) =>
onChange(
next === true
? [...value, option.value]
: value.filter((item) => item !== option.value),
)
}
/>
{option.label}
</label>
);
})}
</div>
</fieldset>
);
}
/** Month and year of "valid since", as `YYYY-MM`. */
function MonthField({
id,
value,
onChange,
t,
disabled,
}: {
id: string;
value: string;
onChange: (value: string) => void;
t: Translate;
disabled: boolean;
}) {
const fmt = useFormatters();
const [year, month] = value.split("-");
const thisYear = new Date().getFullYear();
const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
return (
<fieldset className="space-y-1.5">
<legend className="text-sm font-medium">
{t("records.life.validSince")}
</legend>
<div className="grid grid-cols-2 gap-4">
<NativeSelect
id={`${id}-month`}
aria-label={t("records.life.month")}
value={month}
disabled={disabled}
onChange={(event) => onChange(`${year}-${event.target.value}`)}
>
{Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, "0");
return (
<option key={m} value={m}>
{fmt.monthShort(new Date(Date.UTC(2026, i, 15)))}
</option>
);
})}
</NativeSelect>
<NativeSelect
id={`${id}-year`}
aria-label={t("records.life.year")}
value={year}
disabled={disabled}
onChange={(event) => onChange(`${event.target.value}-${month}`)}
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</NativeSelect>
</div>
</fieldset>
);
}
/** The kinds a section writes, in the order it writes them. */
const SECTION_KINDS: Record<Section["key"], ReadonlyArray<keyof Values>> = {
tobacco: ["SMOKING_STATUS", "TOBACCO_DETAIL"],