Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
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:
+17
-8
@@ -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
@@ -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'>) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user