Seed shelf UI: source links, lots, and what's left (#51) #68

Merged
steve merged 2 commits from feat/seed-shelf-ui into main 2026-07-21 06:03:12 +00:00
9 changed files with 155 additions and 53 deletions
Showing only changes of commit 4597978034 - Show all commits
@@ -3,8 +3,7 @@ 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'
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/**
* Retire a lot. Worth confirming because it's the one place cost and germination
+6 -16
View File
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { PlantIcon } from './PlantIcon'
import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList'
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 { safeExternalUrl, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units'
/**
@@ -37,7 +38,7 @@ export function PlantCard({
const builtin = isBuiltin(plant)
const [showLots, setShowLots] = useState(false)
const summary = summarizeLots(lots)
const sourceUrl = safeExternalUrl(plant.sourceUrl)
return (
<div className="flex flex-col rounded-xl border border-border bg-surface">
<div className="flex items-start gap-3 p-4">
@@ -54,21 +55,10 @@ export function PlantCard({
<p className="mt-0.5 text-sm text-muted">
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
</p>
{(plant.vendor || sourceUrl) && (
{(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>}
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
// The href is a URL somebody pasted; noopener keeps it from
// getting a handle back to this window.
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-fg"
>
source
</a>
)}
<SourceLink url={plant.sourceUrl} />
</p>
)}
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
+6 -21
View File
@@ -1,10 +1,11 @@
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import { SourceLink } from './SourceLink'
import {
formatCost,
formatQuantity,
formatUnitCost,
lotState,
safeExternalUrl,
type LotState,
type SeedLot,
} from '@/lib/seedLots'
@@ -20,7 +21,9 @@ const STATE_LABEL: Record<LotState, string> = {
// 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: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
// "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',
@@ -110,7 +113,6 @@ function LotRow({
onEdit: () => void
onDelete: () => void
}) {
const url = safeExternalUrl(lot.sourceUrl)
const cost = formatCost(lot.costCents)
const unitCost = formatUnitCost(lot)
@@ -130,18 +132,7 @@ function LotRow({
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
{url && (
<a
href={url}
target="_blank"
// noopener/noreferrer on every outbound link: the target is a URL
// someone pasted, and window.opener would hand it a handle back.
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-fg"
>
source
</a>
)}
<SourceLink url={lot.sourceUrl} />
</p>
<RemainingBar lot={lot} />
</div>
@@ -168,9 +159,3 @@ function LotRow({
</div>
)
}
/** 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)
}
+27 -1
View File
@@ -7,6 +7,7 @@ import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
conflictSeedLot,
LOT_UNITS,
safeExternalUrl,
useCreateSeedLot,
@@ -49,11 +50,14 @@ export function SeedLotModal({
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) {
@@ -109,12 +113,32 @@ export function SeedLotModal({
try {
if (isEdit) {
await update.mutateAsync({ id: lot.id, version: lot.version, ...input })
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.'))
}
}
@@ -122,6 +146,8 @@ export function SeedLotModal({
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"
+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>
)
}
+19 -10
View File
@@ -4,8 +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 { lotsByPlant, lotState, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { LotStateChip, formatQuantity } from '@/components/plants/SeedLotList'
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'
@@ -85,12 +93,15 @@ export function PlantPicker({
}, [all, recent, query])
function choose(p: Plant) {
const lots = (lotsFor.get(p.id) ?? []).filter((l) => l.remaining > 0)
const lots = attributableLots(lotsFor.get(p.id) ?? [])
if (lots.length > 1) {
setChoosingLotFor(p)
return
}
setRecent(recordRecent(p.id))
// 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])
}
@@ -121,12 +132,12 @@ export function PlantPicker({
// 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.
// 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 unitName = lots.every((l) => l.unit === lots[0].unit) ? ` ${lots[0].unit}` : ''
const total = lots.reduce((sum, l) => sum + l.remaining, 0)
return ` · ${formatQuantity(total)}${unitName} left`
const { remaining, unit } = summarizeLots(lots)
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
}
return (
@@ -169,9 +180,7 @@ export function PlantPicker({
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
Which lot of {choosingLotFor.name}?
</p>
{(lotsFor.get(choosingLotFor.id) ?? [])
.filter((l) => l.remaining > 0)
.map((lot) => (
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
<button
key={lot.id}
type="button"
+28
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
attributableLots,
formatCost,
formatQuantity,
formatUnitCost,
lotState,
lotsByPlant,
@@ -119,3 +121,29 @@ describe('lotsByPlant', () => {
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
})
})
+28 -1
View File
@@ -6,7 +6,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from './api'
import { ApiError, api } from './api'
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
export type LotUnit = z.infer<typeof lotUnitSchema>
@@ -104,6 +104,27 @@ export function useDeleteSeedLot() {
})
}
/**
* 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[]>()
@@ -149,6 +170,12 @@ export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUn
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
+12 -1
View File
@@ -31,7 +31,7 @@ import {
import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useJournalCounts } from '@/lib/journal'
import type { SeedLot } from '@/lib/seedLots'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle'
@@ -75,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.
@@ -278,8 +280,17 @@ 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.
// 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)
if (lot === undefined) {
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
lot = lots.length === 1 ? lots[0] : undefined
}
setArmedPlant(plant, lot?.id ?? null)
}