From e0d78b891b2ee1e7aff65d791217e977ec70e300 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 12:52:21 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Seed-packet=20scan=20UI:=20camera/upload=20?= =?UTF-8?q?=E2=86=92=20proposal=20=E2=86=92=20confirm=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/components/plants/ScanPacketModal.tsx | 398 ++++++++++++++++++ web/src/lib/agent.ts | 25 +- web/src/lib/api.ts | 19 +- web/src/lib/plants.ts | 2 +- web/src/lib/seedLots.ts | 2 +- web/src/lib/seedPacket.test.ts | 120 ++++++ web/src/lib/seedPacket.ts | 147 +++++++ web/src/pages/PlantsPage.tsx | 12 + 8 files changed, 709 insertions(+), 16 deletions(-) create mode 100644 web/src/components/plants/ScanPacketModal.tsx create mode 100644 web/src/lib/seedPacket.test.ts create mode 100644 web/src/lib/seedPacket.ts diff --git a/web/src/components/plants/ScanPacketModal.tsx b/web/src/components/plants/ScanPacketModal.tsx new file mode 100644 index 0000000..037df7b --- /dev/null +++ b/web/src/components/plants/ScanPacketModal.tsx @@ -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(null) + + const [proposal, setProposal] = useState(null) + const [error, setError] = useState(null) + + // Review-phase fields, seeded from the proposal when a scan lands. + const [selection, setSelection] = useState('new') + const [name, setName] = useState('') + const [category, setCategory] = useState('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('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) { + 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 ( + + {!proposal ? ( +
+

+ 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. +

+ + {/* 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. */} + + + {scan.isPending ? ( +

+ + Reading the packet… this can take a few seconds. +

+ ) : ( + + )} + + {error && {error}} + +
+ +
+
+ ) : ( +
+ + +
+ This packet is… + {proposal.candidates.map((c) => ( + + ))} + +
+ + {selection === 'new' && ( +
+ setName(e.target.value)} + hint="You can set an icon and color later from the plant card." + /> +
+ setLotUnit(e.target.value as LotUnit)} + options={unitOptions} + /> +
+
+ setVendor(e.target.value)} /> + setPackedForYear(e.target.value)} + /> +
+
+ setSku(e.target.value)} /> + setCost(e.target.value)} + /> +
+
+ + {error && {error}} + +
+ + +
+ + )} +
+ ) +} + +/** 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 ( +
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} diff --git a/web/src/lib/agent.ts b/web/src/lib/agent.ts index 2e17d33..9fc4190 100644 --- a/web/src/lib/agent.ts +++ b/web/src/lib/agent.ts @@ -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 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, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 39d87d0..c31c5ba 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -42,7 +42,8 @@ export type Params = Record 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(path: string, opts: RequestOptions = {}): Promise { 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(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(path, { ...opts, method: 'GET' }), post: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: 'POST', body }), + postForm: (path: string, form: FormData, opts?: Omit) => + apiFetch(path, { ...opts, method: 'POST', body: form }), patch: (path: string, body?: unknown, opts?: Omit) => apiFetch(path, { ...opts, method: 'PATCH', body }), delete: (path: string, opts?: Omit) => diff --git a/web/src/lib/plants.ts b/web/src/lib/plants.ts index 65d985a..5a4cf99 100644 --- a/web/src/lib/plants.ts +++ b/web/src/lib/plants.ts @@ -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, diff --git a/web/src/lib/seedLots.ts b/web/src/lib/seedLots.ts index 0d9c4f1..9aa9da4 100644 --- a/web/src/lib/seedLots.ts +++ b/web/src/lib/seedLots.ts @@ -46,7 +46,7 @@ export const seedLotSchema = z.object({ }) export type SeedLot = z.infer -const seedLotsKey = ['seed-lots'] as const +export const seedLotsKey = ['seed-lots'] as const export function useSeedLots() { return useQuery({ diff --git a/web/src/lib/seedPacket.test.ts b/web/src/lib/seedPacket.test.ts new file mode 100644 index 0000000..b0c0ab3 --- /dev/null +++ b/web/src/lib/seedPacket.test.ts @@ -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 " 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() + }) +}) diff --git a/web/src/lib/seedPacket.ts b/web/src/lib/seedPacket.ts new file mode 100644 index 0000000..7a42fde --- /dev/null +++ b/web/src/lib/seedPacket.ts @@ -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 + +// 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 + +// 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 + +// 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 + +// 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 + +// 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 => { + 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 => + 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 + * " 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: '', + } +} diff --git a/web/src/pages/PlantsPage.tsx b/web/src/pages/PlantsPage.tsx index fbc167e..45b23f9 100644 --- a/web/src/pages/PlantsPage.tsx +++ b/web/src/pages/PlantsPage.tsx @@ -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(() => loadUnit()) @@ -72,6 +76,13 @@ export function PlantsPage() { + {/* 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 && ( + + )} @@ -360,7 +384,17 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () {error && {error}}
-