Plants mode: recent-planted quick strip + clump/rows fill (#100) #112

Merged
steve merged 2 commits from feat/plants-mode-recent into main 2026-07-22 05:58:39 +00:00
8 changed files with 251 additions and 22 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag, and the mobile `mode`.
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (the seed tray, shown once a bed is focused; focusing a bed puts you in this mode), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (the last two open the rail sheet; Assistant is hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. Desktop keeps its side-column layout (the mode bar is `md:hidden`) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
+41
View File
@@ -0,0 +1,41 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import type { Plant } from '@/lib/plants'
/**
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
* `rounded` is false when a caller (the tray) attaches a trailing control and
* needs a flat right edge.
*/
export function PlantChip({
plant,
active,
onArm,
rounded = true,
}: {
plant: Plant
active: boolean
onArm: (plant: Plant) => void
rounded?: boolean
}) {
return (
<button
type="button"
onClick={() => onArm(plant)}
aria-pressed={active}
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
active
? 'border-accent bg-accent/10 text-accent-strong'
: 'border-border bg-surface text-fg hover:border-accent',
)}
>
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate">{plant.name}</span>
</button>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { PlantChip } from '@/components/plants/PlantChip'
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) => (
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
))}
</div>
)
Review

🟡 Chip markup duplicated from SeedTray.tsx instead of shared

maintainability · flagged by 2 models

  • web/src/editor/RecentPlants.tsx:28-44 duplicates the chip-rendering logic from web/src/editor/SeedTray.tsx:29-45 almost verbatim: same PlantIcon sizing class (h-5 w-5 rounded-full text-[0.65rem]), the same truncated-name span (max-w-[6rem] truncate), the same active-derived title/aria-pressed logic, and the same active/inactive border-and-background classes (border-accent bg-accent/10 text-accent-strong vs border-border bg-surface text-fg). The two have already begun to drift —…

🪰 Gadfly · advisory

