Files
pansy/web/src/components/plants/PlantCard.tsx
T
steveandClaude Opus 4.8 ffe32d58cf
Build image / build-and-push (push) Successful in 17s
Address Gadfly review on the seed shelf UI
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
2026-07-21 02:01:58 -04:00

117 lines
4.2 KiB
TypeScript

import { useState } from 'react'
import { PlantIcon } from './PlantIcon'
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 { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units'
/**
* One catalog plant as a card: icon tile tinted with the plant's color, name,
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
* offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
*/
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)
return (
<div className="flex flex-col rounded-xl border border-border bg-surface">
<div className="flex items-start gap-3 p-4">
<PlantIcon color={plant.color} icon={plant.icon} className="h-11 w-11 rounded-lg text-2xl" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-fg">{plant.name}</h3>
{builtin && (
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
Built-in
</span>
)}
</div>
<p className="mt-0.5 text-sm text-muted">
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
</p>
{(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>}
<SourceLink url={plant.sourceUrl} />
</p>
)}
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
</div>
<span
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-black/10 dark:border-white/10"
style={{ backgroundColor: plant.color }}
title={plant.color}
/>
</div>
{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>
{!builtin && (
<button type="button" onClick={onEdit} className={cardActionClass}>
Edit
</button>
)}
{!builtin && (
<button type="button" onClick={onDelete} className={cardDangerClass}>
Delete
</button>
)}
</div>
</div>
)
}