Add gardens UI: list page + create/edit/delete (#8)
Build image / build-and-push (push) Successful in 13s
Gadfly review (reusable) / review (pull_request) Successful in 7m21s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m21s

The /gardens page is now home base, backed by the #7 API.

- lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions
  (metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib
  consolidates.
- lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/
  useUpdateGarden/useDeleteGarden (mutations invalidate the list) and
  conflictGarden() to pull the fresh row out of a 409.
- GardensPage: loading/empty/error states; empty state prompts a first
  garden; responsive card grid (single column on phones).
- GardenCard: name, dimensions formatted per the garden's unit, notes
  preview; body links into /gardens/:id, edit/delete kept out of the link.
- GardenFormModal: create/edit with a unit selector; dimensions are entered
  in the chosen unit and converted to cm (switching units converts the
  current values). A 409 rebases the form onto the server's fresh row.
- DeleteGardenModal: confirmation before removing.
- ui/: Modal (Escape/backdrop close), TextArea, Select.

Verified in a real browser against the embedded binary: create appears
without reload; edit persists (version 2), form prefilled from stored cm
converted back to the garden's unit; delete confirms then removes -> empty
state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows
"4' 0" x 8' 0"". tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 18:48:47 -04:00
co-authored by Claude Opus 4.8
parent 67c48162c3
commit e0a1522608
9 changed files with 572 additions and 2 deletions
+85
View File
@@ -0,0 +1,85 @@
// Gardens data layer: zod-validated shapes for /api/v1/gardens plus the
// react-query hooks the /gardens page uses.
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import type { UnitPref } from './units'
const unitPrefSchema: z.ZodType<UnitPref> = z.enum(['metric', 'imperial'])
export const gardenSchema = z.object({
id: z.number(),
ownerId: z.number(),
name: z.string(),
widthCm: z.number(),
heightCm: z.number(),
unitPref: unitPrefSchema,
notes: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type Garden = z.infer<typeof gardenSchema>
const gardensKey = ['gardens'] as const
export const gardensQueryOptions = queryOptions({
queryKey: gardensKey,
queryFn: async (): Promise<Garden[]> => z.array(gardenSchema).parse(await api.get('/gardens')),
})
export function useGardens() {
return useQuery(gardensQueryOptions)
}
export interface GardenInput {
name: string
widthCm: number
heightCm: number
unitPref: UnitPref
notes: string
}
export function useCreateGarden() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: GardenInput): Promise<Garden> =>
gardenSchema.parse(await api.post('/gardens', input)),
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
})
}
export interface GardenUpdate extends GardenInput {
version: number
}
export function useUpdateGarden(id: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: GardenUpdate): Promise<Garden> =>
gardenSchema.parse(await api.patch(`/gardens/${id}`, input)),
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
})
}
export function useDeleteGarden() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/gardens/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: gardensKey }),
})
}
/**
* If err is a 409 version conflict, return the fresh server row it carries
* (under `current`), so a form can rebase onto it; otherwise null.
*/
export function conflictGarden(err: unknown): Garden | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const current = (err.body as { current?: unknown }).current
const parsed = gardenSchema.safeParse(current)
if (parsed.success) return parsed.data
}
return null
}