Address review: rescan staleness + escapable slow scan
Build image / build-and-push (push) Successful in 11s

From the Gadfly review of #119:

- Reset the cost field on a new scan — it was the one review field not reseeded
  in the success handler (there's no cost on a packet), so a value typed before a
  Rescan lingered into the next proposal.
- Clear the review-phase error when returning to capture via Rescan, so a failed
  confirm doesn't leave a stale error showing on the capture screen.
- Make the scan escapable: thread an AbortController through the scan mutation and
  wire it to Cancel (no longer disabled while scanning), so a slow or hung vision
  call — the server allows up to 120s — isn't a trap the user can only leave by
  reloading. An aborted scan is treated as a cancel, not an error.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 13:06:23 -04:00
co-authored by Claude Opus 4.8
parent e0d78b891b
commit d9c2de4882
2 changed files with 63 additions and 27 deletions
+57 -23
View File
@@ -43,6 +43,9 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
const scan = useScanPacket() const scan = useScanPacket()
const create = useCreateFromPacket() const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
// dialog is never a trap the user can only escape by reloading the page.
const scanAbort = useRef<AbortController | null>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null) const [proposal, setProposal] = useState<PacketProposal | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -73,28 +76,40 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
e.target.value = '' e.target.value = ''
if (!file) return if (!file) return
setError(null) setError(null)
scan.mutate(file, { const controller = new AbortController()
onSuccess: (p) => { scanAbort.current = controller
const plant = newPlantDefaults(p) scan.mutate(
const lot = lotDefaults(p.packet) { file, signal: controller.signal },
setProposal(p) {
// Default to the top candidate when there is one — the likely case is the onSuccess: (p) => {
// packet is a variety already in the catalog — else create a new one. const plant = newPlantDefaults(p)
setSelection(p.candidates[0]?.plant.id ?? 'new') const lot = lotDefaults(p.packet)
setName(plant.name) setProposal(p)
setCategory(plant.category) // Default to the top candidate when there is one — the likely case is
setSpacing(String(spacingFromCm(plant.spacingCm, unit))) // the packet is a variety already in the catalog — else create a new one.
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '') setSelection(p.candidates[0]?.plant.id ?? 'new')
setVendor(lot.vendor) setName(plant.name)
setQuantity(String(lot.quantity)) setCategory(plant.category)
setLotUnit(lot.unit) setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setSku(lot.sku) setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setLotCode(lot.lotCode) setVendor(lot.vendor)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '') setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setSku(lot.sku)
setLotCode(lot.lotCode)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
// Cost isn't on a packet, so it's the one field not reseeded from the
// proposal; clear it so a value typed before a Rescan doesn't linger.
setCost('')
},
onError: (err) => {
// An aborted scan is a user cancel, not a failure — and Cancel also
// closes the dialog, so there's nothing to report.
if ((err as Error)?.name === 'AbortError') return
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
},
}, },
onError: (err) => )
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet.")),
})
} }
async function onConfirm(e: FormEvent) { async function onConfirm(e: FormEvent) {
@@ -224,7 +239,16 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}> {/* Not disabled while scanning — this is the way out of a slow scan.
Aborting a settled/absent request is a harmless no-op. */}
<Button
type="button"
variant="ghost"
onClick={() => {
scanAbort.current?.abort()
onClose()
}}
>
Cancel Cancel
</Button> </Button>
</div> </div>
@@ -360,7 +384,17 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-between gap-2"> <div className="mt-1 flex justify-between gap-2">
<Button type="button" variant="ghost" onClick={() => setProposal(null)} disabled={busy}> <Button
type="button"
variant="ghost"
onClick={() => {
// Clear the review-phase error too, or it would show on the
// capture screen we're returning to.
setError(null)
setProposal(null)
}}
disabled={busy}
>
Rescan Rescan
</Button> </Button>
<Button type="submit" disabled={busy}> <Button type="submit" disabled={busy}>
+6 -4
View File
@@ -65,14 +65,16 @@ export interface FromPacketBody {
} }
/** Scan a packet photo into a proposal. Multipart upload; the vision call can /** Scan a packet photo into a proposal. Multipart upload; the vision call can
* take several seconds, so callers surface `isPending` as a scanning state. Not * take several seconds (the server extends its deadline to 120s), so a `signal`
* cached — every photo is a fresh one-shot. */ * can be threaded through to abort a slow/hung scan — the caller wires it to a
* Cancel button so the dialog is never a trap. Not cached — every photo is a
* fresh one-shot. */
export function useScanPacket() { export function useScanPacket() {
return useMutation({ return useMutation({
mutationFn: async (file: File): Promise<PacketProposal> => { mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
const form = new FormData() const form = new FormData()
form.append('image', file) form.append('image', file)
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form)) return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
}, },
}) })
} }