Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
457 lines
18 KiB
TypeScript
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
|
|
}
|