From 83a651e659162312ec2b797d023f76136639ca60 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 01:52:40 -0400 Subject: [PATCH] Seed shelf UI: source links, lots, and what's left (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #50 stores provenance and lots; this is where you actually use them. That question gets asked twice — at the seed rack in January, and standing over a bed in May — so the answer shows up in both places. On the plant card: vendor and a source link, plus a seed count that sits with the actions rather than in the body, because it's what you scan down a list of twenty packets deciding what to order. Expanding shows each lot separately with vendor, dates, germination, cost, and its own remaining count — two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than on the plant. State is encoded in form as well as number: a coloured chip that says "low" or "empty" in words, and a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use. "Over-planted" is a state of its own rather than being clamped to zero — planting more than you recorded buying happens, and hiding it defeats the point of tracking. In the plant picker, remaining shows next to the plant you're about to place, and a plant with several lots asks which packet it came out of. One lot auto-attributes and zero lots stays silent: forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again. External URLs are re-checked client-side before being rendered as links, and carry rel="noopener noreferrer". The server already scheme-checks them, but a link is rendered from whatever the client was handed — an old row, a different server version, a tampered response — and "the backend validated it" is not a reason to hand javascript: to an anchor tag. Retiring a lot says plainly that the plantings survive it, since "will this wipe my garden" is the reasonable fear. Closes #51 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- .../components/plants/DeleteSeedLotModal.tsx | 51 ++++ web/src/components/plants/PlantCard.tsx | 62 ++++- web/src/components/plants/PlantFormModal.tsx | 34 +++ web/src/components/plants/SeedLotList.tsx | 176 ++++++++++++++ web/src/components/plants/SeedLotModal.tsx | 222 ++++++++++++++++++ web/src/editor/GardenCanvas.tsx | 10 +- web/src/editor/PlantPicker.tsx | 74 +++++- web/src/editor/store.ts | 9 +- web/src/lib/objects.ts | 2 + web/src/lib/plants.ts | 6 + web/src/lib/seedLots.test.ts | 121 ++++++++++ web/src/lib/seedLots.ts | 185 +++++++++++++++ web/src/pages/GardenEditorPage.tsx | 9 +- web/src/pages/PlantsPage.tsx | 15 ++ 14 files changed, 965 insertions(+), 11 deletions(-) create mode 100644 web/src/components/plants/DeleteSeedLotModal.tsx create mode 100644 web/src/components/plants/SeedLotList.tsx create mode 100644 web/src/components/plants/SeedLotModal.tsx create mode 100644 web/src/lib/seedLots.test.ts create mode 100644 web/src/lib/seedLots.ts diff --git a/web/src/components/plants/DeleteSeedLotModal.tsx b/web/src/components/plants/DeleteSeedLotModal.tsx new file mode 100644 index 0000000..f87c25b --- /dev/null +++ b/web/src/components/plants/DeleteSeedLotModal.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { Modal } from '@/components/ui/Modal' +import { errorMessage } from '@/lib/api' +import { useDeleteSeedLot, type SeedLot } from '@/lib/seedLots' +import { formatQuantity } from './SeedLotList' + +/** + * Retire a lot. Worth confirming because it's the one place cost and germination + * data lives — and worth saying plainly that the plantings survive it, since + * "will this wipe my garden" is the reasonable fear. + */ +export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) { + const del = useDeleteSeedLot() + const [error, setError] = useState(null) + + return ( + +
+

+ {formatQuantity(lot.quantity)} {lot.unit} + {lot.vendor ? ` from ${lot.vendor}` : ''} + {lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}. +

+

+ Anything planted from it stays exactly where it is — it just stops being attributed to this purchase. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx index fe56d6b..6906f67 100644 --- a/web/src/components/plants/PlantCard.tsx +++ b/web/src/components/plants/PlantCard.tsx @@ -1,6 +1,9 @@ +import { useState } from 'react' import { PlantIcon } from './PlantIcon' +import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList' 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 { formatSpacing, type UnitPref } from '@/lib/units' /** @@ -11,17 +14,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units' 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) + const sourceUrl = safeExternalUrl(plant.sourceUrl) return (
@@ -38,6 +54,23 @@ export function PlantCard({

{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing

+ {(plant.vendor || sourceUrl) && ( +

+ {plant.vendor && {plant.vendor}} + {sourceUrl && ( + + source ↗ + + )} +

+ )} {plant.notes &&

{plant.notes}

}
-
+ {showLots && ( +
+ +
+ )} + +
+ {/* 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. */} + diff --git a/web/src/components/plants/PlantFormModal.tsx b/web/src/components/plants/PlantFormModal.tsx index 70faae3..2072617 100644 --- a/web/src/components/plants/PlantFormModal.tsx +++ b/web/src/components/plants/PlantFormModal.tsx @@ -16,6 +16,7 @@ import { type PlantCategory, type PlantInput, } from '@/lib/plants' +import { safeExternalUrl } from '@/lib/seedLots' import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' const DEFAULT_COLOR = '#4a7c3f' @@ -61,6 +62,8 @@ export function PlantFormModal({ const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR)) const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON) const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') + const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '') + const [vendor, setVendor] = useState(source?.vendor ?? '') const [notes, setNotes] = useState(source?.notes ?? '') const [version, setVersion] = useState(plant?.version ?? 0) const [conflict, setConflict] = useState(null) @@ -96,6 +99,14 @@ export function PlantFormModal({ daysToMaturity = d } + // The server refuses anything that isn't http(s) with a host, but say so here + // rather than letting a paste of "johnnyseeds.com" come back as a generic + // error with no hint about which field or why. + if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) { + setFormError('The source link needs to be a full http:// or https:// address.') + return + } + const input: PlantInput = { name: name.trim(), category, @@ -103,6 +114,8 @@ export function PlantFormModal({ color, icon: icon.trim(), daysToMaturity, + sourceUrl: sourceUrl.trim(), + vendor: vendor.trim(), notes: notes.trim(), } @@ -194,6 +207,27 @@ export function PlantFormModal({ onChange={(e) => setDays(e.target.value)} /> + {/* Provenance for the variety itself. What you bought and what's left + of it is a seed lot, added from the plant card. */} +
+ setVendor(e.target.value)} + /> + setSourceUrl(e.target.value)} + /> +
+