From d3401b5b89d0f08f4404c03aed98054218232e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 17:59:54 +0100 Subject: [PATCH] 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. --- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 12 +- .../records/life-circumstances-fields.tsx | 316 +++++++++++++++++ .../records/life-circumstances-manager.tsx | 320 +----------------- 4 files changed, 334 insertions(+), 318 deletions(-) create mode 100644 src/components/records/life-circumstances-fields.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ff9370204..6fdd36690 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f96e8db4a..b4776feb5 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/components/records/life-circumstances-fields.tsx b/src/components/records/life-circumstances-fields.tsx new file mode 100644 index 000000000..eb558914f --- /dev/null +++ b/src/components/records/life-circumstances-fields.tsx @@ -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; + +export function Field({ + id, + label, + children, +}: { + id: string; + label: string; + children: ReactNode; +}) { + return ( +
+ {/* Block, so the colon follows a label that wraps instead of standing + alone at the far edge as a flex item. */} + + {children} +
+ ); +} + +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 ( + + onChange(event.target.value)} + > + + {options.map((option) => ( + + ))} + + + ); +} + +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 ( + 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(null); + return ( + + { + 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)} + /> + + ); +} + +/** + * 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(null); + return ( + { + 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({ + 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 ( +
+ {label} + {hint ?

{hint}

: null} +
+ {options.map((option) => { + const checked = value.includes(option.value); + const full = max !== undefined && value.length >= max && !checked; + return ( + + ); + })} +
+
+ ); +} + +/** 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 ( +
+ + {t("records.life.validSince")} + +
+ onChange(`${year}-${event.target.value}`)} + > + {Array.from({ length: 12 }, (_, i) => { + const m = String(i + 1).padStart(2, "0"); + return ( + + ); + })} + + onChange(`${event.target.value}-${month}`)} + > + {years.map((y) => ( + + ))} + +
+
+ ); +} diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx index e8a4ced02..01381d92f 100644 --- a/src/components/records/life-circumstances-manager.tsx +++ b/src/components/records/life-circumstances-manager.tsx @@ -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; 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 ( -
- {/* Block, so the colon follows a label that wraps instead of standing - alone at the far edge as a flex item. */} - - {children} -
- ); -} - -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 ( - - onChange(event.target.value)} - > - - {options.map((option) => ( - - ))} - - - ); -} - -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 ( - 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(null); - return ( - - { - 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)} - /> - - ); -} - -/** - * 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(null); - return ( - { - 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({ - 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 ( -
- {label} - {hint ?

{hint}

: null} -
- {options.map((option) => { - const checked = value.includes(option.value); - const full = max !== undefined && value.length >= max && !checked; - return ( - - ); - })} -
-
- ); -} - -/** 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 ( -
- - {t("records.life.validSince")} - -
- onChange(`${year}-${event.target.value}`)} - > - {Array.from({ length: 12 }, (_, i) => { - const m = String(i + 1).padStart(2, "0"); - return ( - - ); - })} - - onChange(`${event.target.value}-${month}`)} - > - {years.map((y) => ( - - ))} - -
-
- ); -} - /** The kinds a section writes, in the order it writes them. */ const SECTION_KINDS: Record> = { tobacco: ["SMOKING_STATUS", "TOBACCO_DETAIL"],