- 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]>
125 lines
4.9 KiB
Go
125 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
|
// clear everything out of it.
|
|
//
|
|
// These were reachable only through the agent toolbox until now, which meant the
|
|
// most valuable bulk operation in a garden planner — and the one carrying the
|
|
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
|
// instance with no model configured. They are thin adapters over the same
|
|
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
|
// the one-change-set-per-operation guarantee come along unchanged.
|
|
|
|
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
|
// to a compass name. A named type (not an inline anonymous struct) to match the
|
|
// rest of internal/api and so it can carry its own validity check.
|
|
type fillRect struct {
|
|
MinX float64 `json:"minXCm"`
|
|
MinY float64 `json:"minYCm"`
|
|
MaxX float64 `json:"maxXCm"`
|
|
MaxY float64 `json:"maxYCm"`
|
|
}
|
|
|
|
// degenerate reports whether the rect encloses no area. Such a rect (including
|
|
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
|
// and plant a single plop at the object's centre — a surprising result for what
|
|
// is really malformed input.
|
|
func (r fillRect) degenerate() bool {
|
|
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
|
}
|
|
|
|
// objectFillRequest is the body for POST /objects/:id/fill.
|
|
//
|
|
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
|
// explicit rect in the object's local frame. The named form is what a person
|
|
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
|
// Exactly one must be supplied — accepting both and silently preferring one
|
|
// would make a client bug look like a geometry bug.
|
|
type objectFillRequest struct {
|
|
PlantID int64 `json:"plantId" binding:"required"`
|
|
Region string `json:"region"`
|
|
Rect *fillRect `json:"rect"`
|
|
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
|
// without editing the catalog entry.
|
|
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
|
// Layout is "clump" (default; fat clumps for a quick sketch) or "grid"
|
|
// (individual plants in rows at true spacing). Empty = clump. An unknown value
|
|
// is refused by the service (#77).
|
|
Layout string `json:"layout"`
|
|
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
|
|
// local day; omitted, the server uses UTC today — which is tomorrow for an
|
|
// evening gardener west of Greenwich, so clients that know better say so.
|
|
PlantedAt *string `json:"plantedAt"`
|
|
}
|
|
|
|
func (h *handlers) fillObject(c *gin.Context) {
|
|
id, ok := parseIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req objectFillRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
|
return
|
|
}
|
|
named, hasRect := req.Region != "", req.Rect != nil
|
|
if named == hasRect {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
|
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
|
return
|
|
}
|
|
|
|
actor := mustActor(c).ID
|
|
var (
|
|
created []domain.Planting
|
|
err error
|
|
)
|
|
if rect := req.Rect; rect != nil {
|
|
// Reject a zero-area rect here rather than let it plant one stray plop.
|
|
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
|
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
|
if rect.degenerate() {
|
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
|
return
|
|
}
|
|
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
|
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
|
} else {
|
|
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
|
|
}
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
// 200, not 201: a fill can legitimately create nothing (the region is already
|
|
// planted), and there is no single resource to point a Location at.
|
|
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
|
}
|
|
|
|
// clearObject soft-removes every active plop in an object.
|
|
//
|
|
// Distinct from deleting the object, and — unlike the client-side loop this
|
|
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
|
// rather than one per plop.
|
|
func (h *handlers) clearObject(c *gin.Context) {
|
|
id, ok := parseIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
|
}
|