Seed-packet scan UI: camera/upload → proposal → confirm (#102) #119

Merged
steve merged 2 commits from feat/seed-packet-scan-ui into main 2026-07-22 17:07:26 +00:00
8 changed files with 709 additions and 16 deletions
Showing only changes of commit e0d78b891b - Show all commits
@@ -0,0 +1,398 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { errorMessage } from '@/lib/api'
import {
lotDefaults,
newPlantDefaults,
useCreateFromPacket,
useScanPacket,
type PacketProposal,
} from '@/lib/seedPacket'
import {
CATEGORY_LABELS,
PLANT_CATEGORIES,
type PlantCategory,
type PlantInput,
} from '@/lib/plants'
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
Review

🟡 categoryOptions duplicated from PlantFormModal

maintainability · flagged by 1 model

  • web/src/components/plants/ScanPacketModal.tsx:26categoryOptions is identical to the one in PlantFormModal.tsx:25. It should be exported once from @/lib/plants (e.g. export const categoryOptions = …) and imported by both modals.

🪰 Gadfly · advisory

🟡 **categoryOptions duplicated from PlantFormModal** _maintainability · flagged by 1 model_ - **`web/src/components/plants/ScanPacketModal.tsx:26`** — `categoryOptions` is identical to the one in `PlantFormModal.tsx:25`. It should be exported once from `@/lib/plants` (e.g. `export const categoryOptions = …`) and imported by both modals. <sub>🪰 Gadfly · advisory</sub>
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
// 'new' is "create a new variety"; a number selects that existing candidate plant.
type Selection = number | 'new'
/**
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
* review never commits blind — the model can misread, so every committed field is
* editable and the human picks "this is an existing plant" vs "a new variety".
*
* Only offered where `capabilities.vision` is on (the caller gates the entry
* point), so a scan should always be possible; a 503 is still handled in case the
* model is torn down between the capabilities poll and the upload.
*/
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
const scan = useScanPacket()
const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null)
Outdated
Review

