Files
pansy/web/src/lib/seedLots.ts
T
steve cf37e57808
Build image / build-and-push (push) Successful in 6s
Seed-packet scan UI: camera/upload → proposal → confirm (#102)
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]>
2026-07-22 17:07:26 +00:00

213 lines
7.6 KiB
TypeScript

// Seed lots (#51): what you actually bought, and what's left of it.
//
// A lot is one purchase of one variety. Inventory belongs to the purchase rather
// than to the variety because the same garlic from two vendors in two years is
// two different things — see #50 for the reasoning behind the shape.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
export type LotUnit = z.infer<typeof lotUnitSchema>
export const LOT_UNITS: { value: LotUnit; label: string }[] = [
{ value: 'seeds', label: 'seeds' },
{ value: 'grams', label: 'grams' },
{ value: 'ounces', label: 'ounces' },
{ value: 'packets', label: 'packets' },
{ value: 'bulbs', label: 'bulbs' },
{ value: 'plants', label: 'plants' },
]
export const seedLotSchema = z.object({
id: z.number(),
ownerId: z.number(),
plantId: z.number(),
vendor: z.string(),
sourceUrl: z.string(),
sku: z.string(),
lotCode: z.string(),
purchasedAt: z.string().optional(),
packedForYear: z.number().optional(),
quantity: z.number(),
unit: lotUnitSchema,
costCents: z.number().optional(),
germinationPct: z.number().optional(),
notes: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
// Computed server-side: used is the summed effective count of active
// plantings attributed to this lot; remaining is quantity - used, and may be
// NEGATIVE when more was planted than the lot recorded buying.
used: z.number(),
remaining: z.number(),
})
export type SeedLot = z.infer<typeof seedLotSchema>
export const seedLotsKey = ['seed-lots'] as const
export function useSeedLots() {
return useQuery({
queryKey: seedLotsKey,
queryFn: async (): Promise<SeedLot[]> => z.array(seedLotSchema).parse(await api.get('/seed-lots')),
})
}
export interface SeedLotInput {
plantId: number
vendor: string
sourceUrl: string
sku: string
lotCode: string
purchasedAt: string | null
packedForYear: number | null
quantity: number
unit: LotUnit
costCents: number | null
germinationPct: number | null
notes: string
}
function invalidate(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: seedLotsKey })
}
export function useCreateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SeedLotInput): Promise<SeedLot> =>
seedLotSchema.parse(await api.post('/seed-lots', input)),
onSuccess: () => invalidate(qc),
})
}
export function useUpdateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: Partial<SeedLotInput> & { id: number; version: number }): Promise<SeedLot> => {
const { id, ...rest } = vars
return seedLotSchema.parse(await api.patch(`/seed-lots/${id}`, rest))
},
onSuccess: () => invalidate(qc),
})
}
export function useDeleteSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/seed-lots/${id}`)
},
onSuccess: () => invalidate(qc),
})
}
/**
* The lots a placement could be attributed to, best first.
*
* Lots with seed left come first, but an exhausted one is still offered: the
* count is a record of what you wrote down, not a fact about the packet, and
* refusing to attribute a planting because the number says zero would make the
* number harder to correct rather than easier.
*/
export function attributableLots(lots: SeedLot[]): SeedLot[] {
return [...lots].sort((a, b) => Number(b.remaining > 0) - Number(a.remaining > 0))
}
/** The current server row carried by a 409, so an edit form can rebase onto it
* and the user can re-save rather than losing what they typed. Every other
* entity's form does this; a lot is no different. */
export function conflictSeedLot(err: unknown): SeedLot | null {
if (!(err instanceof ApiError) || !err.isConflict) return null
const parsed = seedLotSchema.safeParse((err.body as { current?: unknown })?.current)
return parsed.success ? parsed.data : null
}
/** Group lots by the plant they belong to. */
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
const map = new Map<number, SeedLot[]>()
for (const lot of lots ?? []) {
const list = map.get(lot.plantId)
if (list) list.push(lot)
else map.set(lot.plantId, [lot])
}
return map
}
/**
* How a lot is doing, as a state rather than a number — this is the thing you
* scan for when deciding what to order, and a number alone doesn't survive being
* skimmed down a list.
*
* "over" means more was planted than the lot recorded buying. That's a real
* situation (a miscounted packet, a lot entered after the fact) and worth
* showing plainly rather than clamping to zero and hiding the discrepancy.
*/
export type LotState = 'over' | 'empty' | 'low' | 'ok' | 'unknown'
const LOW_FRACTION = 0.2
export function lotState(lot: SeedLot): LotState {
if (lot.quantity <= 0) return 'unknown' // no quantity recorded: nothing to be low on
if (lot.remaining < 0) return 'over'
if (lot.remaining === 0) return 'empty'
if (lot.remaining / lot.quantity <= LOW_FRACTION) return 'low'
return 'ok'
}
/** The total remaining across a plant's lots, and the worst state among them —
* what a plant card shows before you open it. */
export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUnit | null; state: LotState } {
if (lots.length === 0) return { remaining: 0, unit: null, state: 'unknown' }
// Mixed units can't be summed honestly; report the majority unit's total only
// when they all agree, else leave the unit off and let the per-lot rows speak.
const unit = lots.every((l) => l.unit === lots[0].unit) ? lots[0].unit : null
const remaining = lots.reduce((sum, l) => sum + l.remaining, 0)
const order: LotState[] = ['over', 'empty', 'low', 'ok', 'unknown']
const state = order.find((s) => lots.some((l) => lotState(l) === s)) ?? 'unknown'
return { remaining, unit, state }
}
/** Quantities are stored as REAL because grams and ounces are fractional, but a
* whole number of seeds shouldn't render as "100.0". */
export function formatQuantity(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Whole currency from cents, e.g. 499 → "$4.99". */
export function formatCost(cents: number | undefined): string | null {
if (cents == null) return null
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cents / 100)
}
/** Cost per unit, when both are known and it says something useful. */
export function formatUnitCost(lot: SeedLot): string | null {
if (lot.costCents == null || lot.quantity <= 0) return null
const per = lot.costCents / 100 / lot.quantity
return `${new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: per < 0.1 ? 3 : 2 }).format(per)}/${singular(lot.unit)}`
}
function singular(unit: LotUnit): string {
return unit.endsWith('s') ? unit.slice(0, -1) : unit
}
/**
* Whether a stored URL is safe to render as a link.
*
* The server already scheme-checks this (#50), but a link is rendered from
* whatever the client was handed — an old row, a different server version, a
* response someone tampered with — and "the backend validated it" is not a
* reason to hand `javascript:` to an anchor tag. Cheap to check twice.
*/
export function safeExternalUrl(raw: string): string | null {
if (!raw) return null
try {
const u = new URL(raw)
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
} catch {
return null
}
}