🟡 **Chip markup duplicated from SeedTray.tsx instead of shared** _maintainability · flagged by 2 models_ - `web/src/editor/RecentPlants.tsx:28-44` duplicates the chip-rendering logic from `web/src/editor/SeedTray.tsx:29-45` almost verbatim: same `PlantIcon` sizing class (`h-5 w-5 rounded-full text-[0.65rem]`), the same truncated-name span (`max-w-[6rem] truncate`), the same `active`-derived title/aria-pressed logic, and the same active/inactive border-and-background classes (`border-accent bg-accent/10 text-accent-strong` vs `border-border bg-surface text-fg`). The two have already begun to drift —… <sub>🪰 Gadfly · advisory</sub>
}
+10 -19
View File
@@ -1,5 +1,5 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
@@ -26,28 +26,19 @@ export function SeedTray({
{trayPlants.map((p) => {
const active = p.id === armedPlantId
return (
<span
key={p.id}
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
)}
>
<button
type="button"
onClick={() => onArm(p)}
aria-pressed={active}
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
</button>
<span key={p.id} className="inline-flex items-center">
{/* Flat right edge so the remove button below seams into one pill. */}
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
<button
type="button"
onClick={() => onRemove(p.id)}
aria-label={`Remove ${p.name} from tray`}
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
className={cn(
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
active
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
: 'border-border bg-surface text-muted hover:text-fg',
)}
>
</button>
+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[] {
Review

🟡 recentlyPlantedIds sorts entire plantings array when only top-8 unique IDs are needed

performance · flagged by 1 model

  • web/src/lib/plantings.ts:69recentlyPlantedIds copies and sorts the entire plantings array (O(n log n)) even though callers only need the top 8 unique plant IDs. After a large "rows" fill, a garden can have hundreds or thousands of plops; every subsequent /full refetch (triggered by any mutation) re-sorts the full list to extract at most 8 items. A single-pass scan tracking the most recent planting per plantId (O(n)), followed by sorting only the unique entries, would scale lin…

🪰 Gadfly · advisory

🟡 **recentlyPlantedIds sorts entire plantings array when only top-8 unique IDs are needed** _performance · flagged by 1 model_ - **`web/src/lib/plantings.ts:69`** — `recentlyPlantedIds` copies and sorts the entire `plantings` array (O(n log n)) even though callers only need the top 8 unique plant IDs. After a large "rows" fill, a garden can have hundreds or thousands of plops; every subsequent `/full` refetch (triggered by any mutation) re-sorts the full list to extract at most 8 items. A single-pass scan tracking the most recent planting per `plantId` (O(n)), followed by sorting only the unique entries, would scale lin… <sub>🪰 Gadfly · advisory</sub>
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. */
+74 -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'
@@ -42,6 +45,10 @@ import { ApiError } from '@/lib/api'
const routeApi = getRouteApi('/gardens/$gardenId')
// How many recently-planted chips the Plants-mode quick strip shows — a working
// set, not the whole history; the picker covers the long tail.
const RECENT_PLANTS_MAX = 8
export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
const gid = Number(gardenId)
@@ -88,6 +95,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 +117,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
Review

🟡 Magic number 8 for recent plants limit

maintainability · flagged by 3 models

  • web/src/pages/GardenEditorPage.tsx:120 — The .slice(0, 8) in recentPlants is an unexplained magic number. Extract it to a named constant (e.g. MAX_RECENT_PLANTS = 8) so the intent is self-documenting and easier to change.

🪰 Gadfly · advisory

🟡 **Magic number 8 for recent plants limit** _maintainability · flagged by 3 models_ - `web/src/pages/GardenEditorPage.tsx:120` — The `.slice(0, 8)` in `recentPlants` is an unexplained magic number. Extract it to a named constant (e.g. `MAX_RECENT_PLANTS = 8`) so the intent is self-documenting and easier to change. <sub>🪰 Gadfly · advisory</sub>
// in Plants mode, so re-planting "more of the same" doesn't need the picker.
const recentPlants = useMemo(
() =>
recentlyPlantedIds(plantings)
.slice(0, RECENT_PLANTS_MAX)
.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 +412,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 +599,7 @@ export function GardenEditorPage() {
{canEdit &&
(focusedObject.plantable ? (
<PlantPlacementTools
recentPlants={recentPlants}
trayPlants={trayPlants}
armedPlant={armedPlant}
onArm={armPlant}
@@ -580,6 +608,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 +641,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 +650,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 +727,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 +736,10 @@ function PlantPlacementTools({
onDisarm,
focusedPlopCount,
onClear,
onFill,
filling,
}: {
recentPlants: Plant[]
trayPlants: Plant[]
armedPlant: Plant | null
onArm: (p: Plant, lot?: SeedLot) => void
@@ -711,9 +748,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 +761,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 +780,38 @@ function PlantPlacementTools({
)
}
// The fill control (#100 / #77): choose a layout, then fill the whole bed with
Review

🟡 FillControl should be extracted to its own file like RecentPlants

maintainability · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:783FillControl is a standalone, reusable UI component (≈25 lines) defined inside an already-large page file (868 lines). The PR correctly extracted RecentPlants to its own file; FillControl should follow the same pattern and live in web/src/editor/FillControl.tsx to keep the page file from growing and to match the codebase’s component-extraction convention.

🪰 Gadfly · advisory

🟡 **FillControl should be extracted to its own file like RecentPlants** _maintainability · flagged by 1 model_ - **`web/src/pages/GardenEditorPage.tsx:783`** — `FillControl` is a standalone, reusable UI component (≈25 lines) defined inside an already-large page file (868 lines). The PR correctly extracted `RecentPlants` to its own file; `FillControl` should follow the same pattern and live in `web/src/editor/FillControl.tsx` to keep the page file from growing and to match the codebase’s component-extraction convention. <sub>🪰 Gadfly · advisory</sub>
// 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.