14 useState slots in one ~330-line component; a reducer/single review-state object would reduce the setter wall

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **14 useState slots in one ~330-line component; a reducer/single review-state object would reduce the setter wall** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
const [error, setError] = useState<string | null>(null)
// Review-phase fields, seeded from the proposal when a scan lands.
const [selection, setSelection] = useState<Selection>('new')
const [name, setName] = useState('')
const [category, setCategory] = useState<PlantCategory>('vegetable')
const [spacing, setSpacing] = useState('')
const [days, setDays] = useState('')
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
// creating one) and the lot.
const [vendor, setVendor] = useState('')
const [quantity, setQuantity] = useState('')
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
const [sku, setSku] = useState('')
const [lotCode, setLotCode] = useState('')
const [packedForYear, setPackedForYear] = useState('')
const [cost, setCost] = useState('')
const unitLabel = spacingUnitLabel(unit)
const busy = scan.isPending || create.isPending
function onFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Reset the input so re-picking the same file fires change again (e.g. after
// an error, retrying the same photo).
e.target.value = ''
if (!file) return
setError(null)
scan.mutate(file, {
onSuccess: (p) => {
const plant = newPlantDefaults(p)
const lot = lotDefaults(p.packet)
setProposal(p)
// Default to the top candidate when there is one — the likely case is the
// packet is a variety already in the catalog — else create a new one.
setSelection(p.candidates[0]?.plant.id ?? 'new')
setName(plant.name)
setCategory(plant.category)
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setVendor(lot.vendor)
Outdated
Review

🔴 Cost field persists across rescans because setCost('') is missing in onFile success handler

correctness · flagged by 1 model

  • web/src/components/plants/ScanPacketModal.tsx:88-95 — The cost form state is never reset when a new packet is scanned. onFile sets name, category, spacing, days, vendor, quantity, lotUnit, sku, lotCode, and packedForYear from the new proposal, but setCost('') is missing. Consequently, if a user enters a cost for one packet, clicks "Rescan", and scans a different packet, the old cost value silently persists and will be submitted with the new lot. Fix: add `se…

🪰 Gadfly · advisory

🔴 **Cost field persists across rescans because setCost('') is missing in onFile success handler** _correctness · flagged by 1 model_ * **`web/src/components/plants/ScanPacketModal.tsx:88-95`** — The `cost` form state is never reset when a new packet is scanned. `onFile` sets `name`, `category`, `spacing`, `days`, `vendor`, `quantity`, `lotUnit`, `sku`, `lotCode`, and `packedForYear` from the new proposal, but `setCost('')` is missing. Consequently, if a user enters a cost for one packet, clicks "Rescan", and scans a different packet, the old cost value silently persists and will be submitted with the new lot. **Fix:** add `se… <sub>🪰 Gadfly · advisory</sub>
setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setSku(lot.sku)
setLotCode(lot.lotCode)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
},
onError: (err) =>
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet.")),
})
}
async function onConfirm(e: FormEvent) {
e.preventDefault()
if (!proposal) return
setError(null)
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
Outdated
Review

🟠 Lot validation (qty/year/cost) hand-copied from SeedLotModal with no shared helper; comment claims mirroring but nothing enforces it

maintainability · flagged by 4 models

  • web/src/components/plants/ScanPacketModal.tsx:105-128 — duplicated lot validation. The onConfirm qty / packed-for-year / cost validation blocks are character-for-character copies of SeedLotModal.tsx:62-88, and the comment at line 105 explicitly says "Lot validation mirrors SeedLotModal so the two paths accept the same things." Verified by reading both files: the Quantity must be a number, or left blank. string, costCents = Math.round(c * 100), and the 1900/2200 year range a…

🪰 Gadfly · advisory

🟠 **Lot validation (qty/year/cost) hand-copied from SeedLotModal with no shared helper; comment claims mirroring but nothing enforces it** _maintainability · flagged by 4 models_ - **`web/src/components/plants/ScanPacketModal.tsx:105-128` — duplicated lot validation.** The `onConfirm` qty / packed-for-year / cost validation blocks are character-for-character copies of `SeedLotModal.tsx:62-88`, and the comment at line 105 explicitly says *"Lot validation mirrors SeedLotModal so the two paths accept the same things."* Verified by reading both files: the `Quantity must be a number, or left blank.` string, `costCents = Math.round(c * 100)`, and the `1900`/`2200` year range a… <sub>🪰 Gadfly · advisory</sub>
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
const lot = {
Review

🟡 Hand-built lot literal re-spells SeedLotFields instead of spreading lotDefaults + overrides

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Hand-built `lot` literal re-spells SeedLotFields instead of spreading lotDefaults + overrides** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
vendor: vendor.trim(),
sourceUrl: '',
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents,
germinationPct: null,
notes: '',
}
Review

🟠 parseFloat(spacing) is less strict than Number() used for other fields, allowing malformed spacing input

correctness · flagged by 1 model

  • web/src/components/plants/ScanPacketModal.tsx:143 — Spacing validation uses parseFloat(spacing), while quantity and days use the stricter Number() (lines 106, 149). parseFloat accepts trailing garbage (e.g. "15abc"15), so malformed spacing input can slip through where other fields would be rejected. Since the input is type="number" this is harder to trigger in practice, but the inconsistency weakens validation. Fix: replace parseFloat(spacing) with `Number(spacin…

🪰 Gadfly · advisory

🟠 **parseFloat(spacing) is less strict than Number() used for other fields, allowing malformed spacing input** _correctness · flagged by 1 model_ * **`web/src/components/plants/ScanPacketModal.tsx:143`** — Spacing validation uses `parseFloat(spacing)`, while `quantity` and `days` use the stricter `Number()` (lines 106, 149). `parseFloat` accepts trailing garbage (e.g. `"15abc"` → `15`), so malformed spacing input can slip through where other fields would be rejected. Since the input is `type="number"` this is harder to trigger in practice, but the inconsistency weakens validation. **Fix:** replace `parseFloat(spacing)` with `Number(spacin… <sub>🪰 Gadfly · advisory</sub>
let newPlant: PlantInput | undefined
let plantId: number | undefined
if (selection === 'new') {
if (!name.trim()) {
setError('Name the new variety, or pick an existing plant above.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
Review

🟡 New-plant spacing/days validation copied verbatim from PlantFormModal; same duplication pattern as the lot validation

correctness, maintainability · flagged by 2 models

  • web/src/components/plants/ScanPacketModal.tsx:151-163 — duplicated new-plant field validation. The selection === 'new' branch re-implements spacing / days-to-maturity validation that PlantFormModal.tsx:86-100 already has (verified: the spacing < 1 check with the same Spacing must be at least 1 ${unitLabel}. message and "Days to maturity must be a whole number of days" are copied verbatim). Less severe than the lot duplication because the field sets aren't identical (the packet fo…

🪰 Gadfly · advisory

🟡 **New-plant spacing/days validation copied verbatim from PlantFormModal; same duplication pattern as the lot validation** _correctness, maintainability · flagged by 2 models_ - **`web/src/components/plants/ScanPacketModal.tsx:151-163` — duplicated new-plant field validation.** The `selection === 'new'` branch re-implements spacing / days-to-maturity validation that `PlantFormModal.tsx:86-100` already has (verified: the spacing `< 1` check with the same `Spacing must be at least 1 ${unitLabel}.` message and "Days to maturity must be a whole number of days" are copied verbatim). Less severe than the lot duplication because the field sets aren't identical (the packet fo… <sub>🪰 Gadfly · advisory</sub>
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
setError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) {
setError('Days to maturity must be a whole number of days, or left blank.')
return
}
daysToMaturity = d
}
newPlant = {
...newPlantDefaults(proposal),
name: name.trim(),
category,
spacingCm,
daysToMaturity,
vendor: vendor.trim(),
}
} else {
plantId = selection
}
try {
const res = await create.mutateAsync({ plantId, newPlant, lot })
toast.info(
res.plantIsNew
? `Added ${res.plant.name} and its seed lot.`
: `Recorded a seed lot for ${res.plant.name}.`,
)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not save the packet.'))
}
}
return (
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
Review

🟡 No cancel/timeout for the scan mutation; Modal blocks all close paths while busy, trapping the user during a slow/stuck vision call

error-handling · flagged by 1 model

  • web/src/components/plants/ScanPacketModal.tsx:191 (with Modal's blocking logic at web/src/components/ui/Modal.tsx:47,94): while a scan is in flight, there is no way to cancel or bail out. Modal disables Escape (Modal.tsx:47, gated on !busyRef.current) and backdrop-click-to-close (Modal.tsx:94, gated on !busy) whenever busy is true, and ScanPacketModal's own capture-phase Cancel button is disabled={busy} (ScanPacketModal.tsx:227). Confirmed `busy = scan.isPending || crea…

🪰 Gadfly · advisory

🟡 **No cancel/timeout for the scan mutation; Modal blocks all close paths while busy, trapping the user during a slow/stuck vision call** _error-handling · flagged by 1 model_ - `web/src/components/plants/ScanPacketModal.tsx:191` (with `Modal`'s blocking logic at `web/src/components/ui/Modal.tsx:47,94`): while a scan is in flight, there is no way to cancel or bail out. `Modal` disables Escape (`Modal.tsx:47`, gated on `!busyRef.current`) and backdrop-click-to-close (`Modal.tsx:94`, gated on `!busy`) whenever `busy` is true, and `ScanPacketModal`'s own capture-phase Cancel button is `disabled={busy}` (`ScanPacketModal.tsx:227`). Confirmed `busy = scan.isPending || crea… <sub>🪰 Gadfly · advisory</sub>
{!proposal ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Take a photo of the front of a seed packet and pansy reads the details off it you review and
confirm before anything is saved.
</p>
{/* A hidden input is triggered by the buttons below. `capture` hints a
phone to open the camera; on desktop it's ignored and both buttons
open a file chooser. */}
<input
ref={fileInput}
type="file"
accept="image/*"
capture="environment"
onChange={onFile}
className="hidden"
aria-hidden
tabIndex={-1}
/>
{scan.isPending ? (
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
Reading the packet this can take a few seconds.
</p>
) : (
<Button type="button" onClick={() => fileInput.current?.click()}>
Take or choose a photo
</Button>
)}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
</div>
</div>
) : (
<form onSubmit={onConfirm} className="flex flex-col gap-4">
<ReadFields proposal={proposal} unit={unit} />
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-fg">This packet is</legend>
{proposal.candidates.map((c) => (
<label
key={c.plant.id}
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
>
<input
type="radio"
name="packet-selection"
checked={selection === c.plant.id}
onChange={() => setSelection(c.plant.id)}
/>
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
<span className="text-xs text-muted">{c.reason}</span>
</label>
))}
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
<input
type="radio"
name="packet-selection"
checked={selection === 'new'}
onChange={() => setSelection('new')}
/>
<span className="flex-1 font-medium text-fg">
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
</span>
</label>
</fieldset>
{selection === 'new' && (
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
<TextField
label="Name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
hint="You can set an icon and color later from the plant card."
/>
<div className="grid grid-cols-2 gap-3">
<Select
label="Category"
name="category"
value={category}
onChange={(e) => setCategory(e.target.value as PlantCategory)}
options={categoryOptions}
/>
<TextField
label={`Spacing (${unitLabel})`}
name="spacing"
type="number"
inputMode="decimal"
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
/>
</div>
<TextField
label="Days to maturity (optional)"
name="days"
type="number"
inputMode="numeric"
step="1"
min="1"
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</div>
)}
{/* The seed lot — what you bought — recorded against whichever plant. */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-fg">Seed lot</p>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={lotUnit}
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
options={unitOptions}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-between gap-2">
<Button type="button" variant="ghost" onClick={() => setProposal(null)} disabled={busy}>
Review

🔴 Stale review-phase error persists after clicking Rescan

error-handling · flagged by 2 models

🪰 Gadfly · advisory

🔴 **Stale review-phase error persists after clicking Rescan** _error-handling · flagged by 2 models_ <sub>🪰 Gadfly · advisory</sub>
Rescan
</Button>
<Button type="submit" disabled={busy}>
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
</Button>
</div>
</form>
)}
</Modal>
)
}
/** A compact read-only summary of what the model pulled off the packet, so the
* user can see the extraction at a glance while they confirm. Only fields that
* came back are shown. */
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
const p = proposal.packet
const rows: [string, string][] = []
if (p.species) rows.push(['Species', p.species])
if (p.variety) rows.push(['Variety', p.variety])
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
if (rows.length === 0) return null
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{k}</dt>
<dd className="text-fg">{v}</dd>
</div>
))}
</dl>
)
}
+17 -8
View File
@@ -11,18 +11,27 @@ import { API_BASE, api } from './api'
import { gardenFullKey } from './objects'
import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
// What this instance can actually do, so the UI offers only what works: `agent`
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
// default to false so a partial/older response just hides the feature rather
// than failing the whole parse.
const capabilitiesSchema = z.object({
agent: z.boolean().default(false),
vision: z.boolean().default(false),
})
export type Capabilities = z.infer<typeof capabilitiesSchema>
export const capabilitiesKey = ['capabilities'] as const
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
* at all — a dead button is worse than no button.
/** What this instance can do right now: whether the assistant is live and whether
* seed-packet scanning will work. Without a capability the matching feature isn't
* offered at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
* in Settings (#79), so this must be able to change under a running page. The
* settings save invalidates this key directly; the finite staleTime just means
* another admin's change is picked up on the next focus/remount rather than
* never. */
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
* model on or off in Settings (#79), so this must be able to change under a
* running page. The settings save invalidates this key directly; the finite
* staleTime just means another admin's change is picked up on the next
* focus/remount rather than never. */
export function useCapabilities() {
return useQuery({
queryKey: capabilitiesKey,
+13 -6
View File
@@ -42,7 +42,8 @@ export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
/** Request body. A `FormData` goes out as multipart (a file upload — the
* seed-packet scan); anything else is serialized as JSON. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
@@ -90,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
// surfaces as itself, not as a misleading "cannot reach the server" error.
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
// FormData must go out as multipart with a browser-generated boundary, so it's
// sent as-is with NO content-type header (the browser sets it, boundary
// included). Everything else is JSON — serialized before the try so a
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
// misleading "cannot reach the server" error.
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
let res: Response
try {
@@ -102,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
},
body: requestBody,
body: requestBody as BodyInit | undefined,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
@@ -143,6 +148,8 @@ export const api = {
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
+1 -1
View File
@@ -61,7 +61,7 @@ export function filterPlants(plants: Plant[], query: string, category: CategoryF
)
}
const plantsKey = ['plants'] as const
export const plantsKey = ['plants'] as const
export const plantsQueryOptions = queryOptions({
queryKey: plantsKey,
+1 -1
View File
@@ -46,7 +46,7 @@ export const seedLotSchema = z.object({
})
export type SeedLot = z.infer<typeof seedLotSchema>
const seedLotsKey = ['seed-lots'] as const
export const seedLotsKey = ['seed-lots'] as const
export function useSeedLots() {
return useQuery({
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
lotDefaults,
newPlantDefaults,
packetProposalSchema,
seedPacketSchema,
PACKET_PLANT_COLOR,
PACKET_PLANT_ICON,
} from './seedPacket'
// A full proposal as the backend sends it, for the schema + prefill tests.
const proposal = {
packet: {
species: 'garlic',
variety: 'Music',
category: 'vegetable',
vendor: "Johnny's",
sku: 'G-123',
lotCode: 'L9',
packedForYear: 2026,
daysToMaturity: 90,
spacingCm: 15,
seedCount: 12,
},
candidates: [
{
plant: {
id: 7,
name: 'Music',
category: 'vegetable',
spacingCm: 15,
color: '#fff',
icon: '🧄',
notes: '',
version: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
},
reason: 'exact name',
},
],
suggestedName: 'Music',
suggestedCategory: 'vegetable',
}
describe('seedPacketSchema', () => {
it('fills defaults for a sparse packet (only what was printed)', () => {
// A packet where the model only read a species — everything else absent.
const p = seedPacketSchema.parse({ species: 'basil' })
expect(p.species).toBe('basil')
expect(p.variety).toBe('')
expect(p.spacingCm).toBeNull()
expect(p.seedCount).toBeNull()
expect(p.packedForYear).toBeNull()
})
})
describe('packetProposalSchema', () => {
it('parses a full proposal including candidates', () => {
const p = packetProposalSchema.parse(proposal)
expect(p.candidates).toHaveLength(1)
expect(p.candidates[0].plant.id).toBe(7)
expect(p.candidates[0].reason).toBe('exact name')
expect(p.suggestedName).toBe('Music')
})
it('defaults candidates to empty when absent', () => {
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
expect(p.candidates).toEqual([])
expect(p.suggestedName).toBe('')
})
})
describe('newPlantDefaults', () => {
it('prefills name/category/spacing/days/vendor from the proposal', () => {
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
expect(np.name).toBe('Music')
expect(np.category).toBe('vegetable')
expect(np.spacingCm).toBe(15)
expect(np.daysToMaturity).toBe(90)
expect(np.vendor).toBe("Johnny's")
// No icon/color on a packet — placeholders the user can change later.
expect(np.icon).toBe(PACKET_PLANT_ICON)
expect(np.color).toBe(PACKET_PLANT_COLOR)
})
it('falls back to a safe category and default spacing when the packet lacks them', () => {
const np = newPlantDefaults(
packetProposalSchema.parse({
packet: { species: 'mystery' },
suggestedName: 'mystery',
suggestedCategory: 'not-a-real-category',
}),
)
// An unknown category must not leak through — CreatePlant would reject it.
expect(np.category).toBe('vegetable')
// No printed spacing → the same default the manual form uses.
expect(np.spacingCm).toBe(30)
expect(np.daysToMaturity).toBeNull()
})
})
describe('lotDefaults', () => {
it('reads a seed count as "<n> seeds"', () => {
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
expect(lot.quantity).toBe(12)
expect(lot.unit).toBe('seeds')
expect(lot.vendor).toBe("Johnny's")
expect(lot.sku).toBe('G-123')
expect(lot.lotCode).toBe('L9')
expect(lot.packedForYear).toBe(2026)
})
it('defaults to one packet when no seed count is printed', () => {
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
expect(lot.quantity).toBe(1)
expect(lot.unit).toBe('packets')
expect(lot.packedForYear).toBeNull()
})
})
+147
View File
@@ -0,0 +1,147 @@
// Seed-packet capture client (#81/#102). Two steps, deliberately separate — the
// same shape the backend enforces:
// POST /seed-lots/scan a packet photo → a PacketProposal (reads only)
// POST /seed-lots/from-packet a confirmed proposal → a plant + a seed lot
// The scan never writes; a misread can't add anything to the catalog on its own.
// Creation happens only from an explicit confirm, with exactly one of an existing
// plant (plantId) or a new variety (newPlant).
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from './api'
import { PLANT_CATEGORIES, plantSchema, plantsKey, type PlantCategory, type PlantInput } from './plants'
import { seedLotSchema, seedLotsKey, type LotUnit, type SeedLotInput } from './seedLots'
// SeedPacket mirrors internal/vision.SeedPacket: the fields read off a packet.
// Every field can be empty or null because the model fills only what's actually
// printed — so each has a default and nothing here is required.
export const seedPacketSchema = z.object({
species: z.string().default(''),
variety: z.string().default(''),
category: z.string().default(''),
vendor: z.string().default(''),
sku: z.string().default(''),
lotCode: z.string().default(''),
packedForYear: z.number().nullable().default(null),
daysToMaturity: z.number().nullable().default(null),
spacingCm: z.number().nullable().default(null),
seedCount: z.number().nullable().default(null),
})
export type SeedPacket = z.infer<typeof seedPacketSchema>
// A candidate existing plant the packet might already be, with why it matched so
// the UI can show the reason next to it.
export const packetMatchSchema = z.object({ plant: plantSchema, reason: z.string() })
export type PacketMatch = z.infer<typeof packetMatchSchema>
// What a scan returns: the read fields, the candidate existing plants (best
// first; empty means "probably new"), and prefill hints for a new variety.
export const packetProposalSchema = z.object({
packet: seedPacketSchema,
candidates: z.array(packetMatchSchema).default([]),
suggestedName: z.string().default(''),
suggestedCategory: z.string().default(''),
})
export type PacketProposal = z.infer<typeof packetProposalSchema>
// What a confirm produced: the plant (new or matched) and the created lot.
export const packetResultSchema = z.object({
plant: plantSchema,
lot: seedLotSchema,
plantIsNew: z.boolean(),
})
export type PacketResult = z.infer<typeof packetResultSchema>
// The lot half of a confirm carries no plantId — the plant comes from the
// plantId/newPlant choice, and the server attributes the lot to it.
export type SeedLotFields = Omit<SeedLotInput, 'plantId'>
// A confirmed proposal: exactly one of plantId (attach to an existing plant) or
// newPlant (create a variety), plus the lot to record.
export interface FromPacketBody {
plantId?: number
newPlant?: PlantInput
lot: SeedLotFields
}
/** 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
* cached — every photo is a fresh one-shot. */
export function useScanPacket() {
return useMutation({
mutationFn: async (file: File): Promise<PacketProposal> => {
const form = new FormData()
form.append('image', file)
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form))
},
})
}
/** Confirm a proposal into a plant + lot. Invalidates both catalogs the new rows
* show up in. */
export function useCreateFromPacket() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (body: FromPacketBody): Promise<PacketResult> =>
packetResultSchema.parse(await api.post('/seed-lots/from-packet', body)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: plantsKey })
void qc.invalidateQueries({ queryKey: seedLotsKey })
},
})
}
// A packet has no icon or color, so a new variety created from one gets these
// placeholders; the user can set a real icon/color later from the plant card.
export const PACKET_PLANT_COLOR = '#4a7c3f'
Review

