Address Gadfly review on the seed shelf UI
Build image / build-and-push (push) Successful in 17s

The correctness finding is a real bug and the interesting one: arming a plant
from the Seed Tray never attributed a seed lot, not even in the unambiguous
single-lot case. The tray is the QUICKEST way to place a plant, so the fastest
path was the one that silently lost which packet the seed came from — and it
would have looked fine, because attribution is invisible until you go looking at
a remaining count that's wrong. armPlant now does the same single-lot
auto-attribution the picker does. Several lots stays unattributed rather than
guessing; the picker is where that choice belongs.

A single exhausted lot was also indistinguishable from having no lot, because
the "available" filter dropped it before the count was taken. That silently
dropped attribution with no signal. attributableLots now offers exhausted lots
too, just not first: the count records what was written down, not a fact about
the packet, so refusing to attribute a planting because the number says zero
makes a wrong number harder to correct rather than easier. It's also now one
rule in one place instead of the same filter written twice.

The seed lot form had no 409 handling, alone among the version-guarded forms
here. A concurrent edit made you retype everything; it now rebases onto the
current row and says so, like the garden and plant forms do.

Deduplication: the outbound-link anchor was duplicated verbatim between the
plant card and the lot rows, including the security-relevant rel attribute — now
one SourceLink that re-checks the URL and renders nothing when it isn't safe, so
callers can't forget to guard. remainingLabel had its own copy of the
unit-agreement rule that summarizeLots already owns; it now calls it.
formatQuantity moved out of a component file into lib/seedLots.ts with the other
formatters.

STATE_CLASS.over and .low were identical. "Over-planted" is a discrepancy to
look at, not a shortage to act on, so it now reads as its own warning.

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 02:01:58 -04:00
co-authored by Claude Opus 4.8
parent 83a651e659
commit ffe32d58cf
9 changed files with 155 additions and 53 deletions
+6 -16
View File
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { PlantIcon } from './PlantIcon'
import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList'
import { LotStateChip, SeedLotList } from './SeedLotList'
import { SourceLink } from './SourceLink'
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 { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units'
/**
@@ -37,7 +38,7 @@ export function PlantCard({
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">
@@ -54,21 +55,10 @@ 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) && (
{(plant.vendor || plant.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>
)}
<SourceLink url={plant.sourceUrl} />
</p>
)}
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}