Seed shelf UI: source links, lots, and what's left (#51)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Canceled after 9m3s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 9m3s

#50 stores provenance and lots; this is where you actually use them. That
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:
2026-07-21 01:52:40 -04:00
co-authored by Claude Opus 4.8
parent 8c5ddb21ec
commit 83a651e659
14 changed files with 965 additions and 11 deletions
+61 -1
View File
@@ -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>