Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
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

- 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]>
This commit is contained in:
2026-08-22 22:11:12 -04:00
co-authored by Claude Fable 5
parent 05392ee0db
commit 27f658c1f7
36 changed files with 508 additions and 125 deletions
+28 -31
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import {
cmFromFtIn,
convertDimensionField,
dimensionField,
dimensionInputMode,
dimensionUnitLabel,
editDimensionField,
formatCm,
formatDimensionInput,
isValidDimensionCm,
MIN_GARDEN_GRID_CM,
parseDimension,
type LengthField,
type UnitPref,
} from '@/lib/units'
@@ -36,8 +38,11 @@ function entryHint(unit: UnitPref): string {
/**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters; switching units converts what's typed so
* the physical size holds. A 409 rebases the form onto the server's fresh row.
* chosen unit and stored as centimeters: each field is a LengthField, so
* switching units re-shows the same centimeters and a Save sends exactly what
* was loaded unless the person typed over it (re-parsing the display string is
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
* fresh row.
*/
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden
@@ -48,14 +53,10 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState(() =>
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'),
)
const [height, setHeight] = useState(() =>
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
)
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
const [gridSize, setGridSize] = useState<LengthField>(() =>
dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
)
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '')
@@ -65,17 +66,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) {
const convert = (s: string) => {
const cm = parseDimension(s, unit)
return cm === null ? s : formatDimensionInput(cm, next)
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convert(gridSize))
setWidth((f) => convertDimensionField(f, next))
setHeight((f) => convertDimensionField(f, next))
setGridSize((f) => convertDimensionField(f, next))
setUnit(next)
}
const gridSizeCm = parseDimension(gridSize, unit)
const gridSizeCm = gridSize.cm
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) {
@@ -86,8 +83,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setFormError('Give the garden a name.')
return
}
const widthCm = parseDimension(width, unit)
const heightCm = parseDimension(height, unit)
const widthCm = width.cm
const heightCm = height.cm
if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit))
return
@@ -111,9 +108,9 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setVersion(current.version)
setName(current.name)
setUnit(current.unitPref)
setWidth(formatDimensionInput(current.widthCm, current.unitPref))
setHeight(formatDimensionInput(current.heightCm, current.unitPref))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref))
setWidth(dimensionField(current.widthCm, current.unitPref))
setHeight(dimensionField(current.heightCm, current.unitPref))
setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
@@ -138,8 +135,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text"
inputMode={inputMode}
required
value={width}
onChange={(e) => setWidth(e.target.value)}
value={width.text}
onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
/>
<TextField
@@ -148,8 +145,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text"
inputMode={inputMode}
required
value={height}
onChange={(e) => setHeight(e.target.value)}
value={height.text}
onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
/>
<div className="field">
@@ -171,8 +168,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
name="gridSize"
type="text"
inputMode={inputMode}
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
value={gridSize.text}
onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1"
hint={
gridTooFine && gridSizeCm !== null