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
117 lines
4.2 KiB
TypeScript
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>
|
|
)
|
|
}
|