From a9f36d78e84e1352ba50e84923caf6dbbc4a2343 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 16 Jul 2026 16:27:15 -0700 Subject: [PATCH 1/2] F13: destructive-action guards + prefill-failure overwrite fix (#59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the mis-deplete incident (#57): one-way actions fired on a single click, and one silent-overwrite path existed. Confirm dialogs (window.confirm; styling revisits with #52): - Daily entry: Save & submit — freezes the day, creates egg lots. - Sales: Confirm order (allocates stock; void is #60) and Cancel draft (lines discarded). - Flocks: deplete and archive (one-way until reactivate, #57). Reversible actions (grade deactivate, line remove) stay one-click. Prefill-failure guard (daily entry): the edit-aware prefill was best-effort — if the lookup failed, the form showed zeros for a day that has data and saving would overwrite the existing draft. Prefill failure now shows an error, disables both save buttons, and offers a retry; saving unblocks only after a successful prefill. Verified in chromium against the real stack, both dialog paths each: dismiss leaves state untouched (flock stays Active, nothing submits, draft panel stays), accept proceeds (deplete, submit, cancel). Prefill verified by aborting the lookup: banner + disabled saves, retry after unroute re-enables. Closes #59 --- web/src/routes/DailyEntryPage.tsx | 31 ++++++++++++++++++++++++++----- web/src/routes/FlocksPage.tsx | 11 +++++++++-- web/src/routes/SalesPage.tsx | 4 ++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/web/src/routes/DailyEntryPage.tsx b/web/src/routes/DailyEntryPage.tsx index 585c790f1..27273c5eb 100644 --- a/web/src/routes/DailyEntryPage.tsx +++ b/web/src/routes/DailyEntryPage.tsx @@ -41,6 +41,9 @@ export function DailyEntryPage() { // so an untouched re-save must not wipe an existing entry's grading. const [gradesTouched, setGradesTouched] = useState(false); const [existingStatus, setExistingStatus] = useState(null); + // Prefill failure blocks saving (silent-overwrite guard, #59). + const [prefillFailed, setPrefillFailed] = useState(false); + const [prefillRetry, setPrefillRetry] = useState(0); const [busy, setBusy] = useState(false); const inFlight = useRef(false); @@ -78,6 +81,7 @@ export function DailyEntryPage() { useEffect(() => { if (!flockId || !date) return; let cancelled = false; + setPrefillFailed(false); listDailyEntries({ flockId, from: date, to: date, limit: 1 }) .then((entries) => { if (cancelled) return; @@ -98,9 +102,14 @@ export function DailyEntryPage() { setExistingStatus(null); } }) - .catch(() => { /* prefill is best-effort; save still validates server-side */ }); + .catch(() => { + // Not best-effort (#59): without the prefill we can't know whether this + // day already has data — saving would overwrite it with zeros. Block + // saving until a retry succeeds. + if (!cancelled) setPrefillFailed(true); + }); return () => { cancelled = true; }; - }, [flockId, date]); + }, [flockId, date, prefillRetry]); useEffect(() => { if (flockId) localStorage.setItem(LAST_FLOCK_KEY, flockId); @@ -143,7 +152,10 @@ export function DailyEntryPage() { } async function onSave(submit: boolean) { - if (inFlight.current || !selectedFlock) return; // sync re-entry guard + if (inFlight.current || !selectedFlock || prefillFailed) return; // sync re-entry guard + // One-way action (#59): submit freezes the day and creates egg lots. + if (submit && !window.confirm( + "Submit this day? Egg lots are created and the entry can no longer be edited — corrections need a manager adjustment.")) return; inFlight.current = true; setBusy(true); setError(null); @@ -252,6 +264,15 @@ export function DailyEntryPage() {

)} + {prefillFailed && ( +

+ Could not check whether this day already has an entry — saving is blocked + so existing data isn't overwritten.{" "} + +

+ )} +

Sellable production by grade

