From 5533b6edf9db70de630291146c5d5a54902028c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:06:33 +0100 Subject: [PATCH] fix(sheets): ask before a dismissal drops an edit that keeps no seed The illness episode, cycle day, custom metric, target, mood tag, symptom and life event sheets open filled from a record or a fetch, so the body check cannot tell typed input from what was loaded, and a stray tap outside or a swipe dropped the change without asking. Each now keeps what it opened with (the cycle day once its fetch has hydrated the form) and passes whether the fields differ to the sheet's dismissal guard. The custom metric and life event forms report it to their hosts the way the workout form does. The size baselines of the six sheet functions and the cycle sheet file are raised by the few lines the comparison takes. --- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 14 +- .../__tests__/sheet-seed-dismissal.test.tsx | 156 ++++++++++++++++++ .../custom-metrics/custom-metric-detail.tsx | 3 + .../custom-metrics/custom-metric-form.tsx | 46 ++++-- .../custom-metrics/custom-metric-list.tsx | 3 + src/components/cycle/log-day-sheet.tsx | 7 + src/components/illness/new-episode-sheet.tsx | 51 ++++-- .../mood/manage/tag-editor-sheet.tsx | 16 +- .../symptoms/symptom-edit-sheets.tsx | 10 ++ src/components/targets/target-edit-sheet.tsx | 21 ++- src/components/timeline/life-event-form.tsx | 12 +- src/components/timeline/life-event-sheet.tsx | 6 +- 13 files changed, 303 insertions(+), 46 deletions(-) create mode 100644 src/components/__tests__/sheet-seed-dismissal.test.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 97e043989..c5cf7adec 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,11 +1,11 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93802, + "total": 93809, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, - "components/cycle/log-day-sheet.tsx": 1639, + "components/cycle/log-day-sheet.tsx": 1646, "components/insights/coach-panel/chat-bubble.tsx": 821, "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f27269eb7..f9f64c814 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 336, - "total": 109333, + "total": 109378, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -74,14 +74,14 @@ "components/clinician/clinician-view.tsx :: ClinicianView": 223, "components/command-palette/command-palette.tsx :: PaletteBody": 333, "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228, - "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277, - "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210, + "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 280, + "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 212, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298, "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, "components/cycle/cycle-settings.tsx :: CycleSettings": 228, "components/cycle/cycle-view.tsx :: CycleView": 403, - "components/cycle/log-day-sheet.tsx :: LogDaySheet": 753, + "components/cycle/log-day-sheet.tsx :: LogDaySheet": 760, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383, @@ -98,7 +98,7 @@ "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, - "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, + "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 278, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, @@ -221,8 +221,8 @@ "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 215, - "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473, - "components/timeline/life-event-form.tsx :: LifeEventForm": 281, + "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 486, + "components/timeline/life-event-form.tsx :: LifeEventForm": 287, "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, "components/timeline/timeline-view.tsx :: TimelineView": 478, diff --git a/src/components/__tests__/sheet-seed-dismissal.test.tsx b/src/components/__tests__/sheet-seed-dismissal.test.tsx new file mode 100644 index 000000000..495e62b99 --- /dev/null +++ b/src/components/__tests__/sheet-seed-dismissal.test.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * Edit sheets open filled, so "the body has input" says nothing about them: + * each compares its fields with what it opened with and hands that to the + * sheet's dismissal guard. Opened and untouched, every one of them says + * "nothing to lose", so a tap outside closes it without asking; a sheet the + * guard was never wired to passes nothing at all. + */ + +const sheets: unknown[] = []; + +vi.mock("@/components/ui/responsive-sheet", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/ui/responsive-sheet") + >()), + ResponsiveSheet: (props: { dirty?: unknown; children?: ReactNode }) => { + sheets.push(props.dirty); + return <>{props.children}; + }, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NewEpisodeSheet } from "@/components/illness/new-episode-sheet"; +import { TagEditorSheet } from "@/components/mood/manage/tag-editor-sheet"; +import { + EditSymptomDefinitionSheet, + EditSymptomEventSheet, +} from "@/components/symptoms/symptom-edit-sheets"; +import { TargetEditSheet } from "@/components/targets/target-edit-sheet"; +import { LifeEventSheet } from "@/components/timeline/life-event-sheet"; + +function dirtyOf(node: ReactNode): unknown { + sheets.length = 0; + renderToStaticMarkup( + + {node} + , + ); + return sheets[0]; +} + +const noop = () => {}; + +beforeEach(() => { + sheets.length = 0; +}); + +describe("seeded edit sheets and the dismissal guard", () => { + it.each([ + [ + "an illness episode", + , + ], + [ + "a new illness episode", + , + ], + [ + "a mood tag", + , + ], + [ + "a symptom occurrence", + , + ], + [ + "a symptom", + , + ], + [ + "a target", + , + ], + [ + "a life event", + , + ], + ])("%s opens with nothing to lose", (_name, node) => { + expect(dirtyOf(node)).toBe(false); + }); +}); diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index bf7c4c2ad..22084c008 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -83,6 +83,7 @@ export function CustomMetricDetail({ const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(null); + const [editDirty, setEditDirty] = useState(false); const { data: metric, @@ -326,6 +327,7 @@ export function CustomMetricDetail({ setEditOpen(false)} + onDirtyChange={setEditDirty} /> ) : null} diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index e0cb0a053..af37f237c 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { createPortal } from "react-dom"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -58,6 +58,22 @@ interface CustomMetricFormProps { * bottom-sheet branch can sticky-pin it. */ footerSlot?: HTMLElement | null; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; +} + +/** What the form opens with: the edited metric, or an empty definition. */ +function metricFields(existing: CustomMetricDto | undefined) { + const text = (n: number | null | undefined) => (n != null ? String(n) : ""); + return { + name: existing?.name ?? "", + unit: existing?.unit ?? "", + low: text(existing?.targetLow), + high: text(existing?.targetHigh), + decimals: text(existing?.decimals), + description: existing?.description ?? "", + correlationEnabled: existing?.correlationEnabled ?? false, + }; } /** @@ -72,25 +88,27 @@ export function CustomMetricForm({ onSuccess, onCancel, footerSlot, + onDirtyChange, }: CustomMetricFormProps) { const { t } = useTranslations(); const formId = useId(); - const [name, setName] = useState(existing?.name ?? ""); - const [unit, setUnit] = useState(existing?.unit ?? ""); - const [low, setLow] = useState( - existing?.targetLow != null ? String(existing.targetLow) : "", - ); - const [high, setHigh] = useState( - existing?.targetHigh != null ? String(existing.targetHigh) : "", - ); - const [decimals, setDecimals] = useState( - existing?.decimals != null ? String(existing.decimals) : "", - ); - const [description, setDescription] = useState(existing?.description ?? ""); + const [seed] = useState(() => metricFields(existing)); + const [name, setName] = useState(seed.name); + const [unit, setUnit] = useState(seed.unit); + const [low, setLow] = useState(seed.low); + const [high, setHigh] = useState(seed.high); + const [decimals, setDecimals] = useState(seed.decimals); + const [description, setDescription] = useState(seed.description); const [correlationEnabled, setCorrelationEnabled] = useState( - existing?.correlationEnabled ?? false, + seed.correlationEnabled, ); + const fields = { name, unit, low, high, decimals, description }; + const dirty = + JSON.stringify({ ...fields, correlationEnabled }) !== JSON.stringify(seed); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const save = useSaveCustomMetric(existing); diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index db8f0e8ea..6c50e016f 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -46,6 +46,7 @@ export function CustomMetricList() { const router = useRouter(); const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); + const [addDirty, setAddDirty] = useState(false); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), @@ -154,6 +155,7 @@ export function CustomMetricList() { setAddOpen(false)} + onDirtyChange={setAddDirty} /> diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 3dd60fd47..3bff94b92 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -701,8 +701,10 @@ export function LogDaySheet({ ? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}` : null; const [lastFormKey, setLastFormKey] = useState(null); + const [seed, setSeed] = useState(null); if (open && formKey !== lastFormKey && !dayLog.isLoading) { setLastFormKey(formKey); + setSeed(null); setRowId(dto?.id ?? null); setFlow((dto?.flow ?? null) as FlowLevel | null); setIntermenstrual(dto?.intermenstrualBleeding ?? false); @@ -788,6 +790,10 @@ export function LogDaySheet({ note, symptoms, }; + // The form as hydrated, for the dismissal guard (a Map reads as `{}`). + const snapshot = JSON.stringify([formState, [...symptoms].sort()]); + if (hydrated && seed === null) setSeed(snapshot); + const dirty = hydrated && seed !== null && seed !== snapshot; const saveState = dayLogSaveState({ hydrated, rowId, form: formState }); const currentRowId = saveState.rowId; @@ -860,6 +866,7 @@ export function LogDaySheet({ void; @@ -102,22 +117,35 @@ export function NewEpisodeSheet({ // render keyed on the open transition (React's recommended alternative to a // setState-in-effect). const [wasOpen, setWasOpen] = useState(false); + const [seed, setSeed] = useState(() => episodeForm(editEpisode, today)); if (open && !wasOpen) { + const next = episodeForm(editEpisode, today); setWasOpen(true); - setLabel(editEpisode?.label ?? ""); - setType(editEpisode?.type ?? "INFECTION"); - setLifecycle(editEpisode?.lifecycle ?? "ACUTE"); - setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today); - setResolved( - editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "", - ); - setParentId(editEpisode?.parentConditionId ?? NONE); - setNote(editEpisode?.note ?? ""); - setBodySite(editEpisode?.bodySite ?? ""); - setLaterality(editEpisode?.laterality ?? null); + setSeed(next); + setLabel(next.label); + setType(next.type); + setLifecycle(next.lifecycle); + setOnset(next.onset); + setResolved(next.resolved); + setParentId(next.parentId); + setNote(next.note); + setBodySite(next.bodySite); + setLaterality(next.laterality); } else if (!open && wasOpen) { setWasOpen(false); } + const form = { + label, + type, + lifecycle, + onset, + resolved, + parentId, + note, + bodySite, + laterality, + }; + const dirty = JSON.stringify(form) !== JSON.stringify(seed); const showParent = PARENTABLE.includes(lifecycle); @@ -172,6 +200,7 @@ export function NewEpisodeSheet({ (tag?.icon ?? "Tag"); - const [groupKey, setGroupKey] = useState( - tag?.groupKey ?? initialGroupKey ?? "custom", - ); + const [seed] = useState(() => ({ + label: tag?.label ?? "", + icon: tag?.icon ?? "Tag", + groupKey: tag?.groupKey ?? initialGroupKey ?? "custom", + })); + const [label, setLabel] = useState(seed.label); + const [icon, setIcon] = useState(seed.icon); + const [groupKey, setGroupKey] = useState(seed.groupKey); const [saving, setSaving] = useState(false); + const dirty = + JSON.stringify({ label, icon, groupKey }) !== JSON.stringify(seed); const trimmed = label.trim(); const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH; @@ -158,6 +163,7 @@ export function TagEditorSheet({ diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index fe2a44bbc..56b22072d 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -78,6 +78,11 @@ export function EditSymptomEventSheet({ // a link the list cannot show (a deleted episode, the list still loading) // is left alone rather than silently dropped. const initialEpisodeId = effectiveEpisodeId(event.episodeId, episodeOptions); + const dirty = + intensity !== event.intensity || + occurredAt !== toLocalValue(event.occurredAt) || + note !== (event.note ?? "") || + linkedEpisodeId !== initialEpisodeId; const low = t("symptoms.entry.anchorLow"); const high = t("symptoms.entry.anchorHigh"); @@ -107,6 +112,7 @@ export function EditSymptomEventSheet({ onOpenChange={(open) => { if (!open) onClose(); }} + dirty={dirty} title={t("symptoms.section.editEventTitle", { symptom: label })} footer={ <> @@ -222,6 +228,10 @@ export function EditSymptomDefinitionSheet({ onOpenChange={(open) => { if (!open) onClose(); }} + dirty={ + label !== (definition.label ?? "") || + icon !== (definition.icon ?? "Tag") + } title={t("symptoms.section.editSymptomTitle")} footer={ <> diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 890f4b344..b5daf8ff5 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -212,10 +212,22 @@ function TargetEditSheetBody({ // Display values: user-edited string (if any) wins, otherwise we // surface the freshest seeded value derived from the // `thresholdsData` query + the parent-provided `initialRange`. - const displayMin = minStr ?? String(primary.min); - const displayMax = maxStr ?? String(primary.max); - const displayDiaMin = diaMinStr ?? (secondary ? String(secondary.min) : ""); - const displayDiaMax = diaMaxStr ?? (secondary ? String(secondary.max) : ""); + const seeded = { + min: String(primary.min), + max: String(primary.max), + diaMin: secondary ? String(secondary.min) : "", + diaMax: secondary ? String(secondary.max) : "", + }; + const displayMin = minStr ?? seeded.min; + const displayMax = maxStr ?? seeded.max; + const displayDiaMin = diaMinStr ?? seeded.diaMin; + const displayDiaMax = diaMaxStr ?? seeded.diaMax; + // A bound typed away from its seed; a dismissal then asks first. + const dirty = + displayMin !== seeded.min || + displayMax !== seeded.max || + displayDiaMin !== seeded.diaMin || + displayDiaMax !== seeded.diaMax; // Focus management — the first input gets focus when the sheet // opens, matching the Dashboard chart cog's open-on-focus pattern. @@ -412,6 +424,7 @@ function TargetEditSheetBody({ void; /** Where the action row goes; inline when absent. */ footerSlot?: HTMLElement | null; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; } function CategoryChips({ @@ -208,6 +210,7 @@ export function LifeEventForm({ onSuccess, onCancel, footerSlot, + onDirtyChange, }: LifeEventFormProps) { const { t } = useTranslations(); const ids = { @@ -219,9 +222,14 @@ export function LifeEventForm({ note: useId(), }; const today = todayKeyIn(undefined); - const [draft, setDraft] = useState(() => + const [seed] = useState(() => event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today), ); + const [draft, setDraft] = useState(seed); + const dirty = JSON.stringify(draft) !== JSON.stringify(seed); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [errors, setErrors] = useState({}); const [saveError, setSaveError] = useState(null); const { create, update, remove } = useLifeEventMutations(); diff --git a/src/components/timeline/life-event-sheet.tsx b/src/components/timeline/life-event-sheet.tsx index ce8cfa5bb..90255bf67 100644 --- a/src/components/timeline/life-event-sheet.tsx +++ b/src/components/timeline/life-event-sheet.tsx @@ -3,7 +3,8 @@ /** * The timeline's own life-event sheet (v1.42, #613): add from the header * button or a readiness link, edit from a life event in the chronicle or the - * selection bar. A bottom sheet on a phone, a dialog above. + * selection bar. A bottom sheet on a phone, a dialog above. A dismissal + * asks first once the form differs from what it opened with. */ import { useState } from "react"; @@ -24,11 +25,13 @@ export function LifeEventSheet({ }) { const { t } = useTranslations(); const [footer, setFooter] = useState(null); + const [formDirty, setFormDirty] = useState(false); const close = () => onOpenChange(false); return ( } @@ -41,6 +44,7 @@ export function LifeEventSheet({ onSuccess={close} onCancel={close} footerSlot={footer} + onDirtyChange={setFormDirty} /> )}