Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
234 lines
9.3 KiB
TypeScript
234 lines
9.3 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { cn } from '@/lib/cn'
|
|
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'
|
|
const RECENT_MAX = 8
|
|
|
|
/** Recently-picked plant ids, most-recent first, from localStorage. */
|
|
function loadRecent(): number[] {
|
|
try {
|
|
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
|
|
return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Prepend id (dedup, capped) and persist; returns the new list. */
|
|
function recordRecent(id: number): number[] {
|
|
const next = [id, ...loadRecent().filter((n) => n !== id)].slice(0, RECENT_MAX)
|
|
try {
|
|
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
|
} catch {
|
|
// Recents are a nicety; ignore quota/availability failures.
|
|
}
|
|
return next
|
|
}
|
|
|
|
/**
|
|
* Reusable plant chooser: a search-first list with a recently-used shortcut and
|
|
* category filter, rendered as a bottom sheet on mobile / centered panel on
|
|
* desktop. `onSelect` fires with the chosen plant (and records it as recent).
|
|
* The plops editor (#15) opens this when placing plants; /plants demos it.
|
|
*/
|
|
export function PlantPicker({
|
|
onSelect,
|
|
onClose,
|
|
unit = 'metric',
|
|
}: {
|
|
// 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())
|
|
const searchRef = useRef<HTMLInputElement>(null)
|
|
const onCloseRef = useRef(onClose)
|
|
onCloseRef.current = onClose
|
|
|
|
useEffect(() => {
|
|
searchRef.current?.focus()
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onCloseRef.current()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [])
|
|
|
|
const all = useMemo(() => plants.data ?? [], [plants.data])
|
|
|
|
const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
|
|
|
|
// Recents show only when not actively searching, and only rows still present.
|
|
const recentPlants = useMemo(() => {
|
|
if (query.trim() !== '') return []
|
|
const byId = new Map(all.map((p) => [p.id, p]))
|
|
return recent.map((id) => byId.get(id)).filter((p): p is Plant => !!p)
|
|
}, [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))
|
|
// 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
|
|
// both the Recent and All sections doesn't collide on a duplicate React key.
|
|
const row = (p: Plant, keyPrefix: string) => (
|
|
<button
|
|
key={`${keyPrefix}-${p.id}`}
|
|
type="button"
|
|
onClick={() => choose(p)}
|
|
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"
|
|
>
|
|
<PlantIcon color={p.color} icon={p.icon} className="h-9 w-9 rounded-md text-xl" />
|
|
<span className="min-w-0 flex-1">
|
|
<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"
|
|
onMouseDown={(e) => e.target === e.currentTarget && onClose()}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Choose a plant"
|
|
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl border border-border bg-surface shadow-xl sm:max-w-lg sm:rounded-2xl"
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border p-3">
|
|
<input
|
|
ref={searchRef}
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search plants…"
|
|
aria-label="Search plants"
|
|
className={cn(fieldControlClass, 'min-w-0 flex-1')}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="rounded-md px-2 py-2 text-muted outline-none transition-colors hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-b border-border px-3 py-2">
|
|
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
|
</div>
|
|
|
|
{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>}
|
|
|
|
{recentPlants.length > 0 && (
|
|
<>
|
|
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent</p>
|
|
{recentPlants.map((p) => row(p, 'recent'))}
|
|
<p className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted">All plants</p>
|
|
</>
|
|
)}
|
|
|
|
{filtered.map((p) => row(p, 'all'))}
|
|
|
|
{plants.isSuccess && filtered.length === 0 && (
|
|
<p className="p-4 text-sm text-muted">No plants match your search.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|