- 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]>
160 lines
6.6 KiB
TypeScript
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>
|
|
)
|
|
}
|