Plants mode: recent-planted quick strip + clump/rows fill (#100)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s

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:
2026-07-22 01:49:24 -04:00
co-authored by Claude Opus 4.8
parent 90af03d597
commit 97c8a36bac
6 changed files with 215 additions and 3 deletions
+48
View File
@@ -0,0 +1,48 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import type { Plant } from '@/lib/plants'
/**
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
* so re-placing "more of the same" is one tap instead of a trip through the
* catalog or the manual tray. Derived from actual plantings (see
* recentlyPlantedIds), newest first; renders nothing until something's planted.
* Tap a chip to arm it for placement (the armed one is highlighted).
*/
export function RecentPlants({
plants,
armedPlantId,
onArm,
}: {
plants: Plant[]
armedPlantId: number | null
onArm: (plant: Plant) => void
}) {
if (plants.length === 0) return null
return (
<div className="flex items-center gap-1.5 overflow-x-auto">
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
{plants.map((p) => {
const active = p.id === armedPlantId
return (
<button
key={p.id}
type="button"
onClick={() => onArm(p)}
aria-pressed={active}
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full border py-1 pl-1.5 pr-2.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
active
? 'border-accent bg-accent/10 text-accent-strong'
: 'border-border bg-surface text-fg hover:border-accent',
)}
>
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate">{p.name}</span>
</button>
)
})}
</div>
)
}
+32
View File
@@ -329,6 +329,38 @@ export function useUpdatePlanting(gardenId: number) {
}
const clearResultSchema = z.object({ cleared: z.number() })
const fillResultSchema = z.object({ created: z.number() })
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
* coverage, or a grid of individual plants at true spacing you could plant from.
* Passed straight through to the server's `layout` field. */
export type FillLayout = 'clump' | 'grid'
/** Fill a whole plantable object with one plant at the chosen layout, via the
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
* just a count; invalidate rather than optimistically splice a hex lattice we'd
* have to recompute client-side. Returns how many plops it created. */
export function useFillObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
objectId,
plantId,
layout,
}: {
objectId: number
plantId: number
layout: FillLayout
}): Promise<number> => {
const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
)
return res.created
},
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
})
}
/** Clear a bed: soft-remove every active plop in an object (#82).
*
+43 -1
View File
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest'
import { computeDerivedCount, effectiveCount } from './plantings'
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
function plop(over: Partial<EditorPlanting>): EditorPlanting {
return {
id: 0,
objectId: 1,
plantId: 1,
xCm: 0,
yCm: 0,
radiusCm: 5,
count: null,
derivedCount: 1,
label: null,
plantedAt: null,
version: 1,
...over,
}
}
describe('recentlyPlantedIds', () => {
it('returns unique plant ids, newest planted first', () => {
const got = recentlyPlantedIds([
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
])
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
expect(got).toEqual([20, 10])
})
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
const got = recentlyPlantedIds([
plop({ id: 5, plantId: 30, plantedAt: null }),
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
])
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
})
it('is empty for no plantings', () => {
expect(recentlyPlantedIds([])).toEqual([])
})
})
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
+21
View File
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
return p.count ?? p.derivedCount
}
/**
* Plant ids a garden has been planted with, most recent first and de-duplicated —
* the "what have I actually been planting here" quick list (#100). Ordered by
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
* same-day tie. The caller resolves the ids to plants against the catalog.
*/
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
const sorted = [...plantings].sort(
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
)
const seen = new Set<number>()
const ids: number[] = []
for (const p of sorted) {
if (!seen.has(p.plantId)) {
seen.add(p.plantId)
ids.push(p.plantId)
}
}
return ids
}
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */
+70 -1
View File
@@ -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.