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} /> )}