Seed shelf UI: source links, lots, and what's left (#51)
question gets asked twice — at the seed rack in January, and standing over a bed in May — so the answer shows up in both places. On the plant card: vendor and a source link, plus a seed count that sits with the actions rather than in the body, because it's what you scan down a list of twenty packets deciding what to order. Expanding shows each lot separately with vendor, dates, germination, cost, and its own remaining count — two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than on the plant. State is encoded in form as well as number: a coloured chip that says "low" or "empty" in words, and a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use. "Over-planted" is a state of its own rather than being clamped to zero — planting more than you recorded buying happens, and hiding it defeats the point of tracking. In the plant picker, remaining shows next to the plant you're about to place, and a plant with several lots asks which packet it came out of. One lot auto-attributes and zero lots stays silent: forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again. External URLs are re-checked client-side before being rendered as links, and carry rel="noopener noreferrer". The server already scheme-checks them, but a link is rendered from whatever the client was handed — an old row, a different server version, a tampered response — and "the backend validated it" is not a reason to hand javascript: to an anchor tag. Retiring a lot says plainly that the plantings survive it, since "will this wipe my garden" is the reasonable fear. Closes #51 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatQuantity } from './SeedLotList'
|
||||
|
||||
/**
|
||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||
* data lives — and worth saying plainly that the plantings survive it, since
|
||||
* "will this wipe my garden" is the reasonable fear.
|
||||
*/
|
||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||
const del = useDeleteSeedLot()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(lot.id, {
|
||||
onSuccess: onClose,
|
||||
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
|
||||
})
|
||||
}
|
||||
>
|
||||
{del.isPending ? 'Retiring…' : 'Retire lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { PlantIcon } from './PlantIcon'
|
||||
import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||
import { safeExternalUrl, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
/**
|
||||
@@ -11,17 +14,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
export function PlantCard({
|
||||
plant,
|
||||
unit,
|
||||
lots,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onAddLot,
|
||||
onEditLot,
|
||||
onDeleteLot,
|
||||
}: {
|
||||
plant: Plant
|
||||
unit: UnitPref
|
||||
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
||||
* card can carry seed. */
|
||||
lots: SeedLot[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onDuplicate: () => void
|
||||
onAddLot: () => void
|
||||
onEditLot: (lot: SeedLot) => void
|
||||
onDeleteLot: (lot: SeedLot) => void
|
||||
}) {
|
||||
const builtin = isBuiltin(plant)
|
||||
const [showLots, setShowLots] = useState(false)
|
||||
const summary = summarizeLots(lots)
|
||||
const sourceUrl = safeExternalUrl(plant.sourceUrl)
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
@@ -38,6 +54,23 @@ export function PlantCard({
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||
</p>
|
||||
{(plant.vendor || sourceUrl) && (
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||
{plant.vendor && <span>{plant.vendor}</span>}
|
||||
{sourceUrl && (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
// The href is a URL somebody pasted; noopener keeps it from
|
||||
// getting a handle back to this window.
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
>
|
||||
source ↗
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||
</div>
|
||||
<span
|
||||
@@ -46,7 +79,34 @@ export function PlantCard({
|
||||
title={plant.color}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{showLots && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{/* The seed count sits with the actions rather than in the body: it's
|
||||
what you scan for down a list of twenty packets, so it wants a fixed
|
||||
place on the card. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLots((v) => !v)}
|
||||
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
|
||||
aria-expanded={showLots}
|
||||
>
|
||||
{lots.length === 0 ? (
|
||||
<span className="text-muted">No seed</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="tabular-nums">
|
||||
{formatQuantity(summary.remaining)}
|
||||
{summary.unit ? ` ${summary.unit}` : ''} left
|
||||
</span>
|
||||
<LotStateChip state={summary.state} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const DEFAULT_COLOR = '#4a7c3f'
|
||||
@@ -61,6 +62,8 @@ export function PlantFormModal({
|
||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
@@ -96,6 +99,14 @@ export function PlantFormModal({
|
||||
daysToMaturity = d
|
||||
}
|
||||
|
||||
// The server refuses anything that isn't http(s) with a host, but say so here
|
||||
// rather than letting a paste of "johnnyseeds.com" come back as a generic
|
||||
// error with no hint about which field or why.
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
|
||||
const input: PlantInput = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
@@ -103,6 +114,8 @@ export function PlantFormModal({
|
||||
color,
|
||||
icon: icon.trim(),
|
||||
daysToMaturity,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
vendor: vendor.trim(),
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
@@ -194,6 +207,27 @@ export function PlantFormModal({
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Provenance for the variety itself. What you bought and what's left
|
||||
of it is a seed lot, added from the plant card. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Vendor"
|
||||
name="vendor"
|
||||
placeholder="Johnny's Selected Seeds"
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
formatCost,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
safeExternalUrl,
|
||||
type LotState,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
|
||||
const STATE_LABEL: Record<LotState, string> = {
|
||||
over: 'over-planted',
|
||||
empty: 'empty',
|
||||
low: 'low',
|
||||
ok: 'in stock',
|
||||
unknown: 'no count',
|
||||
}
|
||||
|
||||
// Encoded in colour AND words, because this is the thing you skim down a list of
|
||||
// twenty packets deciding what to order — a bare number doesn't survive that.
|
||||
const STATE_CLASS: Record<LotState, string> = {
|
||||
over: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
||||
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||
ok: 'bg-accent/20 text-accent-strong',
|
||||
unknown: 'bg-border/60 text-muted',
|
||||
}
|
||||
|
||||
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
STATE_CLASS[state],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{STATE_LABEL[state]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A proportional bar for how much of a lot is left, so the state reads before
|
||||
* any of the text does. Omitted when there's no quantity to be a fraction of. */
|
||||
function RemainingBar({ lot }: { lot: SeedLot }) {
|
||||
if (lot.quantity <= 0) return null
|
||||
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
|
||||
const state = lotState(lot)
|
||||
return (
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A plant's purchases: what came from where, and what's left of each.
|
||||
*
|
||||
* Two lots of the same variety are two separate rows with independent counts,
|
||||
* which is the whole reason inventory lives on the purchase rather than on the
|
||||
* plant (#50).
|
||||
*/
|
||||
export function SeedLotList({
|
||||
lots,
|
||||
canEdit,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
canEdit: boolean
|
||||
onAdd: () => void
|
||||
onEdit: (lot: SeedLot) => void
|
||||
onDelete: (lot: SeedLot) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{lots.length === 0 && (
|
||||
<p className="text-xs text-muted">
|
||||
No seed recorded. Add a lot to track what you bought and how much is left.
|
||||
</p>
|
||||
)}
|
||||
{lots.map((lot) => (
|
||||
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
|
||||
))}
|
||||
{canEdit && (
|
||||
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
|
||||
+ Add seed lot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LotRow({
|
||||
lot,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lot: SeedLot
|
||||
canEdit: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const url = safeExternalUrl(lot.sourceUrl)
|
||||
const cost = formatCost(lot.costCents)
|
||||
const unitCost = formatUnitCost(lot)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-2 py-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
|
||||
{lot.vendor && <span>{lot.vendor}</span>}
|
||||
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
|
||||
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
||||
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
||||
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
// noopener/noreferrer on every outbound link: the target is a URL
|
||||
// someone pasted, and window.opener would hand it a handle back.
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
>
|
||||
source ↗
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
<RemainingBar lot={lot} />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
LOT_UNITS,
|
||||
safeExternalUrl,
|
||||
useCreateSeedLot,
|
||||
useUpdateSeedLot,
|
||||
type LotUnit,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
|
||||
/**
|
||||
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||
* optional — the point is to make writing it down cheap enough to bother with,
|
||||
* and a form that demands a SKU and a lot code gets skipped.
|
||||
*/
|
||||
export function SeedLotModal({
|
||||
plant,
|
||||
lot,
|
||||
onClose,
|
||||
}: {
|
||||
plant: Plant
|
||||
/** Editing an existing lot, or undefined to record a new one. */
|
||||
lot?: SeedLot
|
||||
onClose: () => void
|
||||
}) {
|
||||
const isEdit = !!lot
|
||||
const create = useCreateSeedLot()
|
||||
const update = useUpdateSeedLot()
|
||||
const pending = create.isPending || update.isPending
|
||||
|
||||
// A new lot inherits the plant's vendor and source link, since the usual case
|
||||
// is buying the variety you already recorded from the place you recorded it.
|
||||
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
return
|
||||
}
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||
setError('Packed-for year should be a four-digit year.')
|
||||
return
|
||||
}
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) {
|
||||
setError('Cost must be an amount, or left blank.')
|
||||
return
|
||||
}
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
let germinationPct: number | null = null
|
||||
if (germination.trim()) {
|
||||
const g = Number(germination)
|
||||
if (!Number.isFinite(g) || g < 0 || g > 100) {
|
||||
setError('Germination is a percentage between 0 and 100.')
|
||||
return
|
||||
}
|
||||
germinationPct = g
|
||||
}
|
||||
|
||||
const input = {
|
||||
plantId: plant.id,
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit,
|
||||
costCents,
|
||||
germinationPct,
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await update.mutateAsync({ id: lot.id, version: lot.version, ...input })
|
||||
} else {
|
||||
await create.mutateAsync(input)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
name="unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as LotUnit)}
|
||||
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField
|
||||
label="Source link"
|
||||
name="sourceUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://…"
|
||||
value={sourceUrl}
|
||||
onChange={(e) => setSourceUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Purchased"
|
||||
name="purchasedAt"
|
||||
type="date"
|
||||
value={purchasedAt}
|
||||
onChange={(e) => setPurchasedAt(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Packed for"
|
||||
name="packedForYear"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2026"
|
||||
value={packedForYear}
|
||||
onChange={(e) => setPackedForYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="4.99"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Germination %"
|
||||
name="germination"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
max="100"
|
||||
value={germination}
|
||||
onChange={(e) => setGermination(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function GardenCanvas({
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const { fitToRect } = useViewport(svgRef)
|
||||
@@ -200,7 +201,14 @@ export function GardenCanvas({
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// the object, so a selection would be hidden until placement ends anyway).
|
||||
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
|
||||
createPlanting.mutate({
|
||||
objectId: focusedObject.id,
|
||||
plantId: armedPlant.id,
|
||||
xCm: x,
|
||||
yCm: y,
|
||||
radiusCm,
|
||||
seedLotId: armedLotId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fieldControlClass } from '@/components/ui/field'
|
||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import { lotsByPlant, lotState, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { LotStateChip, formatQuantity } from '@/components/plants/SeedLotList'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
const RECENT_KEY = 'pansy:recent-plants'
|
||||
@@ -41,11 +43,20 @@ export function PlantPicker({
|
||||
onClose,
|
||||
unit = 'metric',
|
||||
}: {
|
||||
onSelect: (plant: Plant) => void
|
||||
// The chosen plant, and the lot it should be attributed to when that isn't
|
||||
// ambiguous. Undefined lot means "don't attribute" — which is the honest
|
||||
// answer when there are no lots, and the deliberate one when the user skips.
|
||||
onSelect: (plant: Plant, lot?: SeedLot) => void
|
||||
onClose: () => void
|
||||
unit?: UnitPref
|
||||
}) {
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
// Set when a plant with SEVERAL lots is picked: which packet did this come out
|
||||
// of? One lot auto-attributes and zero lots stays silent, because forcing that
|
||||
// question on every placement is how a nicety becomes an obstacle.
|
||||
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||
@@ -74,8 +85,18 @@ export function PlantPicker({
|
||||
}, [all, recent, query])
|
||||
|
||||
function choose(p: Plant) {
|
||||
const lots = (lotsFor.get(p.id) ?? []).filter((l) => l.remaining > 0)
|
||||
if (lots.length > 1) {
|
||||
setChoosingLotFor(p)
|
||||
return
|
||||
}
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p)
|
||||
onSelect(p, lots[0])
|
||||
}
|
||||
|
||||
function chooseLot(p: Plant, lot?: SeedLot) {
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p, lot)
|
||||
}
|
||||
|
||||
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||
@@ -92,11 +113,22 @@ export function PlantPicker({
|
||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
// What's left, shown where you're deciding what to plant. Silent when there
|
||||
// are no lots — most plants won't have any, and "0 left" on all of them would
|
||||
// be noise that trains you to ignore the number.
|
||||
function remainingLabel(lots: SeedLot[]): string {
|
||||
if (lots.length === 0) return ''
|
||||
const unitName = lots.every((l) => l.unit === lots[0].unit) ? ` ${lots[0].unit}` : ''
|
||||
const total = lots.reduce((sum, l) => sum + l.remaining, 0)
|
||||
return ` · ${formatQuantity(total)}${unitName} left`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||
@@ -132,7 +164,43 @@ export function PlantPicker({
|
||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{choosingLotFor && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Which lot of {choosingLotFor.name}?
|
||||
</p>
|
||||
{(lotsFor.get(choosingLotFor.id) ?? [])
|
||||
.filter((l) => l.remaining > 0)
|
||||
.map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, lot)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
|
||||
</span>
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
Don't attribute to a lot
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
|
||||
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
||||
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ interface EditorState {
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
setArmedPlant: (p: Plant | null) => void
|
||||
// Which seed lot placements should be attributed to, when the armed plant has
|
||||
// one worth naming. Cleared with the plant.
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
||||
|
||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||
@@ -98,7 +101,8 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||
|
||||
armedPlant: null,
|
||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||
armedLotId: null,
|
||||
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||
|
||||
liveObject: null,
|
||||
setLiveObject: (o) => set({ liveObject: o }),
|
||||
@@ -118,6 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
selectedPlantingId: null,
|
||||
focusedObjectId: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
|
||||
@@ -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,121 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatCost,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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 { 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),
|
||||
})
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { useJournalCounts } from '@/lib/journal'
|
||||
import type { SeedLot } from '@/lib/seedLots'
|
||||
import { useSeedTray } from '@/lib/seedTray'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
@@ -277,9 +278,9 @@ export function GardenEditorPage() {
|
||||
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
||||
// garden's /full payload, so make sure the chosen one is in the cache first —
|
||||
// otherwise its placed plops render without an icon/color until a refetch.
|
||||
function armPlant(plant: Plant) {
|
||||
function armPlant(plant: Plant, lot?: SeedLot) {
|
||||
ensurePlant(plant)
|
||||
setArmedPlant(plant)
|
||||
setArmedPlant(plant, lot?.id ?? null)
|
||||
}
|
||||
|
||||
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
||||
@@ -294,14 +295,14 @@ export function GardenEditorPage() {
|
||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||
// pick (nothing armed, picker left open). Picking (place OR change) makes sure
|
||||
// the plant renders and drops it into this garden's tray for quick reuse.
|
||||
function onPickPlant(plant: Plant) {
|
||||
function onPickPlant(plant: Plant, lot?: SeedLot) {
|
||||
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||
ensurePlant(plant)
|
||||
addToTray(plant)
|
||||
if (picker === 'change' && selectedPlop) {
|
||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||
} else {
|
||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
||||
setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
|
||||
}
|
||||
setPicker(null)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantCard } from '@/components/plants/PlantCard'
|
||||
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import type { UnitPref } from '@/lib/units'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
@@ -19,6 +22,9 @@ type Dialog =
|
||||
| { kind: 'duplicate'; plant: Plant }
|
||||
| { kind: 'delete'; plant: Plant }
|
||||
| { kind: 'picker' }
|
||||
| { kind: 'addLot'; plant: Plant }
|
||||
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||
| { kind: 'deleteLot'; lot: SeedLot }
|
||||
| null
|
||||
|
||||
// There's no per-user unit preference server-side (only per-garden), so the
|
||||
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
|
||||
export function PlantsPage() {
|
||||
usePageTitle('Plants')
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
@@ -108,9 +116,13 @@ export function PlantsPage() {
|
||||
key={p.id}
|
||||
plant={p}
|
||||
unit={unit}
|
||||
lots={lots.get(p.id) ?? []}
|
||||
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
||||
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
||||
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
|
||||
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
|
||||
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
|
||||
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,6 +133,9 @@ export function PlantsPage() {
|
||||
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
||||
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
|
||||
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
|
||||
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'picker' && (
|
||||
<PlantPicker
|
||||
unit={unit}
|
||||
|
||||
Reference in New Issue
Block a user