// 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 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 export const seedLotsKey = ['seed-lots'] as const export function useSeedLots() { return useQuery({ queryKey: seedLotsKey, queryFn: async (): Promise => 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) { void qc.invalidateQueries({ queryKey: seedLotsKey }) } export function useCreateSeedLot() { const qc = useQueryClient() return useMutation({ mutationFn: async (input: SeedLotInput): Promise => seedLotSchema.parse(await api.post('/seed-lots', input)), onSuccess: () => invalidate(qc), }) } export function useUpdateSeedLot() { const qc = useQueryClient() return useMutation({ mutationFn: async (vars: Partial & { id: number; version: number }): Promise => { 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 => { 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 { const map = new Map() 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 } }