Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #68.
This commit is contained in:
@@ -258,6 +258,8 @@ export interface PlantingCreate {
|
||||
radiusCm: number
|
||||
count?: number | null
|
||||
label?: string | null
|
||||
/** Attributes the plop to a purchase, so that lot can report what's left. */
|
||||
seedLotId?: number
|
||||
}
|
||||
|
||||
export function useCreatePlanting(gardenId: number) {
|
||||
|
||||
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
|
||||
color: z.string(),
|
||||
icon: z.string(),
|
||||
daysToMaturity: z.number().nullable().optional(),
|
||||
// Provenance for the VARIETY — the page you'd go back to to buy it again.
|
||||
// What you actually bought, and how much is left, lives in seedLots.ts.
|
||||
sourceUrl: z.string().default(''),
|
||||
vendor: z.string().default(''),
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
@@ -75,6 +79,8 @@ export interface PlantInput {
|
||||
color: string
|
||||
icon: string
|
||||
daysToMaturity: number | null
|
||||
sourceUrl: string
|
||||
vendor: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
attributableLots,
|
||||
formatCost,
|
||||
formatQuantity,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
lotsByPlant,
|
||||
safeExternalUrl,
|
||||
summarizeLots,
|
||||
type SeedLot,
|
||||
} from './seedLots'
|
||||
|
||||
function lot(over: Partial<SeedLot> = {}): SeedLot {
|
||||
return {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
plantId: 1,
|
||||
vendor: '',
|
||||
sourceUrl: '',
|
||||
sku: '',
|
||||
lotCode: '',
|
||||
quantity: 100,
|
||||
unit: 'seeds',
|
||||
notes: '',
|
||||
version: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
used: 0,
|
||||
remaining: 100,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('safeExternalUrl', () => {
|
||||
// The server already scheme-checks this, but a link is rendered from whatever
|
||||
// the client was handed. "The backend validated it" is not a reason to hand
|
||||
// javascript: to an anchor tag.
|
||||
it('accepts http and https', () => {
|
||||
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
|
||||
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
|
||||
})
|
||||
|
||||
it('refuses everything else', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'file:///etc/passwd',
|
||||
'//evil.example.com',
|
||||
'/seeds/garlic',
|
||||
'not a url',
|
||||
]) {
|
||||
expect(safeExternalUrl(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotState', () => {
|
||||
it('reads the state, not just the number', () => {
|
||||
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
|
||||
// Planting more than you recorded buying is real, and worth showing plainly
|
||||
// rather than clamping to zero.
|
||||
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
|
||||
})
|
||||
|
||||
it('has nothing to say about a lot with no recorded quantity', () => {
|
||||
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeLots', () => {
|
||||
it('totals remaining and reports the worst state', () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, quantity: 100, remaining: 80 }),
|
||||
lot({ id: 2, quantity: 50, remaining: 5 }),
|
||||
])
|
||||
expect(s.remaining).toBe(85)
|
||||
expect(s.unit).toBe('seeds')
|
||||
expect(s.state).toBe('low') // the one that needs attention wins
|
||||
})
|
||||
|
||||
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
|
||||
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
|
||||
])
|
||||
expect(s.unit).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing for a plant with no lots', () => {
|
||||
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost formatting', () => {
|
||||
it('renders whole currency from cents', () => {
|
||||
expect(formatCost(499)).toBe('$4.99')
|
||||
expect(formatCost(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('gives per-unit cost extra precision when it would round to nothing', () => {
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
|
||||
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
|
||||
})
|
||||
|
||||
it('has no per-unit cost without both halves', () => {
|
||||
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotsByPlant', () => {
|
||||
it('groups, and copes with nothing', () => {
|
||||
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
|
||||
expect(grouped.get(7)?.length).toBe(2)
|
||||
expect(grouped.get(9)?.length).toBe(1)
|
||||
expect(lotsByPlant(undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributableLots', () => {
|
||||
// An exhausted lot is still offered: the count records what was written down,
|
||||
// not a fact about the packet, so refusing to attribute a planting because the
|
||||
// number says zero would make a wrong number harder to correct, not easier.
|
||||
it('offers exhausted lots, just not first', () => {
|
||||
const empty = lot({ id: 1, remaining: 0 })
|
||||
const full = lot({ id: 2, remaining: 100 })
|
||||
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
|
||||
expect(attributableLots([empty]).length).toBe(1)
|
||||
})
|
||||
|
||||
it("doesn't mutate its input", () => {
|
||||
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
|
||||
attributableLots(lots)
|
||||
expect(lots.map((l) => l.id)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatQuantity', () => {
|
||||
it('keeps whole counts whole and fractional ones readable', () => {
|
||||
expect(formatQuantity(100)).toBe('100')
|
||||
expect(formatQuantity(12.5)).toBe('12.5')
|
||||
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
// 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>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user