"use client"; /** * Quick-entry sheets for the dashboard: measurement, mood, and medication * intake behind the shared ResponsiveSheet primitive * (bottom-sheet on `, ShareDomain | null> > = { measurement: "measurements", medicationIntake: "medications", mood: "mind", symptom: "illness", workout: null, // v1.42 — life events are written in one's own record only. lifeEvent: null, }; /** * The sheet that may actually be on screen. * * `DashboardHeader` gates the menu items that set this state, and that was the * whole gate — so it held only for as long as the answer was already known. * `resolveRecordCapabilities(undefined)` reads as the caller's own record * until `/api/auth/me` settles, and the switch flow ends in a hard reload that * wipes the persisted cache, so a delegate's first paint carries all menu * items. One of them opened a sheet that then stayed open on a form the server * refuses. * * Re-deriving the open sheet from the current answer withdraws it instead. * Pure and exported so the rule can be pinned without a click. */ export function admittedQuickEntry( open: QuickEntryDialog, caps: Pick, ): QuickEntryDialog { if (open === null) return open; const domain = QUICK_ENTRY_DOMAIN[open]; const admitted = domain === null ? !caps.inSharedRecord : caps.canWriteDomain(domain); return admitted ? open : null; } /** * 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 input or textarea that * carries a value, or any checked checkbox/radio that isn't a default, * counts as unsaved input. Date/time pickers are excluded — the forms * prefill them with the current timestamp on mount, so a pristine sheet * would otherwise always read as dirty. Cheap, synchronous, and runs * only on the one mounted sheet body. */ const PREFILLED_PICKER_TYPES = new Set([ "date", "datetime-local", "time", "month", "week", ]); export function sheetBodyHasUnsavedInput(): boolean { if (typeof document === "undefined") return false; const body = document.querySelector( '[data-slot="responsive-sheet-body"]', ); if (!body) return false; const fields = body.querySelectorAll( "input, textarea", ); for (const field of fields) { if (field.disabled || field.type === "hidden") continue; // Skip pickers the form prefills with "now" — an untouched default // is not user input (v1.11.3 QA H1). if (PREFILLED_PICKER_TYPES.has(field.type)) 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; } // v1.11.5 — the input/textarea walk misses two non-text controls a user can // change before dismissing: the mood `role="radio"` selector and a Radix // Select (measurement type / medication). Losing either silently on a // backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard // was meant to close. // // Mood radios start with nothing selected (`mood = ""`), so ANY // `aria-checked="true"` is a deliberate user choice — flag it. if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) { return true; } // Radix Select trigger renders `role="combobox"`. A trigger with // `data-state="open"` is mid-interaction (the dropdown is up) — the user is // actively choosing, so a dismiss should confirm rather than drop the pick. // We deliberately do NOT treat a merely non-placeholder value as dirty: the // type / medication selects mount with a non-placeholder DEFAULT, and // flagging that would over-trigger the confirm on a pristine sheet. if (body.querySelector('[role="combobox"][data-state="open"]') !== null) { return true; } return false; } export function QuickEntrySheets({ open, onClose, }: { open: QuickEntryDialog; onClose: () => void; }) { const { t } = useTranslations(); const capabilities = useRecordCapabilities(); // Re-derived on every render, never latched: the answer can arrive after the // sheet is already open. See `admittedQuickEntry`. const openSheet = admittedQuickEntry(open, capabilities); // v1.4.27 R4 RC2 — DOM handles for the form action-row portal target // on each quick-entry sheet. The Sheet branch sticky-pins this slot. const [measurementFooterEl, setMeasurementFooterEl] = useState(null); const [moodFooterEl, setMoodFooterEl] = useState(null); // v1.4.37 W7b — medication-intake quick-add lives on the same Sheet // primitive as the other two quick-entries; the form's action row is // portalled into this slot so Save / Cancel stay reachable above the // mobile soft keyboard. const [medicationIntakeFooterEl, setMedicationIntakeFooterEl] = useState(null); const [workoutFooterEl, setWorkoutFooterEl] = useState( null, ); const [symptomFooterEl, setSymptomFooterEl] = useState( null, ); const [lifeEventFooterEl, setLifeEventFooterEl] = useState(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(nextOpen: boolean) { if (nextOpen) return; if (sheetBodyHasUnsavedInput()) { setConfirmDiscardOpen(true); return; } onClose(); } return ( <> } > } > {/* v1.4.37 W7b — third Sheet: medication intake. Same ResponsiveSheet contract as the two above; the form's footer (Cancel + Save) portals into the sticky-pinned slot so it stays reachable above the soft keyboard on mobile. */} } > {/* v1.40 — one occurrence of the person's own symptom. Mounted only while open, so every opening starts with "now" and nothing chosen. */} } > {openSheet === "symptom" && ( )} {/* Log a workout by hand. Mounted only while open, so every opening is a fresh form with its own external id. */} } > {openSheet === "workout" && ( )} {/* v1.42 — a life event (the opt-in timeline module). Mounted only while open, so every opening starts from an empty draft. */} } > {openSheet === "lifeEvent" && ( )} {/* 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. */} {t("dashboard.quickEntryDiscard.title")} {t("dashboard.quickEntryDiscard.description")} {t("dashboard.quickEntryDiscard.cancel")} { setConfirmDiscardOpen(false); onClose(); }} > {t("dashboard.quickEntryDiscard.confirm")} ); }