Address review: rescan staleness + escapable slow scan
Build image / build-and-push (push) Successful in 11s
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:
@@ -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}>
|
||||||
|
|||||||
@@ -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 }))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user