🟡 PACKET_PLANT_COLOR/PACKET_PLANT_ICON duplicate PlantFormModal's DEFAULT_COLOR/DEFAULT_ICON under new names; same value coupled by intent, decoupled in code

maintainability · flagged by 1 model

  • web/src/lib/seedPacket.ts:96-97 — duplicated default color/icon constants. PACKET_PLANT_COLOR = '#4a7c3f' and PACKET_PLANT_ICON = '🌱' are the same values as DEFAULT_COLOR/DEFAULT_ICON in PlantFormModal.tsx:22-23 (verified by grep — both files declare these literals, no shared export). They're renamed and re-declared rather than imported, so the two are coupled by intent ("a new plant with no icon gets this placeholder") but decoupled in code — if the manual form's default chang…

🪰 Gadfly · advisory

🟡 **PACKET_PLANT_COLOR/PACKET_PLANT_ICON duplicate PlantFormModal's DEFAULT_COLOR/DEFAULT_ICON under new names; same value coupled by intent, decoupled in code** _maintainability · flagged by 1 model_ - **`web/src/lib/seedPacket.ts:96-97` — duplicated default color/icon constants.** `PACKET_PLANT_COLOR = '#4a7c3f'` and `PACKET_PLANT_ICON = '🌱'` are the same values as `DEFAULT_COLOR`/`DEFAULT_ICON` in `PlantFormModal.tsx:22-23` (verified by grep — both files declare these literals, no shared export). They're renamed and re-declared rather than imported, so the two are coupled by intent ("a new plant with no icon gets this placeholder") but decoupled in code — if the manual form's default chang… <sub>🪰 Gadfly · advisory</sub>
export const PACKET_PLANT_ICON = '🌱'
// A packet without a printed spacing falls back to this (cm) — the same default
// the manual new-plant form uses.
const DEFAULT_SPACING_CM = 30
function isPlantCategory(c: string): c is PlantCategory {
return (PLANT_CATEGORIES as readonly string[]).includes(c)
}
/**
* Prefill a new-variety form from a proposal. Name and category come from the
* proposal's suggestions (already validated server-side against the known
* categories, but re-checked here); spacing/days/vendor come off the packet.
* Color and icon aren't on a packet, so they take the placeholders above.
*/
export function newPlantDefaults(p: PacketProposal): PlantInput {
return {
name: p.suggestedName,
category: isPlantCategory(p.suggestedCategory) ? p.suggestedCategory : 'vegetable',
spacingCm: p.packet.spacingCm ?? DEFAULT_SPACING_CM,
color: PACKET_PLANT_COLOR,
icon: PACKET_PLANT_ICON,
daysToMaturity: p.packet.daysToMaturity,
sourceUrl: '',
vendor: p.packet.vendor,
notes: '',
}
}
/**
* Prefill the lot fields from the packet. A printed seed count reads naturally as
* "<n> seeds"; without one, default to a single packet — the thing you physically
* bought — which the user can correct.
*/
export function lotDefaults(p: SeedPacket): SeedLotFields {
const hasCount = p.seedCount != null && p.seedCount > 0
const unit: LotUnit = hasCount ? 'seeds' : 'packets'
return {
vendor: p.vendor,
sourceUrl: '',
sku: p.sku,
lotCode: p.lotCode,
purchasedAt: null,
packedForYear: p.packedForYear,
quantity: hasCount ? (p.seedCount as number) : 1,
unit,
costCents: null,
germinationPct: null,
notes: '',
}
}
+12
View File
@@ -9,7 +9,9 @@ import { PlantFormModal } from '@/components/plants/PlantFormModal'
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { SeedLotModal } from '@/components/plants/SeedLotModal'
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
import { PlantPicker } from '@/editor/PlantPicker'
import { useCapabilities } from '@/lib/agent'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import type { UnitPref } from '@/lib/units'
@@ -22,6 +24,7 @@ type Dialog =
| { kind: 'duplicate'; plant: Plant }
| { kind: 'delete'; plant: Plant }
| { kind: 'picker' }
| { kind: 'scan' }
| { kind: 'addLot'; plant: Plant }
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
| { kind: 'deleteLot'; lot: SeedLot }
@@ -41,6 +44,7 @@ function loadUnit(): UnitPref {
export function PlantsPage() {
usePageTitle('Plants')
const plants = usePlants()
const capabilities = useCapabilities()
const seedLots = useSeedLots()
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
@@ -72,6 +76,13 @@ export function PlantsPage() {
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
Try the picker
</Button>
{/* Only where a vision model is configured — otherwise the scan would
404 on the vision call, so we don't offer it. */}
{capabilities.data?.vision && (
<Button variant="ghost" onClick={() => setDialog({ kind: 'scan' })}>
Scan a packet
</Button>
)}
<Button
variant="ghost"
onClick={toggleUnit}
@@ -136,6 +147,7 @@ export function PlantsPage() {
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'scan' && <ScanPacketModal unit={unit} onClose={close} />}
{dialog?.kind === 'picker' && (
<PlantPicker
unit={unit}