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