Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s

Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #119.
This commit is contained in:
2026-07-22 17:07:26 +00:00
committed by steve
parent 08d8c5e47d
commit cf37e57808
8 changed files with 745 additions and 16 deletions
+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()
})
})
+149
View File
@@ -0,0 +1,149 @@
// 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 (the server extends its deadline to 120s), so a `signal`
* can be threaded through to abort a slow/hung scan — the caller wires it to a
* Cancel button so the dialog is never a trap. Not cached — every photo is a
* fresh one-shot. */
export function useScanPacket() {
return useMutation({
mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
const form = new FormData()
form.append('image', file)
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
},
})
}
/** 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: '',
}
}