Plants mode: recent-planted quick strip + clump/rows fill (#100)
Two things Steve asked for, both in the #99 Plants mode: - A "Recent" strip of the plants most recently planted IN THIS GARDEN (recentlyPlantedIds — derived from actual plantings, newest first, unique; NOT the manual localStorage tray), as tap-to-arm chips. So re-planting "more of the same" is one tap, no picker. Hidden until something's planted. - A clump/rows fill control (FillControl), shown once a plant is armed: pick a layout, "Fill bed", and it runs POST /objects/:id/fill region=all with the chosen layout — the #77 grid/clump fill the UI could NOT reach before (it was agent/REST only). Defaults to rows (a real planting). useFillObject mirrors useClearObject: one request, invalidate /full. Both live in the shared PlantPlacementTools, so desktop's focus toolbar and the mobile Plants strip get them identically. Fill is capped/validated server-side (#95) and covered spots are skipped, so re-filling is safe. Verified live at 390px: the Recent strip shows this garden's tomato/lettuce/ garlic; arming a chip reveals Clump|Rows + Fill bed; fill fires cleanly. tsc + vitest (95, incl. recentlyPlantedIds) + build green. DESIGN updated. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -12,6 +12,7 @@ import { PlopInspector } from '@/editor/PlopInspector'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { Palette } from '@/editor/Palette'
|
||||
import { SeedTray } from '@/editor/SeedTray'
|
||||
import { RecentPlants } from '@/editor/RecentPlants'
|
||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||
import { EditorHint } from '@/editor/EditorHint'
|
||||
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||
@@ -24,13 +25,15 @@ import { useMe } from '@/lib/auth'
|
||||
import {
|
||||
toEditorObject,
|
||||
useEnsurePlantInFull,
|
||||
useFillObject,
|
||||
useGardenFull,
|
||||
useGardenSeason,
|
||||
useGardenYears,
|
||||
useUpdateObject,
|
||||
useUpdatePlanting,
|
||||
type FillLayout,
|
||||
} from '@/lib/objects'
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import { recentlyPlantedIds, toEditorPlanting } from '@/lib/plantings'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { useCapabilities } from '@/lib/agent'
|
||||
import { useJournalCounts } from '@/lib/journal'
|
||||
@@ -88,6 +91,7 @@ export function GardenEditorPage() {
|
||||
const setMode = useEditorStore((s) => s.setMode)
|
||||
|
||||
const updatePlanting = useUpdatePlanting(gid)
|
||||
const fillObject = useFillObject(gid)
|
||||
const updateObject = useUpdateObject(gid)
|
||||
const ensurePlant = useEnsurePlantInFull(gid)
|
||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||
@@ -109,6 +113,17 @@ export function GardenEditorPage() {
|
||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||
|
||||
// Plants recently placed in THIS garden, newest first (#100) — the quick strip
|
||||
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
|
||||
const recentPlants = useMemo(
|
||||
() =>
|
||||
recentlyPlantedIds(plantings)
|
||||
.slice(0, 8)
|
||||
.map((id) => plantsById.get(id))
|
||||
.filter((p): p is Plant => !!p),
|
||||
[plantings, plantsById],
|
||||
)
|
||||
|
||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||
// it. Ownership is the authoritative ownerId==me check.
|
||||
const gd = full.data?.garden
|
||||
@@ -393,6 +408,14 @@ export function GardenEditorPage() {
|
||||
if (armedPlant?.id === id) setArmedPlant(null)
|
||||
}
|
||||
|
||||
// Fill the whole focused bed with the armed plant at the chosen layout (#100 /
|
||||
// #77) — the UI's way to run the region fill that was agent-only before. Guards
|
||||
// are belt-and-braces: the control only shows with a plant armed in a bed.
|
||||
function fillBed(layout: FillLayout) {
|
||||
if (!canEdit || focusedObject == null || armedPlant == null) return
|
||||
fillObject.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, layout })
|
||||
}
|
||||
|
||||
// The picker hands back the full Plant (from the whole catalog), so use it
|
||||
// directly rather than re-resolving against the garden's referenced-plant map —
|
||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||
@@ -572,6 +595,7 @@ export function GardenEditorPage() {
|
||||
{canEdit &&
|
||||
(focusedObject.plantable ? (
|
||||
<PlantPlacementTools
|
||||
recentPlants={recentPlants}
|
||||
trayPlants={trayPlants}
|
||||
armedPlant={armedPlant}
|
||||
onArm={armPlant}
|
||||
@@ -580,6 +604,8 @@ export function GardenEditorPage() {
|
||||
onDisarm={() => setArmedPlant(null)}
|
||||
focusedPlopCount={focusedPlops.length}
|
||||
onClear={() => setClearing(true)}
|
||||
onFill={fillBed}
|
||||
filling={fillObject.isPending}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted">Not plantable</span>
|
||||
@@ -611,6 +637,7 @@ export function GardenEditorPage() {
|
||||
focusedObject.plantable ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PlantPlacementTools
|
||||
recentPlants={recentPlants}
|
||||
trayPlants={trayPlants}
|
||||
armedPlant={armedPlant}
|
||||
onArm={armPlant}
|
||||
@@ -619,6 +646,8 @@ export function GardenEditorPage() {
|
||||
onDisarm={() => setArmedPlant(null)}
|
||||
focusedPlopCount={focusedPlops.length}
|
||||
onClear={() => setClearing(true)}
|
||||
onFill={fillBed}
|
||||
filling={fillObject.isPending}
|
||||
/>
|
||||
<Button variant="ghost" className="ml-auto px-2 py-1 text-xs" onClick={exitFocus}>
|
||||
Done planting
|
||||
@@ -694,6 +723,7 @@ export function GardenEditorPage() {
|
||||
// The plant-placement cluster (seed tray + Done + Clear), shared by the desktop
|
||||
// focus toolbar and the mobile Plants strip so the two can't drift apart.
|
||||
function PlantPlacementTools({
|
||||
recentPlants,
|
||||
trayPlants,
|
||||
armedPlant,
|
||||
onArm,
|
||||
@@ -702,7 +732,10 @@ function PlantPlacementTools({
|
||||
onDisarm,
|
||||
focusedPlopCount,
|
||||
onClear,
|
||||
onFill,
|
||||
filling,
|
||||
}: {
|
||||
recentPlants: Plant[]
|
||||
trayPlants: Plant[]
|
||||
armedPlant: Plant | null
|
||||
onArm: (p: Plant, lot?: SeedLot) => void
|
||||
@@ -711,9 +744,12 @@ function PlantPlacementTools({
|
||||
onDisarm: () => void
|
||||
focusedPlopCount: number
|
||||
onClear: () => void
|
||||
onFill: (layout: FillLayout) => void
|
||||
filling: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<RecentPlants plants={recentPlants} armedPlantId={armedPlant?.id ?? null} onArm={onArm} />
|
||||
<SeedTray
|
||||
trayPlants={trayPlants}
|
||||
armedPlantId={armedPlant?.id ?? null}
|
||||
@@ -721,6 +757,7 @@ function PlantPlacementTools({
|
||||
onRemove={onRemove}
|
||||
onOpenPicker={onOpenPicker}
|
||||
/>
|
||||
{armedPlant && <FillControl onFill={onFill} busy={filling} />}
|
||||
{armedPlant && (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onDisarm}>
|
||||
Done
|
||||
@@ -739,6 +776,38 @@ function PlantPlacementTools({
|
||||
)
|
||||
}
|
||||
|
||||
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
|
||||
// the armed plant. Clump packs fat blobs for a quick sketch; grid lays out
|
||||
// individual plants in rows you could actually plant from. Defaults to grid,
|
||||
// since "fill this bed" usually means a real planting.
|
||||
function FillControl({ onFill, busy }: { onFill: (layout: FillLayout) => void; busy: boolean }) {
|
||||
const [layout, setLayout] = useState<FillLayout>('grid')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-0.5 text-xs">
|
||||
<span className="inline-flex overflow-hidden rounded-full">
|
||||
{(['clump', 'grid'] as const).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
aria-pressed={layout === l}
|
||||
onClick={() => setLayout(l)}
|
||||
title={l === 'clump' ? 'Fat clumps — a quick sketch' : 'Rows of plants you could plant from'}
|
||||
className={cn(
|
||||
'px-2 py-0.5 font-medium capitalize transition-colors',
|
||||
layout === l ? 'bg-border/70 text-accent-strong' : 'text-muted hover:text-fg',
|
||||
)}
|
||||
>
|
||||
{l === 'grid' ? 'rows' : l}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<Button variant="ghost" className="px-2 py-0.5 text-xs" disabled={busy} onClick={() => onFill(layout)}>
|
||||
{busy ? 'Filling…' : 'Fill bed'}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The mobile primary mode switch (#99): one always-there tab bar so "placing
|
||||
// beds", "planting", "journaling" and "assistant" stop competing for the same
|
||||
// strip. Assistant is dropped when the instance has no model configured.
|
||||
|
||||
Reference in New Issue
Block a user