Files
pansy/web/src/lib/units.ts
T
steveandClaude Fable 5 27f658c1f7
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00

272 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
/** 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
}
// --- Dimension entry -------------------------------------------------------
// Imperial dimensions are typed the way people say them — 2' 7", not 2.6 — while
// a bare number still means feet so nothing anyone typed before this existed
// breaks. Metric entry is decimal meters, unchanged.
// Feet, then optionally inches. The inch mark is optional once feet are explicit,
// because "2' 7" is a natural thing to type and unambiguous.
const FT_IN_RE = /^(\d*\.?\d+)\s*(?:'|ft|feet|foot)(?:\s*(\d*\.?\d+)\s*(?:"|in|inch|inches)?)?$/
// Inches alone, where the mark is required — a bare number means feet.
const IN_ONLY_RE = /^(\d*\.?\d+)\s*(?:"|in|inch|inches)$/
// A bare decimal, which means feet (imperial) or meters (metric).
const BARE_RE = /^(\d*\.?\d+)$/
const METERS_RE = /^(\d*\.?\d+)\s*m?$/
/** Normalize the characters people actually paste: typographic quotes and the
* various unicode dashes, so text copied from anywhere parses like text typed
* here. Lowercased so "FT"/"In" work too. */
function normalizeEntry(s: string): string {
return s
.trim()
.replace(/[′’‵]/g, "'")
.replace(/[″”‶]/g, '"')
.replace(/[−–—]/g, '-')
.toLowerCase()
}
/** Split a leading sign off a normalized entry. The sign belongs to the WHOLE
* value, not just its first term: -2' 6" is (2ft + 6in) = 30in, not 2ft + 6in. */
function splitSign(s: string): { sign: number; rest: string } {
if (s.startsWith('-')) return { sign: -1, rest: s.slice(1).trim() }
if (s.startsWith('+')) return { sign: 1, rest: s.slice(1).trim() }
return { sign: 1, rest: s }
}
/**
* Parse a typed dimension into centimeters, or null if it isn't a dimension.
*
* Imperial accepts `2' 7"`, `2 7″`, `2ft 7in`, `2' 7`, `31"`, `2'`, and a bare
* `2.5` still meaning 2½ feet. Metric accepts decimal meters with an optional
* `m`. Null means "don't commit anything" — never a silent zero, which would
* turn a typo into a destructive edit.
*/
export function parseDimension(input: string, unit: UnitPref): number | null {
const { sign, rest } = splitSign(normalizeEntry(input))
if (rest === '') return null
if (unit !== 'imperial') {
const m = rest.match(METERS_RE)
return m ? sign * cmFromMeters(parseFloat(m[1])) : null
}
const bare = rest.match(BARE_RE)
if (bare) return sign * cmFromFtIn(parseFloat(bare[1]))
const ftIn = rest.match(FT_IN_RE)
if (ftIn) return sign * cmFromFtIn(parseFloat(ftIn[1]), ftIn[2] ? parseFloat(ftIn[2]) : 0)
const inOnly = rest.match(IN_ONLY_RE)
if (inOnly) return sign * cmFromFtIn(0, parseFloat(inOnly[1]))
return null
}
/**
* Centimeters → the string a dimension input shows, and the exact string
* parseDimension round-trips. Imperial reads `2 7″`; inches carry one decimal
* (`7 10.5″`) because 0.1″ is ~0.25 cm, so what's displayed stays honest about
* what's stored rather than rounding a dragged position to the nearest inch.
*/
export function formatDimensionInput(cm: number, unit: UnitPref): string {
if (unit !== 'imperial') return String(displayFromCm(cm, unit))
const { feet, inches } = feetAndInches(cm, 1)
// Only sign a value that actually rounded to something: a hair below zero is
// 0 0″, and "-0 0″" would be both wrong and un-round-trippable.
const sign = cm < 0 && (feet || inches) ? '-' : ''
return `${sign}${feet} ${inches}″`
}
/**
* The inputMode a dimension field should ask for. Imperial entry needs ' and ",
* which no numeric keypad offers, so those fields get the full keyboard; metric
* is pure decimals and keeps the keypad. A bare number still means feet, so the
* keypad path stays usable either way.
*/
export function dimensionInputMode(unit: UnitPref): 'text' | 'decimal' {
return unit === 'imperial' ? 'text' : 'decimal'
}
/**
* Decompose centimeters into feet and inches, rounding inches to `decimals` and
* carrying 12″ up to the next foot. Magnitude only — the sign is the caller's to
* apply, and only once it knows the rounded result isn't zero.
*/
function feetAndInches(cm: number, decimals: number): { feet: number; inches: number } {
const scale = 10 ** decimals
const totalInches = Math.abs(cm) / CM_PER_INCH
let feet = Math.floor(totalInches / INCHES_PER_FOOT)
let inches = Math.round((totalInches - feet * INCHES_PER_FOOT) * scale) / scale
if (inches >= INCHES_PER_FOOT) {
feet += 1
inches = 0
}
return { feet, inches }
}
/** 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 { feet, inches } = feetAndInches(cm, 0)
return `${cm < 0 && (feet || inches) ? '-' : ''}${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'
}
// --- Compact imperial display ----------------------------------------------
// The editor and the cards label sizes the way a gardener says them — 3, 14″,
// 8″ — rather than the always-both "4 0″" the entry fields round-trip through.
// Display only; nothing here is parsed back.
/** Centimeters → nearest inch, shown as feet/inches with zero parts dropped:
* 91 → "3", 40 → "14″", 20 → "8″". */
export function formatFtIn(cm: number): string {
const inches = Math.round(Math.abs(cm) / CM_PER_INCH)
const feet = Math.floor(inches / INCHES_PER_FOOT)
const rest = inches - feet * INCHES_PER_FOOT
const s = feet ? (rest ? `${feet}${rest}″` : `${feet}`) : `${rest}″`
return cm < 0 && inches ? `-${s}` : s
}
/** One length in the garden's unit: compact feet/inches, or meters. */
export function formatLength(cm: number, unit: UnitPref): string {
return unit === 'imperial' ? formatFtIn(cm) : formatCm(cm, 'metric')
}
/** "3 × 6" / "0.91 m × 1.83 m". */
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
}
// --- Typed-length fields ----------------------------------------------------
// A dialog field that takes a length holds TWO things: the text the person sees
// and the centimeters it means. The centimeters change only when the person
// types; re-showing the field in another unit, or saving without touching it,
// reuses them as they are. Parsing the displayed text back on save is how a
// no-change Save once turned 900 cm into 899.922 — "29 6.3″" is the nearest
// tenth of an inch, not the number that was loaded.
/** A length as typed and as stored; `cm` is null while the text doesn't parse. */
export interface LengthField {
text: string
cm: number | null
}
/** A dimension field (meters, or feet and inches) showing `cm`. */
export function dimensionField(cm: number, unit: UnitPref): LengthField {
return { text: formatDimensionInput(cm, unit), cm }
}
/** The person typed `text` into a dimension field. */
export function editDimensionField(text: string, unit: UnitPref): LengthField {
return { text, cm: parseDimension(text, unit) }
}
/** Re-show a dimension field in another unit; the centimeters don't move. A
* field that doesn't parse keeps its text, so the typo stays visible. */
export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField {
return field.cm === null ? field : dimensionField(field.cm, unit)
}
/** A spacing field (cm, or inches) showing `cm`. */
export function spacingField(cm: number, unit: UnitPref): LengthField {
return { text: String(spacingFromCm(cm, unit)), cm }
}
/** The person typed `text` into a spacing field. */
export function editSpacingField(text: string, unit: UnitPref): LengthField {
const trimmed = text.trim()
const n = Number(trimmed)
return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null }
}