Files
pansy/web/src/components/plants/PlantCard.tsx
T
steveandClaude Fable 5 27f658c1f7
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00

160 lines
6.6 KiB
TypeScript

import { useState } from 'react'
import { Button } from '@/components/ui/Button'
import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag'
import { cn } from '@/lib/cn'
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
import { formatCost, formatQuantity, lotState, safeExternalUrl, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units'
import { Monogram } from './Monogram'
/**
* One catalog plant as a card: monogram, name, "Category · spacing · days", a
* built-in tag for seeded plants, and a seed-lot summary. Clicking expands it
* (accent border) to the lot cards — vendor, packed-for year, what's left — and
* the plant's own actions. Built-ins are read-only: duplicate to customize.
*/
export function PlantCard({
plant,
letters,
unit,
lots,
onEdit,
onDelete,
onDuplicate,
onAddLot,
onEditLot,
onDeleteLot,
}: {
plant: Plant
letters: string
unit: UnitPref
lots: SeedLot[]
onEdit: () => void
onDelete: () => void
onDuplicate: () => void
onAddLot: () => void
onEditLot: (lot: SeedLot) => void
onDeleteLot: (lot: SeedLot) => void
}) {
const builtin = isBuiltin(plant)
const [open, setOpen] = useState(false)
const sub = [CATEGORY_LABELS[plant.category], `${formatSpacing(plant.spacingCm, unit)} spacing`]
if (plant.daysToMaturity != null) sub.push(`${plant.daysToMaturity} days`)
const lotText =
lots.length === 0
? `No seed lots · click to ${open ? 'close' : 'expand'}`
: `${lots.length} seed ${lots.length === 1 ? 'lot' : 'lots'} · click to ${open ? 'close' : 'see'}`
const source = safeExternalUrl(plant.sourceUrl)
return (
<div
className={cn('panel relative transition-shadow hover:[box-shadow:var(--shadow-md)]', open && 'border-accent-400')}
>
{/* The whole face is the toggle; the expanded area below has its own controls. */}
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="block w-full cursor-pointer rounded-[inherit] px-[18px] py-4 text-left"
>
<div className="flex items-center gap-3">
<Monogram color={plant.color} letters={letters} />
<span className="flex min-w-0 flex-col gap-px">
<span className="truncate font-heading text-[16.5px]" title={plant.name}>
{plant.name}
</span>
<span className="text-xs font-semibold text-ink-mute">{sub.join(' · ')}</span>
</span>
{builtin && <Tag tone="neutral" className="ml-auto flex-none">built-in</Tag>}
</div>
<div className="mt-2.5 text-[12.5px] text-ink-soft">{lotText}</div>
</button>
{open && (
<div className="-mt-1.5 flex flex-col gap-2 px-[18px] pb-4">
{lots.map((lot) => (
<LotCard key={lot.id} lot={lot} onEdit={() => onEditLot(lot)} onDelete={() => onDeleteLot(lot)} />
))}
{lots.length === 0 && (
<div className="text-[12.5px] text-ink-mute">No seed lots yet scan a packet, or record one by hand.</div>
)}
{(plant.vendor || source || plant.notes) && (
<div className="text-xs leading-relaxed text-ink-soft">
{plant.vendor && <span>{plant.vendor}</span>}
{plant.vendor && source && <span> · </span>}
{source && (
<a href={source} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">
source <Icon name="external-link" size={11} />
</a>
)}
{plant.notes && <p className="mt-1 whitespace-pre-wrap">{plant.notes}</p>}
</div>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
Record a lot
</Button>
<span className="ml-auto flex flex-wrap justify-end gap-1">
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
Duplicate
</Button>
{!builtin && (
<Button variant="ghost" icon="pencil" iconSize={12} className="px-2.5 text-[13px]" onClick={onEdit}>
Edit
</Button>
)}
{!builtin && (
<Button variant="ghost" icon="trash-2" iconSize={12} className="px-2.5 text-[13px] text-accent-700" onClick={onDelete}>
Delete
</Button>
)}
</span>
</div>
</div>
)}
</div>
)
}
/** What's left of a lot, said plainly: "50 cloves · 14 left" — or how far over
* it was planted, which is a real situation worth showing rather than clamping. */
export function describeLot(lot: SeedLot): string {
const parts = [`${formatQuantity(lot.quantity)} ${lot.unit}`]
const state = lotState(lot)
if (state === 'over') parts.push(`${formatQuantity(-lot.remaining)} over what was bought`)
else if (state === 'empty') parts.push('none left')
else if (state !== 'unknown') parts.push(`${formatQuantity(lot.remaining)} left`)
if (lot.germinationPct != null) parts.push(`${lot.germinationPct}% germination`)
const cost = formatCost(lot.costCents)
if (cost) parts.push(cost)
return parts.join(' · ')
}
function LotCard({ lot, onEdit, onDelete }: { lot: SeedLot; onEdit: () => void; onDelete: () => void }) {
const state = lotState(lot)
return (
<div className="rounded-md border border-divider bg-bg px-[13px] py-2.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-[12.5px] font-bold">{lot.vendor || 'Unnamed lot'}</span>
{state === 'low' && <Tag tone="accent">low</Tag>}
{state === 'empty' && <Tag tone="neutral">empty</Tag>}
{state === 'over' && <Tag tone="accent">over-planted</Tag>}
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">
{lot.packedForYear != null ? `packed for ${lot.packedForYear}` : lot.purchasedAt ? `bought ${lot.purchasedAt}` : ''}
</span>
</div>
<div className="mt-[3px] text-xs text-ink-soft">{describeLot(lot)}</div>
{lot.notes && <div className="mt-1 text-xs text-ink-mute">{lot.notes}</div>}
<div className="mt-1 flex justify-end gap-1">
<button type="button" className="btn btn-ghost px-2 py-1 text-xs" onClick={onEdit}>
Edit
</button>
<button type="button" className="btn btn-ghost px-2 py-1 text-xs text-accent-700" onClick={onDelete}>
Retire
</button>
</div>
</div>
)
}