3 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 aec3bb52d1 Address Gadfly review on the agent plant tools
Build image / build-and-push (push) Successful in 7s
attachSeedRemaining summed Remaining across lots in different units and then
dropped only the unit LABEL, leaving a bare number that reads as a quantity and
isn't one — 50 seeds plus 50 grams reported as "100". Worse for a model than for
a person, since it has nothing else to go on. The total is now omitted with the
label, and SeedLots reports how many lots there are, so "several lots, no single
total" stays distinguishable from "no seed at all".

Also folded TestToolboxScenario's inline setup into the newAgentTestService
helper the new tests use, rather than leaving two copies of the same six lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:04:42 -04:00
steveandClaude Opus 4.8 4ab2711c6d Agent tools: plant lookup, plant creation, journal entries (#55)
"Change the garlic garden bed to instead be cucumbers this year" was nearly
executable already — describe_garden resolves "the garlic bed" to an object id
because it reports the NAMES of what's planted, clear_object empties it, and
fill_region replants it. Step three had no way to get its plantId. The toolbox
had no plant tool at all, so the agent could see the string "Garlic" come out of
describe_garden and had no route from "cucumbers" to an id. One missing tool
blocked the whole flagship interaction.

find_plant matches the actor's visible catalog and deliberately returns SEVERAL
candidates rather than one guess. "garlic" against a catalog holding both
"Garlic" and "German Red Garlic" is genuinely ambiguous, and a caller holding
the surrounding conversation is far better placed to disambiguate than a
fuzzy-match heuristic here. Results are ranked exact, then prefix, then
substring, then category, and ordered stably — a tool whose results reshuffle
between calls is one a model can't reason about across turns.

Each result also reports how much seed is left of that plant across the actor's
lots, so the agent can say "you only have enough for half that bed" instead of
confidently planting seed that doesn't exist. Omitted rather than zeroed when
there are no lots, and dropped when lots disagree about the unit, since summing
seeds and grams would be a lie.

create_plant lets a variety be named in conversation without leaving the chat.
It's user-scoped, not garden-scoped — someone with no editable garden can still
name a plant — which the tests assert deliberately rather than assume.

add_journal_entry arrived with #52: "note that the west bed has mildew" is
squarely the kind of thing you say out loud while walking around.

Also reviewed the descriptions on the existing seven tools, since they are the
model's only documentation. fill_region's region vocabulary now says north is
the top of the garden, gives a worked example of the replant sequence, and notes
that filling twice is safe.

The demo sequence is now a test: describe_garden → find_plant("cucumber") →
clear_object → fill_region, ending with a bed of cucumbers and no active garlic.

go.mod is deliberately untouched — majordomo stays out of the module until #56
drops the build tag, so the default build is unaffected.

Closes #55

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:03:49 -04:00
steve 5d9b10d7c7 Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:03:12 +00:00
18 changed files with 1124 additions and 35 deletions
+7 -20
View File
@@ -25,21 +25,8 @@ import (
// Build/run with: go test -tags majordomo ./internal/agent/
func TestToolboxScenario(t *testing.T) {
ctx := context.Background()
db, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
box := NewToolbox(svc, owner.ID)
svc, ownerID := newAgentTestService(t)
box := NewToolbox(svc, ownerID)
call := func(name string, args any) llm.ToolResult {
t.Helper()
@@ -49,13 +36,13 @@ func TestToolboxScenario(t *testing.T) {
// Garden + plants are set up directly (there are no create_garden/plant tools);
// the agent-facing bits — object + fills + describe — go through the toolbox.
g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
g, err := svc.CreateGarden(ctx, ownerID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
garlic := mustPlant(t, svc, ownerID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, ownerID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, ownerID, "Beans", 10, "🫘")
// create_object → a 400×400 bed.
res := call("create_object", map[string]any{
@@ -120,7 +107,7 @@ func TestToolboxScenario(t *testing.T) {
if err != nil {
t.Fatalf("register viewer: %v", err)
}
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
viewerBox := NewToolbox(svc, viewerUser.ID)
+16 -4
View File
@@ -76,10 +76,14 @@ func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant
type PlantMatch struct {
domain.Plant
// SeedRemaining is the total left across the actor's lots of this plant, and
// SeedUnit the unit they're counted in omitted when there are no lots, or
// when lots disagree about the unit and summing them would be a lie.
// SeedUnit the unit they're counted in. Both are omitted when there are no
// lots — and also when the lots disagree about the unit, because adding
// grams to packets produces a number that means nothing. SeedLots still
// reports how many there are, so "several lots, no single total" is
// distinguishable from "no seed at all".
SeedRemaining *float64 `json:"seedRemaining,omitempty"`
SeedUnit string `json:"seedUnit,omitempty"`
SeedLots int `json:"seedLots,omitempty"`
}
// maxPlantMatches caps FindPlants. A model given fifty candidates is not being
@@ -162,14 +166,22 @@ func (s *Service) attachSeedRemaining(ctx context.Context, actorID int64, matche
if len(ls) == 0 {
continue
}
total := 0.0
matches[i].SeedLots = len(ls)
unit := ls[0].Unit
mixed := false
total := 0.0
for _, l := range ls {
total += l.Remaining
if l.Unit != unit {
unit = "" // mixed units can't be summed honestly
mixed = true
}
}
if mixed {
// Dropping only the LABEL would leave a number that reads as a
// quantity and isn't one. Drop the total with it; SeedLots still says
// there is seed here, just not one figure for it.
continue
}
matches[i].SeedRemaining = &total
matches[i].SeedUnit = unit
}
+34
View File
@@ -338,3 +338,37 @@ func TestFindPlantsReportsSeedRemaining(t *testing.T) {
}
}
}
// TestFindPlantsOmitsATotalItCannotHonestlyGive — lots in different units can't
// be summed. Dropping only the unit LABEL would leave a number that reads as a
// quantity and isn't one; the count of lots still says there is seed here.
func TestFindPlantsOmitsATotalItCannotHonestlyGive(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
for _, u := range []string{domain.UnitSeeds, domain.UnitGrams} {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Quantity: 50, Unit: u}); err != nil {
t.Fatalf("CreateSeedLot(%s): %v", u, err)
}
}
got, err := s.FindPlants(ctx, owner, plant.Name)
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(got) == 0 {
t.Fatal("plant not found")
}
if got[0].SeedRemaining != nil {
t.Errorf("reported %v across mismatched units; that number means nothing", *got[0].SeedRemaining)
}
if got[0].SeedUnit != "" {
t.Errorf("seedUnit = %q, want empty", got[0].SeedUnit)
}
// But "several lots, no single total" must stay distinguishable from "none".
if got[0].SeedLots != 2 {
t.Errorf("seedLots = %d, want 2", got[0].SeedLots)
}
}
@@ -0,0 +1,50 @@
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 { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/**
* 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<string | null>(null)
return (
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
<div className="flex flex-col gap-3">
<p className="text-sm text-fg">
{formatQuantity(lot.quantity)} {lot.unit}
{lot.vendor ? ` from ${lot.vendor}` : ''}
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
</p>
<p className="text-sm text-muted">
Anything planted from it stays exactly where it is it just stops being attributed to this purchase.
</p>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={del.isPending}
onClick={() =>
del.mutate(lot.id, {
onSuccess: onClose,
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
})
}
>
{del.isPending ? 'Retiring…' : 'Retire lot'}
</Button>
</div>
</div>
</Modal>
)
}
+51 -1
View File
@@ -1,6 +1,10 @@
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'
/**
@@ -11,17 +15,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)
return (
<div className="flex flex-col rounded-xl border border-border bg-surface">
<div className="flex items-start gap-3 p-4">
@@ -38,6 +55,12 @@ export function PlantCard({
<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
@@ -46,7 +69,34 @@ export function PlantCard({
title={plant.color}
/>
</div>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
{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>
@@ -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<string | null>(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. */}
<div className="grid grid-cols-2 gap-3">
<TextField
label="Vendor"
name="vendor"
placeholder="Johnny's Selected Seeds"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
/>
<TextField
label="Source link"
name="sourceUrl"
type="url"
inputMode="url"
placeholder="https://…"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
/>
</div>
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{formError && <Alert>{formError}</Alert>}
+161
View File
@@ -0,0 +1,161 @@
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import { SourceLink } from './SourceLink'
import {
formatCost,
formatQuantity,
formatUnitCost,
lotState,
type LotState,
type SeedLot,
} from '@/lib/seedLots'
const STATE_LABEL: Record<LotState, string> = {
over: 'over-planted',
empty: 'empty',
low: 'low',
ok: 'in stock',
unknown: 'no count',
}
// Encoded in colour AND words, because this is the thing you skim down a list of
// twenty packets deciding what to order — a bare number doesn't survive that.
const STATE_CLASS: Record<LotState, string> = {
// "over" is a discrepancy to look at rather than a shortage to act on, so it
// reads as a distinct warning rather than sharing "low"'s styling.
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
ok: 'bg-accent/20 text-accent-strong',
unknown: 'bg-border/60 text-muted',
}
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
return (
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
STATE_CLASS[state],
className,
)}
>
{STATE_LABEL[state]}
</span>
)
}
/** A proportional bar for how much of a lot is left, so the state reads before
* any of the text does. Omitted when there's no quantity to be a fraction of. */
function RemainingBar({ lot }: { lot: SeedLot }) {
if (lot.quantity <= 0) return null
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
const state = lotState(lot)
return (
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
<div
className={cn(
'h-full rounded-full',
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
)}
style={{ width: `${pct}%` }}
/>
</div>
)
}
/**
* A plant's purchases: what came from where, and what's left of each.
*
* Two lots of the same variety are two separate rows with independent counts,
* which is the whole reason inventory lives on the purchase rather than on the
* plant (#50).
*/
export function SeedLotList({
lots,
canEdit,
onAdd,
onEdit,
onDelete,
}: {
lots: SeedLot[]
canEdit: boolean
onAdd: () => void
onEdit: (lot: SeedLot) => void
onDelete: (lot: SeedLot) => void
}) {
return (
<div className="flex flex-col gap-2">
{lots.length === 0 && (
<p className="text-xs text-muted">
No seed recorded. Add a lot to track what you bought and how much is left.
</p>
)}
{lots.map((lot) => (
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
))}
{canEdit && (
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
+ Add seed lot
</Button>
)}
</div>
)
}
function LotRow({
lot,
canEdit,
onEdit,
onDelete,
}: {
lot: SeedLot
canEdit: boolean
onEdit: () => void
onDelete: () => void
}) {
const cost = formatCost(lot.costCents)
const unitCost = formatUnitCost(lot)
return (
<div className="rounded-lg border border-border px-2 py-1.5">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
<span className="font-medium tabular-nums">
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
</span>
<LotStateChip state={lotState(lot)} />
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
{lot.vendor && <span>{lot.vendor}</span>}
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
<SourceLink url={lot.sourceUrl} />
</p>
<RemainingBar lot={lot} />
</div>
{canEdit && (
<div className="flex shrink-0 flex-col items-end">
<button
type="button"
onClick={onEdit}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Edit
</button>
<button
type="button"
onClick={onDelete}
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
>
Retire
</button>
</div>
)}
</div>
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
</div>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { useState, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
conflictSeedLot,
LOT_UNITS,
safeExternalUrl,
useCreateSeedLot,
useUpdateSeedLot,
type LotUnit,
type SeedLot,
} from '@/lib/seedLots'
import type { Plant } from '@/lib/plants'
/**
* Record a purchase, or correct one. Everything except quantity and unit is
* optional — the point is to make writing it down cheap enough to bother with,
* and a form that demands a SKU and a lot code gets skipped.
*/
export function SeedLotModal({
plant,
lot,
onClose,
}: {
plant: Plant
/** Editing an existing lot, or undefined to record a new one. */
lot?: SeedLot
onClose: () => void
}) {
const isEdit = !!lot
const create = useCreateSeedLot()
const update = useUpdateSeedLot()
const pending = create.isPending || update.isPending
// A new lot inherits the plant's vendor and source link, since the usual case
// is buying the variety you already recorded from the place you recorded it.
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
const [sku, setSku] = useState(lot?.sku ?? '')
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
const [notes, setNotes] = useState(lot?.notes ?? '')
const [version, setVersion] = useState(lot?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setConflict(null)
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
setError('The source link needs to be a full http:// or https:// address.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
let germinationPct: number | null = null
if (germination.trim()) {
const g = Number(germination)
if (!Number.isFinite(g) || g < 0 || g > 100) {
setError('Germination is a percentage between 0 and 100.')
return
}
germinationPct = g
}
const input = {
plantId: plant.id,
vendor: vendor.trim(),
sourceUrl: sourceUrl.trim(),
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
packedForYear: year,
quantity: qty,
unit,
costCents,
germinationPct,
notes: notes.trim(),
}
try {
if (isEdit) {
await update.mutateAsync({ id: lot.id, version, ...input })
} else {
await create.mutateAsync(input)
}
onClose()
} catch (err) {
const current = conflictSeedLot(err)
if (current) {
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
// re-save applies, rather than making them retype everything — the same
// contract every other version-guarded form here honours.
setVersion(current.version)
setVendor(current.vendor)
setSourceUrl(current.sourceUrl)
setQuantity(String(current.quantity))
setUnit(current.unit)
setPurchasedAt(current.purchasedAt ?? '')
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
setSku(current.sku)
setLotCode(current.lotCode)
setNotes(current.notes)
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
return
}
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
}
}
return (
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
{conflict && <Alert tone="info">{conflict}</Alert>}
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={unit}
onChange={(e) => setUnit(e.target.value as LotUnit)}
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Source link"
name="sourceUrl"
type="url"
inputMode="url"
placeholder="https://…"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Purchased"
name="purchasedAt"
type="date"
value={purchasedAt}
onChange={(e) => setPurchasedAt(e.target.value)}
/>
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
<TextField
label="Germination %"
name="germination"
type="number"
inputMode="decimal"
step="any"
min="0"
max="100"
value={germination}
onChange={(e) => setGermination(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
</div>
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
</Button>
</div>
</form>
</Modal>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { safeExternalUrl } from '@/lib/seedLots'
/**
* A link out to where seed came from.
*
* The href is a URL somebody pasted, so it is re-checked here before rendering
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
* but a link is rendered from whatever the client was handed, and "the backend
* validated it" is not a reason to hand javascript: to an anchor tag.
*
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
* have to remember to guard.
*/
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
const safe = safeExternalUrl(url)
if (!safe) return null
return (
<a
href={safe}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-fg"
>
{label}
</a>
)
}
+9 -1
View File
@@ -96,6 +96,7 @@ export function GardenCanvas({
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const armedPlant = useEditorStore((s) => s.armedPlant)
const armedLotId = useEditorStore((s) => s.armedLotId)
const liveObject = useEditorStore((s) => s.liveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const { fitToRect } = useViewport(svgRef)
@@ -200,7 +201,14 @@ export function GardenCanvas({
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
// Stay armed for repeat-placement; don't select (the placement sheet covers
// the object, so a selection would be hidden until placement ends anyway).
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
createPlanting.mutate({
objectId: focusedObject.id,
plantId: armedPlant.id,
xCm: x,
yCm: y,
radiusCm,
seedLotId: armedLotId ?? undefined,
})
}
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
+80 -3
View File
@@ -4,6 +4,16 @@ import { fieldControlClass } from '@/components/ui/field'
import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import {
attributableLots,
formatQuantity,
lotsByPlant,
lotState,
summarizeLots,
useSeedLots,
type SeedLot,
} from '@/lib/seedLots'
import { LotStateChip } from '@/components/plants/SeedLotList'
import { formatSpacing, type UnitPref } from '@/lib/units'
const RECENT_KEY = 'pansy:recent-plants'
@@ -41,11 +51,20 @@ export function PlantPicker({
onClose,
unit = 'metric',
}: {
onSelect: (plant: Plant) => void
// The chosen plant, and the lot it should be attributed to when that isn't
// ambiguous. Undefined lot means "don't attribute" — which is the honest
// answer when there are no lots, and the deliberate one when the user skips.
onSelect: (plant: Plant, lot?: SeedLot) => void
onClose: () => void
unit?: UnitPref
}) {
const plants = usePlants()
const seedLots = useSeedLots()
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// Set when a plant with SEVERAL lots is picked: which packet did this come out
// of? One lot auto-attributes and zero lots stays silent, because forcing that
// question on every placement is how a nicety becomes an obstacle.
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
const [recent, setRecent] = useState<number[]>(() => loadRecent())
@@ -74,8 +93,21 @@ export function PlantPicker({
}, [all, recent, query])
function choose(p: Plant) {
const lots = attributableLots(lotsFor.get(p.id) ?? [])
if (lots.length > 1) {
setChoosingLotFor(p)
return
}
setRecent(recordRecent(p.id))
onSelect(p)
// A single lot attributes even when its count says empty. The count records
// what was written down, not a fact about the packet — dropping attribution
// there would make a wrong number harder to correct rather than easier.
onSelect(p, lots[0])
}
function chooseLot(p: Plant, lot?: SeedLot) {
setRecent(recordRecent(p.id))
onSelect(p, lot)
}
// A plant option row. keyPrefix namespaces the key so a plant appearing in
@@ -92,11 +124,22 @@ export function PlantPicker({
<span className="block truncate font-medium text-fg">{p.name}</span>
<span className="block text-xs text-muted">
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
{remainingLabel(lotsFor.get(p.id) ?? [])}
</span>
</span>
</button>
)
// What's left, shown where you're deciding what to plant. Silent when there
// are no lots — most plants won't have any, and "0 left" on all of them would
// be noise that trains you to ignore the number. summarizeLots owns the
// unit-agreement rule; a second copy of it here would drift.
function remainingLabel(lots: SeedLot[]): string {
if (lots.length === 0) return ''
const { remaining, unit } = summarizeLots(lots)
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
@@ -132,7 +175,41 @@ export function PlantPicker({
<CategoryChips value={category} onChange={setCategory} size="sm" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{choosingLotFor && (
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
Which lot of {choosingLotFor.name}?
</p>
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
<button
key={lot.id}
type="button"
onClick={() => chooseLot(choosingLotFor, lot)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-fg">
{lot.vendor || 'Unnamed lot'}
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
</span>
<span className="block text-xs text-muted">
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
</span>
</span>
<LotStateChip state={lotState(lot)} />
</button>
))}
<button
type="button"
onClick={() => chooseLot(choosingLotFor, undefined)}
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
>
Don't attribute to a lot
</button>
</div>
)}
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
+7 -2
View File
@@ -47,7 +47,10 @@ interface EditorState {
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
setArmedPlant: (p: Plant | null) => void
// Which seed lot placements should be attributed to, when the armed plant has
// one worth naming. Cleared with the plant.
armedLotId: number | null
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
// During a move/resize/rotate, the object's live geometry is held here so the
// canvas renders it instantly; the PATCH fires only on gesture end.
@@ -98,7 +101,8 @@ export const useEditorStore = create<EditorState>((set) => ({
setJournalObjectId: (id) => set({ journalObjectId: id }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
liveObject: null,
setLiveObject: (o) => set({ liveObject: o }),
@@ -118,6 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
selectedPlantingId: null,
focusedObjectId: null,
armedPlant: null,
armedLotId: null,
armedKind: null,
liveObject: null,
livePlanting: null,
+2
View File
@@ -258,6 +258,8 @@ export interface PlantingCreate {
radiusCm: number
count?: number | null
label?: string | null
/** Attributes the plop to a purchase, so that lot can report what's left. */
seedLotId?: number
}
export function useCreatePlanting(gardenId: number) {
+6
View File
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
color: z.string(),
icon: z.string(),
daysToMaturity: z.number().nullable().optional(),
// Provenance for the VARIETY — the page you'd go back to to buy it again.
// What you actually bought, and how much is left, lives in seedLots.ts.
sourceUrl: z.string().default(''),
vendor: z.string().default(''),
notes: z.string(),
version: z.number(),
createdAt: z.string(),
@@ -75,6 +79,8 @@ export interface PlantInput {
color: string
icon: string
daysToMaturity: number | null
sourceUrl: string
vendor: string
notes: string
}
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import {
attributableLots,
formatCost,
formatQuantity,
formatUnitCost,
lotState,
lotsByPlant,
safeExternalUrl,
summarizeLots,
type SeedLot,
} from './seedLots'
function lot(over: Partial<SeedLot> = {}): SeedLot {
return {
id: 1,
ownerId: 1,
plantId: 1,
vendor: '',
sourceUrl: '',
sku: '',
lotCode: '',
quantity: 100,
unit: 'seeds',
notes: '',
version: 1,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
used: 0,
remaining: 100,
...over,
}
}
describe('safeExternalUrl', () => {
// The server already scheme-checks this, but a link is rendered from whatever
// the client was handed. "The backend validated it" is not a reason to hand
// javascript: to an anchor tag.
it('accepts http and https', () => {
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
})
it('refuses everything else', () => {
for (const bad of [
'',
'javascript:alert(1)',
'JavaScript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
'//evil.example.com',
'/seeds/garlic',
'not a url',
]) {
expect(safeExternalUrl(bad)).toBeNull()
}
})
})
describe('lotState', () => {
it('reads the state, not just the number', () => {
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
// Planting more than you recorded buying is real, and worth showing plainly
// rather than clamping to zero.
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
})
it('has nothing to say about a lot with no recorded quantity', () => {
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
})
})
describe('summarizeLots', () => {
it('totals remaining and reports the worst state', () => {
const s = summarizeLots([
lot({ id: 1, quantity: 100, remaining: 80 }),
lot({ id: 2, quantity: 50, remaining: 5 }),
])
expect(s.remaining).toBe(85)
expect(s.unit).toBe('seeds')
expect(s.state).toBe('low') // the one that needs attention wins
})
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
const s = summarizeLots([
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
])
expect(s.unit).toBeNull()
})
it('says nothing for a plant with no lots', () => {
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
})
})
describe('cost formatting', () => {
it('renders whole currency from cents', () => {
expect(formatCost(499)).toBe('$4.99')
expect(formatCost(undefined)).toBeNull()
})
it('gives per-unit cost extra precision when it would round to nothing', () => {
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
})
it('has no per-unit cost without both halves', () => {
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
})
})
describe('lotsByPlant', () => {
it('groups, and copes with nothing', () => {
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
expect(grouped.get(7)?.length).toBe(2)
expect(grouped.get(9)?.length).toBe(1)
expect(lotsByPlant(undefined).size).toBe(0)
})
})
describe('attributableLots', () => {
// An exhausted lot is still offered: the count records what was written down,
// not a fact about the packet, so refusing to attribute a planting because the
// number says zero would make a wrong number harder to correct, not easier.
it('offers exhausted lots, just not first', () => {
const empty = lot({ id: 1, remaining: 0 })
const full = lot({ id: 2, remaining: 100 })
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
expect(attributableLots([empty]).length).toBe(1)
})
it("doesn't mutate its input", () => {
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
attributableLots(lots)
expect(lots.map((l) => l.id)).toEqual([1, 2])
})
})
describe('formatQuantity', () => {
it('keeps whole counts whole and fractional ones readable', () => {
expect(formatQuantity(100)).toBe('100')
expect(formatQuantity(12.5)).toBe('12.5')
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
})
})
+212
View File
@@ -0,0 +1,212 @@
// Seed lots (#51): what you actually bought, and what's left of it.
//
// A lot is one purchase of one variety. Inventory belongs to the purchase rather
// than to the variety because the same garlic from two vendors in two years is
// two different things — see #50 for the reasoning behind the shape.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
export type LotUnit = z.infer<typeof lotUnitSchema>
export const LOT_UNITS: { value: LotUnit; label: string }[] = [
{ value: 'seeds', label: 'seeds' },
{ value: 'grams', label: 'grams' },
{ value: 'ounces', label: 'ounces' },
{ value: 'packets', label: 'packets' },
{ value: 'bulbs', label: 'bulbs' },
{ value: 'plants', label: 'plants' },
]
export const seedLotSchema = z.object({
id: z.number(),
ownerId: z.number(),
plantId: z.number(),
vendor: z.string(),
sourceUrl: z.string(),
sku: z.string(),
lotCode: z.string(),
purchasedAt: z.string().optional(),
packedForYear: z.number().optional(),
quantity: z.number(),
unit: lotUnitSchema,
costCents: z.number().optional(),
germinationPct: z.number().optional(),
notes: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
// Computed server-side: used is the summed effective count of active
// plantings attributed to this lot; remaining is quantity - used, and may be
// NEGATIVE when more was planted than the lot recorded buying.
used: z.number(),
remaining: z.number(),
})
export type SeedLot = z.infer<typeof seedLotSchema>
const seedLotsKey = ['seed-lots'] as const
export function useSeedLots() {
return useQuery({
queryKey: seedLotsKey,
queryFn: async (): Promise<SeedLot[]> => z.array(seedLotSchema).parse(await api.get('/seed-lots')),
})
}
export interface SeedLotInput {
plantId: number
vendor: string
sourceUrl: string
sku: string
lotCode: string
purchasedAt: string | null
packedForYear: number | null
quantity: number
unit: LotUnit
costCents: number | null
germinationPct: number | null
notes: string
}
function invalidate(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: seedLotsKey })
}
export function useCreateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SeedLotInput): Promise<SeedLot> =>
seedLotSchema.parse(await api.post('/seed-lots', input)),
onSuccess: () => invalidate(qc),
})
}
export function useUpdateSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: Partial<SeedLotInput> & { id: number; version: number }): Promise<SeedLot> => {
const { id, ...rest } = vars
return seedLotSchema.parse(await api.patch(`/seed-lots/${id}`, rest))
},
onSuccess: () => invalidate(qc),
})
}
export function useDeleteSeedLot() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/seed-lots/${id}`)
},
onSuccess: () => invalidate(qc),
})
}
/**
* The lots a placement could be attributed to, best first.
*
* Lots with seed left come first, but an exhausted one is still offered: the
* count is a record of what you wrote down, not a fact about the packet, and
* refusing to attribute a planting because the number says zero would make the
* number harder to correct rather than easier.
*/
export function attributableLots(lots: SeedLot[]): SeedLot[] {
return [...lots].sort((a, b) => Number(b.remaining > 0) - Number(a.remaining > 0))
}
/** The current server row carried by a 409, so an edit form can rebase onto it
* and the user can re-save rather than losing what they typed. Every other
* entity's form does this; a lot is no different. */
export function conflictSeedLot(err: unknown): SeedLot | null {
if (!(err instanceof ApiError) || !err.isConflict) return null
const parsed = seedLotSchema.safeParse((err.body as { current?: unknown })?.current)
return parsed.success ? parsed.data : null
}
/** Group lots by the plant they belong to. */
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
const map = new Map<number, SeedLot[]>()
for (const lot of lots ?? []) {
const list = map.get(lot.plantId)
if (list) list.push(lot)
else map.set(lot.plantId, [lot])
}
return map
}
/**
* How a lot is doing, as a state rather than a number — this is the thing you
* scan for when deciding what to order, and a number alone doesn't survive being
* skimmed down a list.
*
* "over" means more was planted than the lot recorded buying. That's a real
* situation (a miscounted packet, a lot entered after the fact) and worth
* showing plainly rather than clamping to zero and hiding the discrepancy.
*/
export type LotState = 'over' | 'empty' | 'low' | 'ok' | 'unknown'
const LOW_FRACTION = 0.2
export function lotState(lot: SeedLot): LotState {
if (lot.quantity <= 0) return 'unknown' // no quantity recorded: nothing to be low on
if (lot.remaining < 0) return 'over'
if (lot.remaining === 0) return 'empty'
if (lot.remaining / lot.quantity <= LOW_FRACTION) return 'low'
return 'ok'
}
/** The total remaining across a plant's lots, and the worst state among them —
* what a plant card shows before you open it. */
export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUnit | null; state: LotState } {
if (lots.length === 0) return { remaining: 0, unit: null, state: 'unknown' }
// Mixed units can't be summed honestly; report the majority unit's total only
// when they all agree, else leave the unit off and let the per-lot rows speak.
const unit = lots.every((l) => l.unit === lots[0].unit) ? lots[0].unit : null
const remaining = lots.reduce((sum, l) => sum + l.remaining, 0)
const order: LotState[] = ['over', 'empty', 'low', 'ok', 'unknown']
const state = order.find((s) => lots.some((l) => lotState(l) === s)) ?? 'unknown'
return { remaining, unit, state }
}
/** Quantities are stored as REAL because grams and ounces are fractional, but a
* whole number of seeds shouldn't render as "100.0". */
export function formatQuantity(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Whole currency from cents, e.g. 499 → "$4.99". */
export function formatCost(cents: number | undefined): string | null {
if (cents == null) return null
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cents / 100)
}
/** Cost per unit, when both are known and it says something useful. */
export function formatUnitCost(lot: SeedLot): string | null {
if (lot.costCents == null || lot.quantity <= 0) return null
const per = lot.costCents / 100 / lot.quantity
return `${new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: per < 0.1 ? 3 : 2 }).format(per)}/${singular(lot.unit)}`
}
function singular(unit: LotUnit): string {
return unit.endsWith('s') ? unit.slice(0, -1) : unit
}
/**
* Whether a stored URL is safe to render as a link.
*
* The server already scheme-checks this (#50), but a link is rendered from
* whatever the client was handed — an old row, a different server version, a
* response someone tampered with — and "the backend validated it" is not a
* reason to hand `javascript:` to an anchor tag. Cheap to check twice.
*/
export function safeExternalUrl(raw: string): string | null {
if (!raw) return null
try {
const u = new URL(raw)
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
} catch {
return null
}
}
+16 -4
View File
@@ -31,6 +31,7 @@ import {
import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle'
@@ -74,6 +75,8 @@ export function GardenEditorPage() {
const updateObject = useUpdateObject(gid)
const ensurePlant = useEnsurePlantInFull(gid)
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
const seedLots = useSeedLots()
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
@@ -277,9 +280,18 @@ export function GardenEditorPage() {
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
// garden's /full payload, so make sure the chosen one is in the cache first —
// otherwise its placed plops render without an icon/color until a refetch.
function armPlant(plant: Plant) {
// The Seed Tray arms a plant directly, without going through the picker, so it
// has to do the picker's single-lot auto-attribution itself — otherwise the
// quickest way to place a plant is the one path that silently loses which
// packet it came from. Several lots is genuinely ambiguous, so that case stays
// unattributed rather than guessing; the picker is where you choose.
function armPlant(plant: Plant, lot?: SeedLot) {
ensurePlant(plant)
setArmedPlant(plant)
if (lot === undefined) {
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
lot = lots.length === 1 ? lots[0] : undefined
}
setArmedPlant(plant, lot?.id ?? null)
}
// Removing the armed plant from the tray also disarms it, so placement doesn't
@@ -294,14 +306,14 @@ export function GardenEditorPage() {
// a not-yet-placed plant isn't in that map, which used to silently abort the
// pick (nothing armed, picker left open). Picking (place OR change) makes sure
// the plant renders and drops it into this garden's tray for quick reuse.
function onPickPlant(plant: Plant) {
function onPickPlant(plant: Plant, lot?: SeedLot) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
ensurePlant(plant)
addToTray(plant)
if (picker === 'change' && selectedPlop) {
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
setArmedPlant(plant) // 'place' mode: arm for repeat placement
setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
+15
View File
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
import { PlantCard } from '@/components/plants/PlantCard'
import { PlantFormModal } from '@/components/plants/PlantFormModal'
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
import { SeedLotModal } from '@/components/plants/SeedLotModal'
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
import { PlantPicker } from '@/editor/PlantPicker'
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import type { UnitPref } from '@/lib/units'
import { usePageTitle } from '@/lib/usePageTitle'
@@ -19,6 +22,9 @@ type Dialog =
| { kind: 'duplicate'; plant: Plant }
| { kind: 'delete'; plant: Plant }
| { kind: 'picker' }
| { kind: 'addLot'; plant: Plant }
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
| { kind: 'deleteLot'; lot: SeedLot }
| null
// There's no per-user unit preference server-side (only per-garden), so the
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
export function PlantsPage() {
usePageTitle('Plants')
const plants = usePlants()
const seedLots = useSeedLots()
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
@@ -108,9 +116,13 @@ export function PlantsPage() {
key={p.id}
plant={p}
unit={unit}
lots={lots.get(p.id) ?? []}
onEdit={() => setDialog({ kind: 'edit', plant: p })}
onDelete={() => setDialog({ kind: 'delete', plant: p })}
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
/>
))}
</div>
@@ -121,6 +133,9 @@ export function PlantsPage() {
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
{dialog?.kind === 'picker' && (
<PlantPicker
unit={unit}