Build image / build-and-push (push) Successful in 21s
Use the full Plant the picker returns (fixes the silent placement failure) and add a per-garden Seed Tray for quick repeat placing. Review fixes: disarm on tray-remove, cap load, consolidate toolbar guards, rename interface, tidy. Closes #39. Co-authored-by: Steve Dudenhoeffer <[email protected]>
85 lines
2.9 KiB
TypeScript
85 lines
2.9 KiB
TypeScript
// The Seed Tray: a small, deliberately-curated set of plants a gardener keeps at
|
|
// hand while planting a bed, so repeat placement is a single tap instead of a
|
|
// trip through the full catalog. It's a per-garden convenience, persisted in
|
|
// localStorage (same rationale as the PlantPicker's "recent plants"): a nicety,
|
|
// not authoritative state, so a quota/availability failure is swallowed.
|
|
//
|
|
// Only plant ids are stored; they resolve against the live catalog on read, so a
|
|
// renamed plant stays current and a deleted one silently drops out of the tray.
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { usePlants, type Plant } from './plants'
|
|
|
|
const KEY = (gardenId: number) => `pansy:seed-tray:${gardenId}`
|
|
const TRAY_MAX = 24 // a working set, not a catalog; caps pathological growth
|
|
|
|
function loadIds(gardenId: number): number[] {
|
|
try {
|
|
const v: unknown = JSON.parse(localStorage.getItem(KEY(gardenId)) ?? '[]')
|
|
const ids = Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
|
|
return ids.slice(-TRAY_MAX) // honor the cap even if storage was hand-edited
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function saveIds(gardenId: number, ids: number[]) {
|
|
try {
|
|
localStorage.setItem(KEY(gardenId), JSON.stringify(ids))
|
|
} catch {
|
|
// The tray is a convenience; ignore quota/availability failures.
|
|
}
|
|
}
|
|
|
|
export interface SeedTrayState {
|
|
/** Tray plants in insertion order, resolved against the live catalog. */
|
|
trayPlants: Plant[]
|
|
/** Add a plant to the end of the tray (no-op if already present; when the tray
|
|
* is full the oldest entry is evicted). */
|
|
add: (plant: Plant) => void
|
|
/** Remove a plant from the tray. */
|
|
remove: (id: number) => void
|
|
}
|
|
|
|
/** Per-garden Seed Tray backed by localStorage and the plant catalog. */
|
|
export function useSeedTray(gardenId: number): SeedTrayState {
|
|
const plants = usePlants()
|
|
const [ids, setIds] = useState<number[]>(() => loadIds(gardenId))
|
|
|
|
// Re-read when switching gardens (the route param changes without remounting).
|
|
useEffect(() => {
|
|
setIds(loadIds(gardenId))
|
|
}, [gardenId])
|
|
|
|
const byId = useMemo(() => new Map((plants.data ?? []).map((p) => [p.id, p])), [plants.data])
|
|
|
|
// Resolve ids → plants, dropping any no longer in the catalog; preserve order.
|
|
const trayPlants = useMemo(
|
|
() => ids.map((id) => byId.get(id)).filter((p): p is Plant => !!p),
|
|
[ids, byId],
|
|
)
|
|
|
|
const add = useCallback(
|
|
(plant: Plant) =>
|
|
setIds((prev) => {
|
|
if (prev.includes(plant.id)) return prev
|
|
const next = [...prev, plant.id].slice(-TRAY_MAX)
|
|
saveIds(gardenId, next)
|
|
return next
|
|
}),
|
|
[gardenId],
|
|
)
|
|
|
|
const remove = useCallback(
|
|
(id: number) =>
|
|
setIds((prev) => {
|
|
const next = prev.filter((x) => x !== id)
|
|
saveIds(gardenId, next)
|
|
return next
|
|
}),
|
|
[gardenId],
|
|
)
|
|
|
|
return { trayPlants, add, remove }
|
|
}
|