diff --git a/DESIGN.md b/DESIGN.md index aa669a1..0920f13 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/web/src/editor/RecentPlants.tsx b/web/src/editor/RecentPlants.tsx new file mode 100644 index 0000000..6b4f41a --- /dev/null +++ b/web/src/editor/RecentPlants.tsx @@ -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 ( +
+ Recent + {plants.map((p) => { + const active = p.id === armedPlantId + return ( + + ) + })} +
+ ) +} diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index 44dbf30..9c0a4e0 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -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 => { + 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). * diff --git a/web/src/lib/plantings.test.ts b/web/src/lib/plantings.test.ts index a8497d8..f86afca 100644 --- a/web/src/lib/plantings.test.ts +++ b/web/src/lib/plantings.test.ts @@ -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 { + 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²))', () => { diff --git a/web/src/lib/plantings.ts b/web/src/lib/plantings.ts index a1d0261..d237965 100644 --- a/web/src/lib/plantings.ts +++ b/web/src/lib/plantings.ts @@ -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() + 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. */ diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 5816421..3d3157d 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -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 ? ( setArmedPlant(null)} focusedPlopCount={focusedPlops.length} onClear={() => setClearing(true)} + onFill={fillBed} + filling={fillObject.isPending} /> ) : ( Not plantable @@ -611,6 +637,7 @@ export function GardenEditorPage() { focusedObject.plantable ? (
setArmedPlant(null)} focusedPlopCount={focusedPlops.length} onClear={() => setClearing(true)} + onFill={fillBed} + filling={fillObject.isPending} /> + ))} + + + + ) +} + // 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.