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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:06:33 +01:00
1 parent 4105e2c85f
commit 5533b6edf9
13 files changed
+303 -46

No files matched your search

@@ -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,
@@ -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,
@@ -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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
return sheets[0];
}
const noop = () => {};
beforeEach(() => {
sheets.length = 0;
});
describe("seeded edit sheets and the dismissal guard", () => {
it.each([
[
"an illness episode",
<NewEpisodeSheet
key="episode"
open
onOpenChange={noop}
today="2026-11-10"
editEpisode={{
id: "ep-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-11-01T12:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: "Started on the train",
bodySite: null,
laterality: null,
createdAt: "2026-11-01T12:00:00.000Z",
updatedAt: "2026-11-01T12:00:00.000Z",
}}
/>,
],
[
"a new illness episode",
<NewEpisodeSheet
key="new-episode"
open
onOpenChange={noop}
today="2026-11-10"
/>,
],
[
"a mood tag",
<TagEditorSheet
key="tag"
open
onOpenChange={noop}
groups={[{ key: "custom", name: "Custom" }]}
tag={{ key: "c-1", label: "Garden", icon: "Leaf", groupKey: "custom" }}
/>,
],
[
"a symptom occurrence",
<EditSymptomEventSheet
key="symptom-event"
label="Headache"
onClose={noop}
event={{
id: "se-1",
definitionId: "sd-1",
occurredAt: "2026-11-09T08:30:00.000Z",
intensity: 6,
note: "After screen time",
episodeId: null,
createdAt: "2026-11-09T08:30:00.000Z",
updatedAt: "2026-11-09T08:30:00.000Z",
}}
/>,
],
[
"a symptom",
<EditSymptomDefinitionSheet
key="symptom"
onClose={noop}
definition={{
id: "sd-1",
label: "Headache",
icon: "Brain",
sortOrder: 0,
isActive: true,
recent: { count30d: 1, maxIntensity30d: 6, lastOccurredAt: null },
}}
/>,
],
[
"a target",
<TargetEditSheet
key="target"
open
onOpenChange={noop}
targetType="WEIGHT"
targetLabel="Weight"
unit="kg"
initialRange={{ min: 70, max: 80 }}
/>,
],
[
"a life event",
<LifeEventSheet key="life-event" open onOpenChange={noop} event={null} />,
],
])("%s opens with nothing to lose", (_name, node) => {
expect(dirtyOf(node)).toBe(false);
});
});
@@ -83,6 +83,7 @@ export function CustomMetricDetail({
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
const [editDirty, setEditDirty] = useState(false);
const {
data: metric,
@@ -326,6 +327,7 @@ export function CustomMetricDetail({
<ResponsiveSheet
open={editOpen}
onOpenChange={setEditOpen}
dirty={editDirty}
title={t("customMetrics.editTitle")}
description={t("customMetrics.defineDescription")}
footer={
@@ -341,6 +343,7 @@ export function CustomMetricDetail({
footerSlot={editFooterEl}
onSuccess={afterEditMetric}
onCancel={() => setEditOpen(false)}
onDirtyChange={setEditDirty}
/>
) : null}
</ResponsiveSheet>
@@ -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<string | null>(null);
const save = useSaveCustomMetric(existing);
@@ -46,6 +46,7 @@ export function CustomMetricList() {
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const [addDirty, setAddDirty] = useState(false);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
@@ -154,6 +155,7 @@ export function CustomMetricList() {
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
dirty={addDirty}
title={t("customMetrics.defineTitle")}
description={t("customMetrics.defineDescription")}
footer={
@@ -164,6 +166,7 @@ export function CustomMetricList() {
footerSlot={addFooterEl}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
onDirtyChange={setAddDirty}
/>
</ResponsiveSheet>
</section>
+7
View File
@@ -701,8 +701,10 @@ export function LogDaySheet({
? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
const [seed, setSeed] = useState<string | null>(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({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
// The target date IS the title. Every button below writes to it and
// nothing else on the surface says which day it is.
title={
+40 -11
View File
@@ -56,6 +56,21 @@ const PARENTABLE: IllnessLifecycle[] = ["FLARE", "RECURRING"];
const NONE = "__none__";
/** What the form opens with: the edited episode, or a new one from today. */
function episodeForm(episode: IllnessEpisodeDTO | undefined, today: string) {
return {
label: episode?.label ?? "",
type: episode?.type ?? ("INFECTION" as IllnessType),
lifecycle: episode?.lifecycle ?? ("ACUTE" as IllnessLifecycle),
onset: episode ? episode.onsetAt.slice(0, 10) : today,
resolved: episode?.resolvedAt ? episode.resolvedAt.slice(0, 10) : "",
parentId: episode?.parentConditionId ?? NONE,
note: episode?.note ?? "",
bodySite: episode?.bodySite ?? "",
laterality: (episode?.laterality ?? null) as BodySiteSideValue,
};
}
interface NewEpisodeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => 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({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
title={isEdit ? t("illness.edit.title") : t("illness.new.title")}
description={
isEdit ? t("illness.edit.description") : t("illness.new.description")
@@ -97,12 +97,17 @@ export function TagEditorSheet({
// so the field state seeds fresh from props on every open — no
// re-seeding effect needed.
const isEdit = tag !== undefined;
const [label, setLabel] = useState(tag?.label ?? "");
const [icon, setIcon] = useState<string | null>(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<string | null>(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({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={dirty}
title={isEdit ? t("common.edit") : t("mood.manage.newTag")}
footer={
<>
@@ -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={
<>
+17 -4
View File
@@ -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({
<ResponsiveSheet
open
onOpenChange={onOpenChange}
dirty={dirty}
title={t("targets.edit.title", { metric: targetLabel })}
description={
isDerivedMetric
+10 -2
View File
@@ -10,7 +10,7 @@
* capture picker's sheet, which hands it a footer slot so the actions sit in
* the sheet's sticky footer like every other quick entry.
*/
import { useId, useState } from "react";
import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Loader2, Lock, Plus, Trash2, X } from "lucide-react";
@@ -65,6 +65,8 @@ export interface LifeEventFormProps {
onCancel?: () => 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<LifeEventDraft>(() =>
const [seed] = useState<LifeEventDraft>(() =>
event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today),
);
const [draft, setDraft] = useState<LifeEventDraft>(seed);
const dirty = JSON.stringify(draft) !== JSON.stringify(seed);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const [errors, setErrors] = useState<LifeEventDraftErrors>({});
const [saveError, setSaveError] = useState<string | null>(null);
const { create, update, remove } = useLifeEventMutations();
+5 -1
View File
@@ -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<HTMLDivElement | null>(null);
const [formDirty, setFormDirty] = useState(false);
const close = () => onOpenChange(false);
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={formDirty}
title={event ? t("lifeEvents.edit") : t("lifeEvents.title")}
contentWidth="lg"
footer={<div ref={setFooter} className="flex w-full" />}
@@ -41,6 +44,7 @@ export function LifeEventSheet({
onSuccess={close}
onCancel={close}
footerSlot={footer}
onDirtyChange={setFormDirty}
/>
)}
</ResponsiveSheet>