Merge pull request 'Plants mode: recent-planted quick strip + clump/rows fill (#100)' (#112) from feat/plants-mode-recent into main
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
This commit was merged in pull request #112.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+10
-19
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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²))', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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