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
+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. */