#50 stores provenance and lots; this is where you actually use them. That question gets asked twice — at the seed rack in January, and standing over a bed in May — so the answer shows up in both places. On the plant card: vendor and a source link, plus a seed count that sits with the actions rather than in the body, because it's what you scan down a list of twenty packets deciding what to order. Expanding shows each lot separately with vendor, dates, germination, cost, and its own remaining count — two lots of one variety are two rows with independent numbers, which is the whole reason inventory lives on the purchase rather than on the plant. State is encoded in form as well as number: a coloured chip that says "low" or "empty" in words, and a proportional bar. A bare number doesn't survive being skimmed, and skimming is the actual use. "Over-planted" is a state of its own rather than being clamped to zero — planting more than you recorded buying happens, and hiding it defeats the point of tracking. In the plant picker, remaining shows next to the plant you're about to place, and a plant with several lots asks which packet it came out of. One lot auto-attributes and zero lots stays silent: forcing that question on every placement is how a nicety becomes an obstacle. The armed lot rides along with the armed plant, so repeat placement keeps attributing without asking again. External URLs are re-checked client-side before being rendered as links, and carry rel="noopener noreferrer". The server already scheme-checks them, but a link is rendered from whatever the client was handed — an old row, a different server version, a tampered response — and "the backend validated it" is not a reason to hand javascript: to an anchor tag. Retiring a lot says plainly that the plantings survive it, since "will this wipe my garden" is the reasonable fear. Closes #51 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
459 lines
18 KiB
TypeScript
459 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
|
|
/** 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<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
|
|
}
|