Files
pansy/web/src/lib/plantings.ts
T
steveandClaude Opus 4.8 97c8a36bac
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
Plants mode: recent-planted quick strip + clump/rows fill (#100)
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
2026-07-22 01:49:24 -04:00

92 lines
3.0 KiB
TypeScript

// Plantings ("plops") data layer: the zod shape for a /full planting plus the
// EditorPlanting the canvas renders. Optimistic create/update/remove mutations
// live in objects.ts alongside the FullGarden cache they patch (a plop is part
// of the one-shot editor payload).
import { z } from 'zod'
export const serverPlantingSchema = z.object({
id: z.number(),
objectId: z.number(),
plantId: z.number(),
xCm: z.number(),
yCm: z.number(),
radiusCm: z.number(),
count: z.number().nullable().optional(), // null/absent = derived
label: z.string().nullable().optional(),
plantedAt: z.string().nullable().optional(),
removedAt: z.string().nullable().optional(),
derivedCount: z.number(), // computed server-side from area / spacing²
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type ServerPlanting = z.infer<typeof serverPlantingSchema>
/** The plop shape the canvas renders. x/y/radius are in the parent object's
* local frame (origin at object center), so a plop tracks its object. */
export interface EditorPlanting {
id: number
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count: number | null
derivedCount: number
label: string | null
plantedAt: string | null
version: number
}
export function toEditorPlanting(p: ServerPlanting): EditorPlanting {
return {
id: p.id,
objectId: p.objectId,
plantId: p.plantId,
xCm: p.xCm,
yCm: p.yCm,
radiusCm: p.radiusCm,
count: p.count ?? null,
derivedCount: p.derivedCount,
label: p.label ?? null,
plantedAt: p.plantedAt ?? null,
version: p.version,
}
}
/** The count a plop shows: its explicit override, else the derived value. */
export function effectiveCount(p: { count: number | null; derivedCount: number }): 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. */
export function computeDerivedCount(radiusCm: number, spacingCm: number): number {
if (radiusCm <= 0 || spacingCm <= 0) return 1
const n = Math.round((Math.PI * radiusCm * radiusCm) / (spacingCm * spacingCm))
return Math.max(1, Math.min(1_000_000, n))
}