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
+11 -9
View File
@@ -15,7 +15,7 @@ import {
type PlantInput,
} from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
// Markers are monograms now, but the API still carries an icon per plant; a
@@ -26,8 +26,10 @@ const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY
/**
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
* (`template`, the Duplicate action; the only way to customize a built-in).
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases
* onto the server's current row.
* Spacing is typed in the page's unit and stored in centimeters — as a
* LengthField, so a Save that didn't touch it sends the centimeters that were
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
* server's current row.
*/
export function PlantDialog({
plant,
@@ -48,7 +50,7 @@ export function PlantDialog({
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [vendor, setVendor] = useState(source?.vendor ?? '')
@@ -68,8 +70,8 @@ export function PlantDialog({
setFormError('Give the plant a name.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
const spacingCm = spacing.cm
if (spacingCm === null || spacingCm < 1) {
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
@@ -107,7 +109,7 @@ export function PlantDialog({
setVersion(current.version)
setName(current.name)
setCategory(current.category)
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
setSpacing(spacingField(current.spacingCm, unit))
setColor(expandHex(current.color))
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
setVendor(current.vendor)
@@ -142,8 +144,8 @@ export function PlantDialog({
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
value={spacing.text}
onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
wrapperClassName="flex-1"
/>
</div>