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
+7 -2
View File
@@ -10,6 +10,7 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react'
import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
@@ -34,6 +35,10 @@ import {
import { useEditorStore, type Viewport } from './store'
import type { EditorGarden, EditorObject } from './types'
// A plop whose plant is missing from the catalog (a shared garden's private
// plant) still needs a color to be drawn in.
const FALLBACK_PLANT_COLOR = '#97a97c'
const WHEEL_SENSITIVITY = 0.0016
const ANIM_MS = 520
const REFIT_THRESHOLD_PX = 60
@@ -589,7 +594,7 @@ export const Canvas = forwardRef<
>
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
<circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
</g>
)
})}
@@ -642,7 +647,7 @@ export const Canvas = forwardRef<
textAnchor="middle"
dominantBaseline="central"
fontSize={r * 1.05}
fill="var(--color-paper)"
fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
style={{ fontFamily: 'var(--font-heading)' }}
>
{letters.get(p.plantId) ?? '?'}
+36 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ColorDot } from '@/components/plants/Monogram'
import { Button, IconButton } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextAreaField, TextField } from '@/components/ui/Field'
import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag'
@@ -22,7 +23,7 @@ import {
spacingUnitLabel,
type UnitPref,
} from '@/lib/units'
import { kindDef, kindPlural } from './kinds'
import { kindDef, kindPlural, objectDisplayName } from './kinds'
import { MIN_OBJECT_CM, plopCount } from './shared'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
@@ -40,6 +41,13 @@ export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsB
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
}
/** How many plants an object holds right now (its plops × their counts). */
export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): number {
let n = 0
for (const p of plantings) if (p.objectId === o.id) n += plopCount(p, plantsById.get(p.plantId))
return n
}
/** A collapsible block of the less-often-needed fields. */
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
return (
@@ -66,6 +74,7 @@ export function ObjectInspector({
canEdit,
focused,
roster,
plantCount,
noteCount,
large,
onPlantThis,
@@ -79,6 +88,8 @@ export function ObjectInspector({
/** Already inside this bed (so "Plant this" is redundant). */
focused: boolean
roster: string
/** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */
plantCount: number
noteCount: number
/** Phone: 16px inputs, 44px targets. */
large?: boolean
@@ -89,6 +100,7 @@ export function ObjectInspector({
const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId)
const rootRef = useRef<HTMLDivElement>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [name, setName] = useState(object.name)
const [details, setDetails] = useState(false)
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
@@ -178,6 +190,13 @@ export function ObjectInspector({
iconClassName="text-accent-700"
disabled={del.isPending}
onClick={() => {
// An empty object goes straight away (one Undo brings it back); a
// planted one takes its plants with it, which is worth a question —
// on the phone this button sits right beside "Plant this".
if (plantCount > 0) {
setConfirmRemove(true)
return
}
onDeleted()
del.mutate(object.id)
}}
@@ -259,6 +278,22 @@ export function ObjectInspector({
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
</fieldset>
</Details>
{confirmRemove && (
<ConfirmDialog
title={`Remove ${objectDisplayName(object)}?`}
confirmLabel="Remove it"
busyLabel="Removing…"
errorFallback="Could not remove it."
onConfirm={async () => {
await del.mutateAsync(object.id)
onDeleted()
}}
onClose={() => setConfirmRemove(false)}
>
It has {plantCount === 1 ? 'one plant' : `${plantCount} plants`} in it, and they go with it. One Undo brings
everything back.
</ConfirmDialog>
)}
</div>
)
}