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"],