mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user