Files
pansy/web/src/lib/objects.ts
T
steve 8c5ddb21ec
Build image / build-and-push (push) Successful in 17s
Season view: filter the editor to a year (#54) (#66)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:47:34 +00:00

457 lines
18 KiB
TypeScript

// 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<typeof serverObjectSchema>
export const fullGardenSchema = z.object({
garden: gardenSchema,
objects: z.array(serverObjectSchema),
plantings: z.array(serverPlantingSchema),
plants: z.array(plantSchema),
})
export type FullGarden = z.infer<typeof fullGardenSchema>
/** 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<FullGarden> => 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<FullGarden> =>
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<number[]> =>
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<ServerObject> =>
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<ServerObject> =>
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<FullGarden>(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<FullGarden>(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
}
export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
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<ServerPlanting> =>
serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(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<ServerPlanting> => {
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<FullGarden>(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<FullGarden>(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
}