- 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]>
117 lines
5.0 KiB
TypeScript
117 lines
5.0 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { Link } from '@tanstack/react-router'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { IconButton } from '@/components/ui/Button'
|
|
import { Tag } from '@/components/ui/Tag'
|
|
import type { Garden } from '@/lib/gardens'
|
|
import { useGardenFull } from '@/lib/objects'
|
|
import { parsePlanName, planYearOf } from '@/lib/plan'
|
|
import { sharesQueryOptions } from '@/lib/shares'
|
|
import { formatSize } from '@/lib/units'
|
|
import { kindPlural } from '@/editor/kinds'
|
|
import { GardenThumb } from './GardenThumb'
|
|
|
|
// The order kinds are counted in on the card's meta line.
|
|
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 (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.
|
|
*/
|
|
export function GardenCard({
|
|
garden,
|
|
currentUserId,
|
|
onShare,
|
|
onCopy,
|
|
onEdit,
|
|
onDelete,
|
|
onLeave,
|
|
}: {
|
|
garden: Garden
|
|
currentUserId?: number
|
|
onShare: () => void
|
|
onCopy: () => void
|
|
onEdit: () => void
|
|
onDelete: () => void
|
|
onLeave: () => void
|
|
}) {
|
|
const owner = currentUserId != null && garden.ownerId === currentUserId
|
|
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
|
|
if (!data) return full.isError ? 'Could not load the plot.' : '…'
|
|
if (data.objects.length === 0) return 'Bare ground — drag your first bed on.'
|
|
const counts = new Map<string, number>()
|
|
for (const o of data.objects) counts.set(o.kind, (counts.get(o.kind) ?? 0) + 1)
|
|
const parts = COUNTED_KINDS.filter((k) => counts.has(k)).map((k) => kindPlural(k, counts.get(k)!))
|
|
for (const [k, n] of counts) if (!COUNTED_KINDS.includes(k)) parts.push(kindPlural(k, n))
|
|
const plops = data.plantings.length
|
|
parts.push(`${plops} ${plops === 1 ? 'planting' : 'plantings'}`)
|
|
const since = new Date(garden.createdAt).getFullYear()
|
|
if (Number.isFinite(since)) parts.push(`tended since ${since}`)
|
|
return parts.join(' · ')
|
|
}, [full.data, full.isError, garden.createdAt])
|
|
|
|
const sharedLine = (() => {
|
|
if (!owner) return garden.myRole ? `Shared with you · ${garden.myRole}` : 'Shared with you'
|
|
const list = shares.data ?? []
|
|
if (list.length === 0) return null
|
|
const first = list[0]
|
|
const who = first.email.includes('@') ? first.email.slice(0, first.email.indexOf('@') + 1) : first.displayName
|
|
return list.length === 1 ? `Shared with ${who} · ${first.role}` : `Shared with ${who} +${list.length - 1}`
|
|
})()
|
|
|
|
return (
|
|
<div className="panel flex flex-col overflow-hidden transition-shadow hover:[box-shadow:var(--shadow-md)]">
|
|
<Link
|
|
to="/gardens/$gardenId"
|
|
params={{ gardenId: String(garden.id) }}
|
|
className="block border-b border-divider bg-field no-underline"
|
|
aria-label={`Open ${garden.name}`}
|
|
>
|
|
<GardenThumb widthCm={garden.widthCm} heightCm={garden.heightCm} full={full.data} />
|
|
</Link>
|
|
<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}>
|
|
{title}
|
|
</span>
|
|
{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>
|
|
</div>
|
|
<div className="text-[13px] leading-relaxed text-ink-soft">{meta}</div>
|
|
{sharedLine && <div className="text-xs font-semibold text-accent-2-700">{sharedLine}</div>}
|
|
<div className="mt-auto flex gap-2 pt-2">
|
|
<Link
|
|
to="/gardens/$gardenId"
|
|
params={{ gardenId: String(garden.id) }}
|
|
className="btn btn-primary flex-1 no-underline"
|
|
>
|
|
Open
|
|
</Link>
|
|
{owner ? (
|
|
<>
|
|
<IconButton label="Share" icon="share-2" onClick={onShare} />
|
|
<IconButton label="Copy — plan a season from it" icon="copy" onClick={onCopy} />
|
|
<IconButton label="Edit the garden's name and size" icon="pencil" onClick={onEdit} />
|
|
<IconButton label="Delete" icon="trash-2" onClick={onDelete} iconClassName="text-accent-700" />
|
|
</>
|
|
) : (
|
|
<IconButton label="Leave this garden" icon="log-out" onClick={onLeave} iconClassName="text-accent-700" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|