// Garden objects data layer: the /full editor payload plus optimistic // create/update/delete mutations. Updates apply to the cache immediately and // PATCH with the row version; a 409 rolls back to the server's current row and // toasts (see DESIGN § Sync). import { useCallback } from 'react' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { z } from 'zod' import { ApiError, api } from './api' import { gardenSchema } from './gardens' import { plantSchema, type Plant } from './plants' import { serverPlantingSchema, type ServerPlanting } from './plantings' import { toast } from '@/components/ui/toast' import type { EditorObject, ObjectShapeKind } from '@/editor/types' const shapeSchema = z.enum(['rect', 'circle', 'polygon']) export const serverObjectSchema = z.object({ id: z.number(), gardenId: z.number(), kind: z.string(), name: z.string(), shape: shapeSchema, xCm: z.number(), yCm: z.number(), widthCm: z.number(), heightCm: z.number(), rotationDeg: z.number(), zIndex: z.number(), plantable: z.boolean(), color: z.string().nullable().optional(), props: z.string().nullable().optional(), gridSizeCm: z.number(), snapToGrid: z.boolean(), notes: z.string(), version: z.number(), }) export type ServerObject = z.infer export const fullGardenSchema = z.object({ garden: gardenSchema, objects: z.array(serverObjectSchema), plantings: z.array(serverPlantingSchema), plants: z.array(plantSchema), }) export type FullGarden = z.infer /** Map a server object to the canvas's EditorObject (polygon never reaches the * v1 editor; coerce it to rect defensively). */ export function toEditorObject(o: ServerObject): EditorObject { const shape: ObjectShapeKind = o.shape === 'circle' ? 'circle' : 'rect' return { id: o.id, kind: o.kind, name: o.name, shape, xCm: o.xCm, yCm: o.yCm, widthCm: o.widthCm, heightCm: o.heightCm, rotationDeg: o.rotationDeg, zIndex: o.zIndex, color: o.color ?? null, gridSizeCm: o.gridSizeCm, snapToGrid: o.snapToGrid, notes: o.notes, plantable: o.plantable, version: o.version, } } // Exported so anything that changes a garden's contents from outside this file // — undo, in particular — can invalidate the editor payload and make the canvas // redraw. export const gardenFullKey = (gardenId: number) => ['garden-full', gardenId] as const const fullKey = gardenFullKey export function gardenFullQueryOptions(gardenId: number) { return queryOptions({ queryKey: fullKey(gardenId), queryFn: async (): Promise => fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`)), }) } export function useGardenFull(gardenId: number) { return useQuery(gardenFullQueryOptions(gardenId)) } // A past season is a SEPARATE query under its own key, deliberately. The // optimistic mutations below all patch gardenFullKey(gardenId); if a year were // folded into that key they would write into whichever season happened to be on // screen. Season views are read-only, so they never need that machinery — and // keeping them out of it means they can't accidentally join it. export const gardenSeasonKey = (gardenId: number, year: number) => ['garden-season', gardenId, year] as const /** A garden as it stood in a given calendar year: every plop whose time in the * ground overlapped it, including ones since removed. Read-only. */ export function useGardenSeason(gardenId: number, year: number | null) { return useQuery({ queryKey: gardenSeasonKey(gardenId, year ?? 0), enabled: year !== null, queryFn: async (): Promise => fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`, { params: { year } })), }) } const yearsSchema = z.object({ years: z.array(z.number().int()) }) /** The years this garden holds planting data for, newest first, always * including the current one. Offering only years with data keeps the selector * from inviting a typo that produces a confidently empty garden. */ export function useGardenYears(gardenId: number) { return useQuery({ queryKey: ['garden-years', gardenId] as const, queryFn: async (): Promise => yearsSchema.parse(await api.get(`/gardens/${gardenId}/years`)).years, }) } /** * Ensure a plant is present in the /full cache's `plants` list. The full payload * carries only the garden's *referenced* plants (ListReferencedPlants), so a * plant chosen from the full catalog but not yet placed here is absent — its * plops would render without an icon/color until the next refetch. Call this * when arming/placing such a plant so it renders immediately. No-op if present. */ export function useEnsurePlantInFull(gardenId: number) { const qc = useQueryClient() return useCallback( (plant: Plant) => patchFullCache(qc, gardenId, (full) => full.plants.some((p) => p.id === plant.id) ? full : { ...full, plants: [...full.plants, plant] }, ), [qc, gardenId], ) } // --- mutations ------------------------------------------------------------- export interface ObjectCreate { kind: string name?: string shape?: ObjectShapeKind xCm: number yCm: number widthCm: number heightCm: number rotationDeg?: number zIndex?: number plantable?: boolean color?: string | null gridSizeCm?: number snapToGrid?: boolean } export function useCreateObject(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async (input: ObjectCreate): Promise => serverObjectSchema.parse(await api.post(`/gardens/${gardenId}/objects`, input)), onSuccess: (created) => { // Splice the created object into the cache (avoids a full refetch flash). patchFullCache(qc, gardenId, (full) => ({ ...full, objects: [...full.objects, created] })) }, onError: (err) => toast.error(objectErrorMessage(err, 'Could not add that object.')), }) } /** Fields the editor patches. version is required for the optimistic guard. */ export interface ObjectPatch { id: number version: number name?: string xCm?: number yCm?: number widthCm?: number heightCm?: number rotationDeg?: number zIndex?: number plantable?: boolean color?: string | null gridSizeCm?: number snapToGrid?: boolean notes?: string } export function useUpdateObject(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ id, ...body }: ObjectPatch): Promise => serverObjectSchema.parse(await api.patch(`/objects/${id}`, body)), // Apply the change to the cache up front so the object doesn't snap back // while the request is in flight. Snapshot for rollback. onMutate: async (patch) => { await qc.cancelQueries({ queryKey: fullKey(gardenId) }) const prev = qc.getQueryData(fullKey(gardenId)) patchFullCache(qc, gardenId, (full) => ({ ...full, objects: full.objects.map((o) => (o.id === patch.id ? applyServerPatch(o, patch) : o)), })) return { prev } }, onSuccess: (updated) => { patchFullCache(qc, gardenId, (full) => ({ ...full, objects: full.objects.map((o) => (o.id === updated.id ? updated : o)), })) }, onError: (err, _patch, ctx) => { const current = conflictObject(err) if (current) { // Someone else changed it: adopt the server's row and tell the user. patchFullCache(qc, gardenId, (full) => ({ ...full, objects: full.objects.map((o) => (o.id === current.id ? current : o)), })) toast.error('That object was updated elsewhere — your change was rolled back.') } else if (ctx?.prev) { qc.setQueryData(fullKey(gardenId), ctx.prev) toast.error(objectErrorMessage(err, 'Could not save that change.')) } }, // No onSettled refetch: onSuccess already writes the authoritative server // row and onError reconciles. Invalidating here would fire a full-garden GET // after every PATCH — a request storm during drags, and a refetch race that // can clobber a fast follow-up edit. }) } export function useDeleteObject(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: (id: number) => api.delete(`/objects/${id}`), onMutate: async (id) => { await qc.cancelQueries({ queryKey: fullKey(gardenId) }) const prev = qc.getQueryData(fullKey(gardenId)) patchFullCache(qc, gardenId, (full) => ({ ...full, objects: full.objects.filter((o) => o.id !== id) })) return { prev } }, onError: (err, _id, ctx) => { if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev) toast.error(objectErrorMessage(err, 'Could not delete that object.')) }, onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), }) } // --- planting (plop) mutations --------------------------------------------- // Plops are part of the FullGarden cache, so these patch the same query as the // object mutations, with the same optimistic + 409-rollback contract. export interface PlantingCreate { objectId: number plantId: number xCm: number yCm: number radiusCm: number count?: number | null label?: string | null /** Attributes the plop to a purchase, so that lot can report what's left. */ seedLotId?: number } export function useCreatePlanting(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise => serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)), onSuccess: (created) => { patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] })) }, onError: (err) => toast.error(objectErrorMessage(err, 'Could not place that plant.')), }) } /** Fields the plop editor patches. version is required for the optimistic guard. */ export interface PlantingPatch { id: number version: number plantId?: number xCm?: number yCm?: number radiusCm?: number count?: number | null label?: string | null plantedAt?: string | null removedAt?: string | null } export function useUpdatePlanting(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ id, ...body }: PlantingPatch): Promise => serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)), onMutate: async (patch) => { await qc.cancelQueries({ queryKey: fullKey(gardenId) }) const prev = qc.getQueryData(fullKey(gardenId)) patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.map((p) => (p.id === patch.id ? applyPlantingPatch(p, patch) : p)), })) return { prev } }, onSuccess: (updated) => { patchFullCache(qc, gardenId, (full) => ({ ...full, // A soft-remove (removedAt set) drops the plop from the active list; // otherwise write the authoritative row (carries the fresh derivedCount). plantings: updated.removedAt ? full.plantings.filter((p) => p.id !== updated.id) : full.plantings.map((p) => (p.id === updated.id ? updated : p)), })) }, onError: (err, _patch, ctx) => { const current = conflictPlanting(err) if (current) { patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.map((p) => (p.id === current.id ? current : p)), })) toast.error('That plant was updated elsewhere — your change was rolled back.') } else if (ctx?.prev) { qc.setQueryData(fullKey(gardenId), ctx.prev) toast.error(objectErrorMessage(err, 'Could not save that change.')) } }, }) } /** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes; * a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates * once at the end. Pass the object's active plops (id + current version). */ export function useClearObject(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async (plops: { id: number; version: number }[]) => { const today = new Date().toISOString().slice(0, 10) // allSettled, not all: a partial failure still soft-removed some rows // server-side, so we must reconcile the cache rather than roll everything // back. Report how many failed. const results = await Promise.allSettled( plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })), ) const failed = results.filter((r) => r.status === 'rejected').length if (failed > 0) { throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`) } }, // Reconcile on success OR partial failure, so the cache matches the server. onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'), }) } /** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves * the active list. Distinct from a hard delete. */ export function useRemovePlanting(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async ({ id, version }: { id: number; version: number }): Promise => { const today = new Date().toISOString().slice(0, 10) return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version })) }, onMutate: async ({ id }) => { await qc.cancelQueries({ queryKey: fullKey(gardenId) }) const prev = qc.getQueryData(fullKey(gardenId)) patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.filter((p) => p.id !== id) })) return { prev } }, onError: (err, _vars, ctx) => { if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev) // On a 409, adopt the server's current row so the plop reappears with a // fresh version — otherwise a retry keeps 409-ing on the stale version. const current = conflictPlanting(err) if (current) { patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.map((p) => (p.id === current.id ? current : p)), })) toast.error('That plant was updated elsewhere — try removing it again.') } else { toast.error(objectErrorMessage(err, 'Could not remove that plant.')) } }, }) } // --- helpers --------------------------------------------------------------- function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) { qc.setQueryData(fullKey(gardenId), (full) => (full ? fn(full) : full)) } /** Apply a plop patch onto a cached row for the optimistic pass. version is * bumped to mirror the server's post-commit increment (as applyServerPatch does * for objects), so a fast follow-up edit doesn't self-conflict. derivedCount is * left as-is here; the editor recomputes it live from the plant's spacing and * onSuccess writes the authoritative value. */ function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlanting { return { ...p, version: p.version + 1, ...(patch.plantId !== undefined ? { plantId: patch.plantId } : {}), ...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}), ...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}), ...(patch.radiusCm !== undefined ? { radiusCm: patch.radiusCm } : {}), ...(patch.count !== undefined ? { count: patch.count } : {}), ...(patch.label !== undefined ? { label: patch.label } : {}), ...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}), ...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}), } } /** The current server plop from a 409 conflict body, or null. */ function conflictPlanting(err: unknown): ServerPlanting | null { if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') { const parsed = serverPlantingSchema.safeParse((err.body as { current?: unknown }).current) if (parsed.success) return parsed.data } return null } /** Apply a patch onto a cached server object for the optimistic pass. The * version is bumped to mirror the server's post-commit increment, so a second * edit started before the first PATCH resolves reads the next version instead * of re-sending the stale one and self-conflicting (a spurious 409). */ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject { return { ...o, version: o.version + 1, ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}), ...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}), ...(patch.widthCm !== undefined ? { widthCm: patch.widthCm } : {}), ...(patch.heightCm !== undefined ? { heightCm: patch.heightCm } : {}), ...(patch.rotationDeg !== undefined ? { rotationDeg: patch.rotationDeg } : {}), ...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}), ...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}), ...(patch.gridSizeCm !== undefined ? { gridSizeCm: patch.gridSizeCm } : {}), ...(patch.snapToGrid !== undefined ? { snapToGrid: patch.snapToGrid } : {}), ...(patch.notes !== undefined ? { notes: patch.notes } : {}), } } /** The current server row from a 409 conflict body, or null. */ function conflictObject(err: unknown): ServerObject | null { if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') { const parsed = serverObjectSchema.safeParse((err.body as { current?: unknown }).current) if (parsed.success) return parsed.data } return null } function objectErrorMessage(err: unknown, fallback: string): string { return err instanceof ApiError ? err.message : fallback }