fix(dashboard): confirm before discarding a partly-filled quick-entry sheet

An accidental overlay tap, Escape, or mobile swipe-down on a quick-entry
sheet silently dropped any typed measurement, mood, or intake. Detect
unsaved input at dismiss time and ask before closing; an explicit Cancel
or a successful save still closes straight away.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 15:37:36 +02:00
1 parent ad3bbd5507
commit e591137162
7 files changed
+129 -3

No files matched your search

+93 -3
View File
@@ -32,6 +32,16 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -59,6 +69,37 @@ const DASHBOARD_QUERY_OPTS = {
refetchOnWindowFocus: false,
} as const;
/**
* v1.11.3 F3 — guard the quick-entry sheets against an accidental
* dismiss (overlay tap, Esc, mobile swipe-down) discarding a partly
* filled form. The forms live behind a stable `ResponsiveSheet` body
* slot and don't expose their dirty state, so we read it from the DOM
* at dismiss time: any visible text/number/date input or textarea that
* carries a value, or any checked checkbox/radio that isn't a default,
* counts as unsaved input. Cheap, synchronous, and runs only on the
* one mounted sheet body.
*/
function sheetBodyHasUnsavedInput(): boolean {
if (typeof document === "undefined") return false;
const body = document.querySelector<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input, textarea");
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
if (field.type === "checkbox" || field.type === "radio") {
const box = field as HTMLInputElement;
if (box.checked !== box.defaultChecked) return true;
continue;
}
if (field.value.trim() !== "") return true;
}
return false;
}
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
@@ -215,6 +256,22 @@ export default function DashboardPage() {
// mobile soft keyboard.
const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] =
useState<HTMLDivElement | null>(null);
// v1.11.3 F3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
// confirms (close) or keeps editing (dismiss the confirm).
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
// Intercept a sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding.
function handleQuickEntryOpenChange(open: boolean) {
if (open) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
setQuickEntryDialog(null);
}
// v1.4.29 M5 — the three inline dashboard queries default to a
// 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch
@@ -738,7 +795,7 @@ export default function DashboardPage() {
{/* Quick Entry Sheets — bottom-sheet on `<md`, centred Dialog on `md+`. */}
<ResponsiveSheet
open={quickEntryDialog === "measurement"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={
<div ref={setMeasurementFooterEl} className="flex w-full" />
@@ -752,7 +809,7 @@ export default function DashboardPage() {
</ResponsiveSheet>
<ResponsiveSheet
open={quickEntryDialog === "mood"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("mood.addEntry")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
@@ -768,7 +825,7 @@ export default function DashboardPage() {
stays reachable above the soft keyboard on mobile. */}
<ResponsiveSheet
open={quickEntryDialog === "medicationIntake"}
onOpenChange={(open) => !open && setQuickEntryDialog(null)}
onOpenChange={handleQuickEntryOpenChange}
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
footer={
@@ -782,6 +839,39 @@ export default function DashboardPage() {
/>
</ResponsiveSheet>
{/* v1.11.3 F3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
until the user decides. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
setQuickEntryDialog(null);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{(() => {
type TrendEntry = { id: string; order: number; node: React.ReactNode };
const trendCards: TrendEntry[] = [];