{grades.map((g) => ( @@ -274,10 +295,10 @@ export function DailyEntryPage() { {message &&

{message}

}
- diff --git a/web/src/routes/FlocksPage.tsx b/web/src/routes/FlocksPage.tsx index dde657029..acde182db 100644 --- a/web/src/routes/FlocksPage.tsx +++ b/web/src/routes/FlocksPage.tsx @@ -268,13 +268,20 @@ export function FlocksPage() { onClick={() => startEdit(f)}>edit {f.status === "Active" && ( )} {f.status !== "Archived" && ( )} diff --git a/web/src/routes/SalesPage.tsx b/web/src/routes/SalesPage.tsx index f2408eff5..c8ce1784d 100644 --- a/web/src/routes/SalesPage.tsx +++ b/web/src/routes/SalesPage.tsx @@ -156,6 +156,9 @@ export function SalesPage() { const onConfirm = () => run(async () => { if (!active) return; + // One-way action (#59): allocation cannot be undone yet (void is #60). + if (!window.confirm( + "Confirm this order? Stock is allocated from inventory (FIFO) and cannot be undone.")) return; const scope = `confirm:${active.id}`; await confirmOrder(active.id, keyFor(scope)); const refreshed = await getOrder(active.id); @@ -167,6 +170,7 @@ export function SalesPage() { const onCancel = () => run(async () => { if (!active) return; + if (!window.confirm("Cancel this draft? Its line items are discarded.")) return; const scope = `cancel:${active.id}`; await cancelOrder(active.id, keyFor(scope)); setActive(null); From 9f906e2df1a7034e3c4b4d284e165d162ef266d7 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 16 Jul 2026 16:33:44 -0700 Subject: [PATCH 2/2] Address codex + pi review feedback on destructive-action guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefill retry no longer clears the blocked state optimistically: saves stay disabled from the moment a prefill is in flight until it succeeds (new prefillPending state) — the optimistic clear reopened the exact overwrite window the guard exists to close. (codex P1, pi High) - A retry that recovers for the same flock+date no longer zeroes the form: values typed while the banner was up survive (there is no server entry to overwrite); a normal date/flock change still resets. (pi Medium) - Sales confirms hoisted above run(): buttons no longer flash disabled while the user is deciding, matching the Flocks pattern. (pi Medium) - Cancel-draft dialog reworded: cancellation is a status change — the order keeps its lines and becomes read-only; "lines are discarded" was wrong. (codex P2) Re-verified in chromium: retry-while-broken stays blocked, typed values survive a retry recovery, normal date change still resets, recovery unblocks saving. --- web/src/routes/DailyEntryPage.tsx | 32 ++++++++++++++----- web/src/routes/SalesPage.tsx | 51 ++++++++++++++++++------------- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/web/src/routes/DailyEntryPage.tsx b/web/src/routes/DailyEntryPage.tsx index 27273c5eb..412238af4 100644 --- a/web/src/routes/DailyEntryPage.tsx +++ b/web/src/routes/DailyEntryPage.tsx @@ -41,9 +41,12 @@ export function DailyEntryPage() { // so an untouched re-save must not wipe an existing entry's grading. const [gradesTouched, setGradesTouched] = useState(false); const [existingStatus, setExistingStatus] = useState(null); - // Prefill failure blocks saving (silent-overwrite guard, #59). + // Prefill failure OR in-flight prefill blocks saving (silent-overwrite + // guard, #59); failedTarget marks which flock+date the failure was for. const [prefillFailed, setPrefillFailed] = useState(false); + const [prefillPending, setPrefillPending] = useState(false); const [prefillRetry, setPrefillRetry] = useState(0); + const failedTarget = useRef(null); const [busy, setBusy] = useState(false); const inFlight = useRef(false); @@ -81,11 +84,19 @@ export function DailyEntryPage() { useEffect(() => { if (!flockId || !date) return; let cancelled = false; - setPrefillFailed(false); + // Saves stay blocked from the moment a prefill is in flight until it + // SUCCEEDS — clearing the flag optimistically would reopen the exact + // overwrite window this guard closes (#61 review). + setPrefillPending(true); + const target = `${flockId}|${date}`; listDailyEntries({ flockId, from: date, to: date, limit: 1 }) .then((entries) => { if (cancelled) return; const existing = entries.find((e) => e.date === date); + // A retry that recovers for the SAME flock+date must not zero the form: + // the user may have typed while the banner was up, and with no server + // entry there is nothing to overwrite. + const isRetryRecovery = failedTarget.current === target; if (existing) { setTotalEggs(existing.totalEggs); setCracked(existing.crackedEggs); @@ -95,18 +106,25 @@ export function DailyEntryPage() { setGradeQty(Object.fromEntries(existing.grades.map((g) => [g.eggGradeId, g.quantity]))); setGradesTouched(existing.grades.length > 0); setExistingStatus(existing.status); - } else { + } else if (!isRetryRecovery) { setTotalEggs(0); setCracked(0); setDirty(0); setDiscarded(0); setMortality(0); setGradeQty({}); setGradesTouched(false); setExistingStatus(null); } + failedTarget.current = null; + setPrefillFailed(false); + setPrefillPending(false); }) .catch(() => { // Not best-effort (#59): without the prefill we can't know whether this // day already has data — saving would overwrite it with zeros. Block // saving until a retry succeeds. - if (!cancelled) setPrefillFailed(true); + if (!cancelled) { + failedTarget.current = target; + setPrefillFailed(true); + setPrefillPending(false); + } }); return () => { cancelled = true; }; }, [flockId, date, prefillRetry]); @@ -152,7 +170,7 @@ export function DailyEntryPage() { } async function onSave(submit: boolean) { - if (inFlight.current || !selectedFlock || prefillFailed) return; // sync re-entry guard + if (inFlight.current || !selectedFlock || prefillFailed || prefillPending) return; // sync re-entry guard // One-way action (#59): submit freezes the day and creates egg lots. if (submit && !window.confirm( "Submit this day? Egg lots are created and the entry can no longer be edited — corrections need a manager adjustment.")) return; @@ -295,10 +313,10 @@ export function DailyEntryPage() { {message &&

{message}

}
- diff --git a/web/src/routes/SalesPage.tsx b/web/src/routes/SalesPage.tsx index c8ce1784d..36955acc2 100644 --- a/web/src/routes/SalesPage.tsx +++ b/web/src/routes/SalesPage.tsx @@ -154,30 +154,39 @@ export function SalesPage() { clearKey(scope); }); - const onConfirm = () => run(async () => { - if (!active) return; - // One-way action (#59): allocation cannot be undone yet (void is #60). + // One-way actions (#59). Confirm BEFORE run() so buttons don't flash + // disabled while the user decides. + const onConfirm = () => { + // Allocation cannot be undone yet (void is #60). if (!window.confirm( "Confirm this order? Stock is allocated from inventory (FIFO) and cannot be undone.")) return; - const scope = `confirm:${active.id}`; - await confirmOrder(active.id, keyFor(scope)); - const refreshed = await getOrder(active.id); - setActive(refreshed); - setMessage(`Order ${refreshed.referenceNumber} confirmed — stock allocated (FIFO).`); - await loadOrders(); - clearKey(scope); - }); + void run(async () => { + if (!active) return; + const scope = `confirm:${active.id}`; + await confirmOrder(active.id, keyFor(scope)); + const refreshed = await getOrder(active.id); + setActive(refreshed); + setMessage(`Order ${refreshed.referenceNumber} confirmed — stock allocated (FIFO).`); + await loadOrders(); + clearKey(scope); + }); + }; - const onCancel = () => run(async () => { - if (!active) return; - if (!window.confirm("Cancel this draft? Its line items are discarded.")) return; - const scope = `cancel:${active.id}`; - await cancelOrder(active.id, keyFor(scope)); - setActive(null); - setMessage("Draft order cancelled."); - await loadOrders(); - clearKey(scope); - }); + const onCancel = () => { + // Cancel is a status change: the order keeps its lines but becomes + // read-only and can't be confirmed. + if (!window.confirm( + "Cancel this draft? The order becomes cancelled and can no longer be edited or confirmed.")) return; + void run(async () => { + if (!active) return; + const scope = `cancel:${active.id}`; + await cancelOrder(active.id, keyFor(scope)); + setActive(null); + setMessage("Draft order cancelled."); + await loadOrders(); + clearKey(scope); + }); + }; // Always fetch fresh on open — the list row may be stale relative to // mutations made through the panel since the list was loaded.