Plant catalog UI: /plants page + PlantPicker (#13)
Build image / build-and-push (push) Successful in 15s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #32.
This commit is contained in:
2026-07-19 02:18:12 +00:00
committed by steve
parent 293532f021
commit e4505ed9a7
10 changed files with 848 additions and 3 deletions
+122
View File
@@ -0,0 +1,122 @@
// Plant catalog data layer: zod-validated shapes for /api/v1/plants plus the
// react-query hooks the /plants page and PlantPicker use. Built-ins (ownerId
// null) are seeded and read-only; a user's own rows are editable. "Cloning" a
// built-in is just a create of a copy (no special endpoint).
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
export const CATEGORY_LABELS: Record<PlantCategory, string> = {
vegetable: 'Vegetable',
herb: 'Herb',
flower: 'Flower',
fruit: 'Fruit',
tree_shrub: 'Tree / shrub',
cover: 'Cover crop',
}
const plantCategorySchema = z.enum(PLANT_CATEGORIES)
export const plantSchema = z.object({
id: z.number(),
// Absent (built-in) or a user id. The API omits ownerId for built-ins.
ownerId: z.number().nullable().optional(),
name: z.string(),
category: plantCategorySchema,
spacingCm: z.number(),
color: z.string(),
icon: z.string(),
daysToMaturity: z.number().nullable().optional(),
notes: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type Plant = z.infer<typeof plantSchema>
/** A built-in (seeded, read-only) plant has no owner. */
export function isBuiltin(p: Plant): boolean {
return p.ownerId == null
}
/** Category filter value: a specific category, or 'all'. Shared by the catalog
* page and the PlantPicker so their chip rows and filtering stay in sync. */
export type CategoryFilter = PlantCategory | 'all'
/** Filter a plant list by a name query (case-insensitive substring) and
* category. The single source of truth for both the /plants grid and the
* PlantPicker list. */
export function filterPlants(plants: Plant[], query: string, category: CategoryFilter): Plant[] {
const q = query.trim().toLowerCase()
return plants.filter(
(p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)),
)
}
const plantsKey = ['plants'] as const
export const plantsQueryOptions = queryOptions({
queryKey: plantsKey,
queryFn: async (): Promise<Plant[]> => z.array(plantSchema).parse(await api.get('/plants')),
})
export function usePlants() {
return useQuery(plantsQueryOptions)
}
export interface PlantInput {
name: string
category: PlantCategory
spacingCm: number
color: string
icon: string
daysToMaturity: number | null
notes: string
}
export function useCreatePlant() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: PlantInput): Promise<Plant> => plantSchema.parse(await api.post('/plants', input)),
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
})
}
export interface PlantUpdate extends PlantInput {
id: number
version: number
}
export function useUpdatePlant() {
const qc = useQueryClient()
return useMutation({
// id/version travel with the mutation variables so a single hook serves any row.
mutationFn: async ({ id, ...body }: PlantUpdate): Promise<Plant> =>
plantSchema.parse(await api.patch(`/plants/${id}`, body)),
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
})
}
export function useDeletePlant() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/plants/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
})
}
/**
* 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 conflictPlant(err: unknown): Plant | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const parsed = plantSchema.safeParse((err.body as { current?: unknown }).current)
if (parsed.success) return parsed.data
}
return null
}
+28 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'
import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units'
import {
cmFromFtIn,
cmFromMeters,
cmFromSpacing,
displayFromCm,
formatCm,
formatDimensions,
formatSpacing,
spacingFromCm,
} from './units'
describe('cm conversions', () => {
it('feet+inches → whole cm', () => {
@@ -42,3 +51,21 @@ describe('formatCm', () => {
expect(formatDimensions(122, 244, 'imperial')).toBe('4 0″ × 8 0″')
})
})
describe('plant spacing (small scale)', () => {
it('formats cm in metric, whole inches in imperial', () => {
expect(formatSpacing(15, 'metric')).toBe('15 cm')
expect(formatSpacing(15, 'imperial')).toBe('6″') // 15/2.54 = 5.9 → 6
expect(formatSpacing(30, 'imperial')).toBe('12″')
})
it('parses an entered value back to whole cm', () => {
expect(cmFromSpacing(15, 'metric')).toBe(15)
expect(cmFromSpacing(6, 'imperial')).toBe(15) // 6in = 15.24 → 15
})
it('prefills an input from cm', () => {
expect(spacingFromCm(15, 'metric')).toBe(15)
expect(spacingFromCm(30, 'imperial')).toBe(11.8) // 30/2.54 = 11.81 → 11.8
})
})
+26
View File
@@ -68,3 +68,29 @@ export function formatDimensions(widthCm: number, heightCm: number, unit: UnitPr
export function dimensionUnitLabel(unit: UnitPref): string {
return unit === 'imperial' ? 'ft' : 'm'
}
// --- Plant spacing (small scale) -------------------------------------------
// Plant spacing is a handful of centimeters, so meters/feet read poorly here;
// metric shows cm and imperial shows whole inches.
/** Centimeters → a spacing string in the given unit (e.g. "15 cm" or "6″"). */
export function formatSpacing(cm: number, unit: UnitPref): string {
if (unit === 'imperial') return `${Math.round(cm / CM_PER_INCH)}`
return `${Math.round(cm)} cm`
}
/** A spacing value typed in the given unit (cm, or inches) → whole centimeters. */
export function cmFromSpacing(value: number, unit: UnitPref): number {
return unit === 'imperial' ? Math.round(value * CM_PER_INCH) : Math.round(value)
}
/** Centimeters → a spacing number in the given unit, for prefilling an input.
* Inches to 0.1 so cm-quantization doesn't show through. */
export function spacingFromCm(cm: number, unit: UnitPref): number {
return unit === 'imperial' ? Math.round((cm / CM_PER_INCH) * 10) / 10 : Math.round(cm)
}
/** The spacing input-field unit label. */
export function spacingUnitLabel(unit: UnitPref): string {
return unit === 'imperial' ? 'in' : 'cm'
}