- web/src/lib/plants.ts: zod-validated Plant schema + react-query hooks (list/create/update/delete) over the #12 endpoints, with the category enum, labels, isBuiltin helper, and a 409 conflict extractor. - /plants page: searchable, category-chip-filtered grid of PlantCard; built-ins badged and offer only Duplicate; own plants add Edit/Delete. A local metric/imperial spacing toggle (no per-user unit pref exists server-side). - PlantFormModal: create/edit/duplicate a custom plant (name, category, spacing, color, emoji icon, days-to-maturity, notes), unit-aware spacing, 409 rebase. - DeletePlantModal: confirm + surface the server's PLANT_IN_USE refusal inline. - editor/PlantPicker.tsx: reusable search-first chooser with recently-used (localStorage) and category filter, bottom-sheet on mobile / panel on desktop, onSelect callback. Demoed via the /plants "Try the picker" button until #15 consumes it. - units.ts: small-scale spacing helpers (cm/inches) + tests. tsc --noEmit clean; 20/20 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
// Unit conversion + display for garden dimensions. The API and DB are metric
|
||
// only (centimeters); imperial is purely a display/entry concern here. A minimal
|
||
// helper set for #8 — #9's geometry lib consolidates/extends it.
|
||
|
||
export type UnitPref = 'metric' | 'imperial'
|
||
|
||
const CM_PER_INCH = 2.54
|
||
const CM_PER_METER = 100
|
||
const INCHES_PER_FOOT = 12
|
||
|
||
// Mirrors the server's garden dimension bounds (service/gardens.go): [1cm, 100m].
|
||
export const MIN_DIMENSION_CM = 1
|
||
export const MAX_DIMENSION_CM = 10_000
|
||
|
||
/** Whether a centimeter dimension is within the server's accepted range. */
|
||
export function isValidDimensionCm(cm: number): boolean {
|
||
return Number.isFinite(cm) && cm >= MIN_DIMENSION_CM && cm <= MAX_DIMENSION_CM
|
||
}
|
||
|
||
/** Feet (+ optional inches) → whole centimeters. */
|
||
export function cmFromFtIn(feet: number, inches = 0): number {
|
||
return Math.round((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
|
||
}
|
||
|
||
/** Meters → whole centimeters. */
|
||
export function cmFromMeters(meters: number): number {
|
||
return Math.round(meters * CM_PER_METER)
|
||
}
|
||
|
||
/** A value typed in the given unit (meters, or feet) → centimeters. */
|
||
export function cmFromDisplay(value: number, unit: UnitPref): number {
|
||
return unit === 'imperial' ? cmFromFtIn(value) : cmFromMeters(value)
|
||
}
|
||
|
||
/** Centimeters → a number in the given unit, for prefilling an input field.
|
||
* Rounded so cm-quantization doesn't show through (e.g. 244cm shows as 8 ft,
|
||
* not 8.01): meters to the cm (2 dp), feet to 0.1 ft. */
|
||
export function displayFromCm(cm: number, unit: UnitPref): number {
|
||
if (unit === 'imperial') {
|
||
const feet = cm / CM_PER_INCH / INCHES_PER_FOOT
|
||
return Math.round(feet * 10) / 10
|
||
}
|
||
return Math.round((cm / CM_PER_METER) * 100) / 100
|
||
}
|
||
|
||
/** Centimeters → a human string in the given unit (e.g. "1.22 m" or "4′ 0″"). */
|
||
export function formatCm(cm: number, unit: UnitPref): string {
|
||
if (unit === 'imperial') {
|
||
const totalInches = cm / CM_PER_INCH
|
||
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
|
||
let inches = Math.round(totalInches - feet * INCHES_PER_FOOT)
|
||
if (inches === INCHES_PER_FOOT) {
|
||
feet += 1
|
||
inches = 0
|
||
}
|
||
return `${feet}′ ${inches}″`
|
||
}
|
||
const meters = Math.round((cm / CM_PER_METER) * 100) / 100
|
||
return `${meters} m`
|
||
}
|
||
|
||
/** Centimeters width × height → a human string in the given unit. */
|
||
export function formatDimensions(widthCm: number, heightCm: number, unit: UnitPref): string {
|
||
return `${formatCm(widthCm, unit)} × ${formatCm(heightCm, unit)}`
|
||
}
|
||
|
||
/** The input-field label for a single dimension in the given unit. */
|
||
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'
|
||
}
|