Feet-and-inches entry: type 2' 7\" instead of 2.6 (#59) (#62)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #62.
This commit is contained in:
2026-07-21 05:08:57 +00:00
committed by steve
parent f208da94d6
commit 3ec77a1099
4 changed files with 301 additions and 73 deletions
+37 -23
View File
@@ -8,25 +8,34 @@ import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import {
cmFromDisplay,
dimensionUnitLabel,
displayFromCm,
formatCm,
dimensionInputMode,
formatDimensionInput,
isValidDimensionCm,
MIN_GARDEN_GRID_CM,
parseDimension,
type UnitPref,
} from '@/lib/units'
const DEFAULT_METERS = 10 // matches the server's 10 m default
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
// What to say when a field can't be read. Naming the accepted forms beats
// "invalid input", which leaves the person guessing which field and which part.
function entryHint(unit: UnitPref): string {
return unit === 'imperial'
? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.`
: 'Enter sizes in meters, e.g. 2.5.'
}
const unitOptions = [
{ value: 'metric', label: 'Metric (m)' },
{ value: 'imperial', label: 'Imperial (ft)' },
]
function dimString(cm: number | undefined, unit: UnitPref): string {
return cm === undefined ? String(DEFAULT_METERS) : String(displayFromCm(cm, unit))
return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit)
}
/**
@@ -46,7 +55,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
const [gridSize, setGridSize] = useState(() =>
String(displayFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')),
formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
@@ -55,9 +64,11 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) {
// Re-render each field in the new unit, preserving the physical size. An
// unparseable field is left as typed rather than blanked.
const convert = (s: string) => {
const v = parseFloat(s)
return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s
const cm = parseDimension(s, unit)
return cm === null ? s : formatDimensionInput(cm, next)
}
setWidth(convert(width))
setHeight(convert(height))
@@ -70,11 +81,11 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
// Converted once per render and used by both the submit handler and the
// too-fine hint, so the two can never disagree about what was entered.
const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit)
const gridSizeCm = parseDimension(gridSize, unit)
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
// someone reaching for plant spacing, which lives on the bed instead — but it
// is a legitimate choice for a very small garden, so the save still goes through.
const gridTooFine = Number.isFinite(gridSizeCm) && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) {
e.preventDefault()
@@ -88,12 +99,20 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
// Validate the converted centimeter values against the same bounds the
// server enforces, so sub-cm or over-100m sizes fail here with a clear
// message instead of a generic server error.
const widthCm = cmFromDisplay(parseFloat(width), unit)
const heightCm = cmFromDisplay(parseFloat(height), unit)
const widthCm = parseDimension(width, unit)
const heightCm = parseDimension(height, unit)
if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) {
setFormError('Width and height must be between 1 cm and 100 m.')
return
}
if (gridSizeCm === null) {
setFormError(entryHint(unit))
return
}
if (!isValidDimensionCm(gridSizeCm)) {
setFormError('Grid size must be between 1 cm and 100 m.')
return
@@ -126,7 +145,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setUnit(current.unitPref)
setWidth(dimString(current.widthCm, current.unitPref))
setHeight(dimString(current.heightCm, current.unitPref))
setGridSize(String(displayFromCm(current.gridSizeCm, current.unitPref)))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
@@ -137,6 +156,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
}
const unitLabel = dimensionUnitLabel(unit)
const inputMode = dimensionInputMode(unit)
return (
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
@@ -157,10 +177,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
<TextField
label={`Width (${unitLabel})`}
name="width"
type="number"
inputMode="decimal"
step="any"
min="0"
type="text"
inputMode={inputMode}
required
value={width}
onChange={(e) => setWidth(e.target.value)}
@@ -168,10 +186,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
<TextField
label={`Height (${unitLabel})`}
name="height"
type="number"
inputMode="decimal"
step="any"
min="0"
type="text"
inputMode={inputMode}
required
value={height}
onChange={(e) => setHeight(e.target.value)}
@@ -184,10 +200,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
<TextField
label={`Garden grid (${unitLabel})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
type="text"
inputMode={inputMode}
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
/>