Build image / build-and-push (push) Successful in 8s
Five findings, all fair: The soft-minimum hint contradicted itself. "did you mean ft?" made sense when the grid field was labelled inches sitting under a feet field — which is exactly what this PR removed. The remaining failure mode is different: someone reaching for plant spacing, which belongs to the bed, not the garden. The hint now says that, and points at where the bed grid actually lives. The stale "1 typed meaning one foot" comments went with it. gridNote was a three-level nested ternary; it is now gridNoteFor, with the same logic in explicit branches and the reasoning in one doc comment rather than threaded between the arms. cmFromDisplay(parseFloat(gridSize), unit) was computed once for the hint and again in the submit handler. Hoisted to a single per-render value so the two can't disagree about what was entered. roundCm's comment called the 1e-6 cm grain "1 nanometer"; it is 10 nm. Corrected — the constant was always fine, only the comment was wrong. Extracted the duplicated overlay-badge class string. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
111 lines
4.4 KiB
TypeScript
111 lines
4.4 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
|
||
|
||
// Below this, a garden-scale grid is fine enough to be worth questioning — it
|
||
// usually means someone reached for plant spacing, which belongs to the bed, not
|
||
// the garden. Soft: the form hints, it doesn't refuse.
|
||
export const MIN_GARDEN_GRID_CM = 10
|
||
|
||
/** 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
|
||
}
|
||
|
||
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
|
||
* 30.479999999999997 in binary floating point, and the 1e-6 cm (10 nm) grain
|
||
* here is far below anything a garden cares about. Every cm column is REAL, so
|
||
* entry stays exact — this only stops 30.48 being stored as 30.479999999999997. */
|
||
function roundCm(cm: number): number {
|
||
return Math.round(cm * 1e6) / 1e6
|
||
}
|
||
|
||
/** Feet (+ optional inches) → centimeters, exactly (1 ft → 30.48). */
|
||
export function cmFromFtIn(feet: number, inches = 0): number {
|
||
return roundCm((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
|
||
}
|
||
|
||
/** Meters → centimeters, exactly. */
|
||
export function cmFromMeters(meters: number): number {
|
||
return roundCm(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) → centimeters,
|
||
* exactly (12 in → 30.48). */
|
||
export function cmFromSpacing(value: number, unit: UnitPref): number {
|
||
return roundCm(unit === 'imperial' ? value * CM_PER_INCH : 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'
|
||
}
|