Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 10m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m33s

The seed-packet capture backend has been live since #94 (POST /seed-lots/scan
and /seed-lots/from-packet, with /capabilities reporting `vision`), but there was
no UI, so the feature Steve asked for — "take a picture of a packet and it
auto-creates" — was unreachable. This adds the mobile-first UI.

- Entry point in the Plants catalog ("Scan a packet"), shown only where
  `capabilities.vision` is on — so it's never a dead button on an instance with
  no vision model. Adds `vision` to the capabilities schema (both flags now
  default false, so a partial response hides a feature rather than failing parse).
- ScanPacketModal: two phases in one dialog. Capture uses a file input with
  `accept="image/*" capture="environment"` so a phone opens the camera directly
  (also picks an existing photo; HEIC is handled server-side). Review shows the
  extracted fields read-only for context, then an editable confirm — pick a ranked
  existing candidate (with its match reason) or create a new variety prefilled from
  the packet. Nothing commits blind, which is the whole point of the backend's
  never-auto-create design.
- New data layer (lib/seedPacket.ts): zod schemas mirroring the backend
  (SeedPacket / PacketProposal / PacketResult), the scan + confirm mutations, and
  the pure prefill helpers (newPlantDefaults / lotDefaults) — unit-tested, since
  the prefill mapping (unknown category, missing spacing, seed-count→unit) is
  where the bugs hide.
- api.ts learns to send FormData as multipart (no JSON content-type) for the
  scan upload, via a new api.postForm.

Frontend only; no backend or route change. Last open child of epic #96.

Live camera→vision→proposal verification needs a vision-configured instance
(the flow is model-gated and unreachable locally, same as the assistant);
the confirm half and all prefill logic are covered by tests.

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 12:52:21 -04:00
co-authored by Claude Opus 4.8
parent 08d8c5e47d
commit e0d78b891b
8 changed files with 709 additions and 16 deletions
@@ -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] }))
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)
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)
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.
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 = {
vendor: vendor.trim(),
sourceUrl: '',
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents,
germinationPct: null,
notes: '',
}
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)
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}>
{!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}>
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'
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}