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
92 lines
3.0 KiB
TypeScript
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))
|
|
}
|