- 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]>
57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
// Season plans are whole-garden copies (POST /gardens/:id/copy): a separate
|
|
// garden you rearrange freely while this year's stays put. The API keeps no link
|
|
// between a copy and its source, so the relationship rides on the name the copy
|
|
// is given by default — "Home Garden — 2027" — which is what the editor's season
|
|
// control and the gardens list read back. Rename the copy and it is simply a
|
|
// garden again; nothing breaks, it just stops being offered as a plan.
|
|
|
|
const PLAN_RE = /^(.+?)\s+[—–-]\s+(\d{4})$/
|
|
|
|
export interface PlanName {
|
|
base: string
|
|
year: number
|
|
}
|
|
|
|
/** "Home Garden — 2027" → { base: "Home Garden", year: 2027 }, else null. */
|
|
export function parsePlanName(name: string): PlanName | null {
|
|
const m = name.trim().match(PLAN_RE)
|
|
if (!m) return null
|
|
return { base: m[1].trim(), year: Number(m[2]) }
|
|
}
|
|
|
|
/** The default name for a plan copy of `base` for `year`. */
|
|
export function planNameFor(base: string, year: number): string {
|
|
return `${base} — ${year}`
|
|
}
|
|
|
|
/** Whether a garden reads as a forward-looking plan (a year-suffixed copy for
|
|
* this year or later). Past years are archives, not plans, and get no tag. */
|
|
export function planYearOf(name: string, now = new Date()): number | null {
|
|
const p = parsePlanName(name)
|
|
return p && p.year >= now.getFullYear() ? p.year : null
|
|
}
|
|
|
|
/** The plan copies of `base` among `gardens`, ascending by year. */
|
|
export function planGardensOf<G extends { id: number; name: string }>(base: string, gardens: readonly G[]): (G & { year: number })[] {
|
|
const out: (G & { year: number })[] = []
|
|
for (const g of gardens) {
|
|
const p = parsePlanName(g.name)
|
|
if (p && p.base === base.trim()) out.push({ ...g, year: p.year })
|
|
}
|
|
return out.sort((a, b) => a.year - b.year)
|
|
}
|
|
|
|
/** The first year from `from` on that `base` has no plan copy for among
|
|
* `names`, so a new copy never proposes a name that is already taken — two
|
|
* "Back Yard — 2027"s would both be offered as the 2027 plan. */
|
|
export function nextPlanYear(base: string, names: readonly string[], from: number): number {
|
|
const taken = new Set<number>()
|
|
for (const n of names) {
|
|
const p = parsePlanName(n)
|
|
if (p && p.base === base.trim()) taken.add(p.year)
|
|
}
|
|
let year = from
|
|
while (taken.has(year)) year += 1
|
|
return year
|
|
}
|