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]>
This commit is contained in:
@@ -6,24 +6,31 @@ import { Dialog } from '@/components/ui/Dialog'
|
||||
import { TextField } from '@/components/ui/Field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
import { parsePlanName, planNameFor } from '@/lib/plan'
|
||||
import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
|
||||
import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
|
||||
|
||||
/**
|
||||
* Duplicate a garden — the way to scheme a season: the copy is a separate
|
||||
* garden you rearrange freely while this one stays put. Beds and everything
|
||||
* currently planted come along; the share link and shares don't. The name is
|
||||
* prefilled as "<name> — <next year>", which is what the editor's season
|
||||
* control and the `plan` tag read back (see lib/plan.ts). On success we land in
|
||||
* the copy, since the point of copying is to start editing it.
|
||||
* prefilled as "<name> — <year>" for the next year that doesn't already have a
|
||||
* plan, which is what the editor's season control and the `plan` tag read back
|
||||
* (see lib/plan.ts). On success we land in the copy, since the point of copying
|
||||
* is to start editing it.
|
||||
*/
|
||||
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const gardens = useGardens()
|
||||
const names = (gardens.data ?? []).map((g) => g.name)
|
||||
const base = parsePlanName(garden.name)?.base ?? garden.name
|
||||
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
|
||||
const year = nextPlanYear(base, names, from)
|
||||
const [name, setName] = useState(() => planNameFor(base, year))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// The API allows duplicate names; say so rather than let two gardens read as
|
||||
// the same season's plan.
|
||||
const taken = names.some((n) => n.trim() === name.trim())
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -47,6 +54,7 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
|
||||
</p>
|
||||
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<p className="text-xs text-ink-mute">Keep the “— {year}” and it shows up as that season's plan in the editor.</p>
|
||||
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" onClick={onClose} disabled={copy.isPending}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconButton } from '@/components/ui/Button'
|
||||
import { Tag } from '@/components/ui/Tag'
|
||||
import type { Garden } from '@/lib/gardens'
|
||||
import { useGardenFull } from '@/lib/objects'
|
||||
import { planYearOf } from '@/lib/plan'
|
||||
import { parsePlanName, planYearOf } from '@/lib/plan'
|
||||
import { sharesQueryOptions } from '@/lib/shares'
|
||||
import { formatSize } from '@/lib/units'
|
||||
import { kindPlural } from '@/editor/kinds'
|
||||
@@ -15,8 +15,8 @@ import { GardenThumb } from './GardenThumb'
|
||||
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
|
||||
|
||||
/**
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name + an
|
||||
* optional `plan` tag, size, a counts line, who it's shared with, and a footer
|
||||
* One garden as a card: the plot thumbnail (a link into the editor), name (a
|
||||
* plan copy shows its base name and a `<year> plan` tag), size, a counts line, who it's shared with, and a footer
|
||||
* of Open + share / copy / edit / delete. A garden shared WITH you shows its
|
||||
* role and a leave action instead of the owner's tools.
|
||||
*/
|
||||
@@ -41,6 +41,9 @@ export function GardenCard({
|
||||
const full = useGardenFull(garden.id)
|
||||
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
|
||||
const planYear = planYearOf(garden.name)
|
||||
// A plan's year is the point of its name, and the first thing truncation
|
||||
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
|
||||
const title = planYear != null ? (parsePlanName(garden.name)?.base ?? garden.name) : garden.name
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const data = full.data
|
||||
@@ -79,9 +82,9 @@ export function GardenCard({
|
||||
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
|
||||
{garden.name}
|
||||
{title}
|
||||
</span>
|
||||
{planYear != null && <Tag tone="accent">plan</Tag>}
|
||||
{planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
|
||||
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
|
||||
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user