"Change the garlic bed to cucumbers this year" has a year in it, and pansy had no notion of one — the editor showed whatever is currently planted, full stop. The data has always supported seasons: plantings carry planted_at and removed_at, and "clear bed" soft-removes rather than deleting. A season is a date range over data that already exists. No schema change, and specifically no seasons table — it would duplicate what the dates already say and create a second source of truth about when something was in the ground. ?year=YYYY on /full returns every plop whose [planted_at, removed_at] interval overlapped that calendar year, so garlic planted in October and pulled the following July appears in BOTH years, which is what actually happened. Undated plantings appear in every year: everything that predates this feature has a null planted_at, and a rule that excluded them would empty every existing garden the moment a year was selected. Without the param /full behaves exactly as before — the existing tests pass unchanged, which was the point of doing it this way. Widening to past plops means widening the referenced-plant lookup with it. A plant pulled last July isn't active, but its plops still have to render with the right icon and colour, so ListReferencedPlants takes an includeRemoved flag that tracks the same switch. A past season is READ-ONLY, gated at the single canEdit the palette, inspector, nudging, placement and drag handles all key off. Editing the past by accident is the failure mode this feature introduces, so the state is stated in a banner rather than implied by a dropdown you set a while ago, with the way back to the live garden next to it. The season is a separate query under its own key. The optimistic mutations all patch gardenFullKey(gardenId); folding a year into that key would let them write into whichever season happened to be on screen. Read-only views never need that machinery, and keeping them out of it means they can't accidentally join it. The year selector offers only years the garden holds data for, plus the current one. A free numeric field invites a typo, and a typo'd year produces a confidently empty garden that reads as data loss rather than a mistake — the server bounds the year for the same reason. Closes #54 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
401 lines
14 KiB
Go
401 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strings"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// This file holds pansy's bulk, "natural-language-shaped" operations — the ones
|
||
// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live
|
||
// on *Service like every other operation, so agent tools (internal/agent) and any
|
||
// future REST surface inherit the same ACL enforcement via objectForRole /
|
||
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
|
||
// object's center, +x east, +y south, so -y is NORTH).
|
||
|
||
// maxFillPlops bounds a single FillRegion so a huge bed with tiny spacing can't
|
||
// generate a runaway number of inserts. A bed with thousands of plops is already
|
||
// far past any real garden; over the cap we refuse rather than silently truncate.
|
||
const maxFillPlops = 5000
|
||
|
||
// Region is an axis-aligned rectangle in an object's local frame. (Circle/polygon
|
||
// regions are post-v1, like polygon objects; NamedRegion produces only rects.)
|
||
type Region struct {
|
||
MinX, MinY, MaxX, MaxY float64
|
||
}
|
||
|
||
// contains reports whether a local point lies in the region.
|
||
func (r Region) contains(x, y float64) bool {
|
||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
||
}
|
||
|
||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||
// so an oversized caller-supplied region can't make hexCenters loop forever.
|
||
func (r Region) clampTo(halfW, halfH float64) Region {
|
||
return Region{
|
||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||
MaxX: math.Min(r.MaxX, halfW), MaxY: math.Min(r.MaxY, halfH),
|
||
}
|
||
}
|
||
|
||
// rect builds a rectangular region.
|
||
func rect(minX, minY, maxX, maxY float64) Region {
|
||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||
}
|
||
|
||
// NamedRegion resolves a compass name to a Region in the object's local frame.
|
||
// Recognizes the quarter corners "nw|ne|sw|se", the halves
|
||
// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all".
|
||
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
|
||
// is -y (see the file header). Unknown names return ErrInvalidInput.
|
||
func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
|
||
if o == nil {
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
hw, hh := o.WidthCM/2, o.HeightCM/2
|
||
key := strings.ToLower(strings.TrimSpace(name))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
|
||
|
||
switch key {
|
||
case "all":
|
||
return rect(-hw, -hh, hw, hh), nil
|
||
case "north", "top":
|
||
return rect(-hw, -hh, hw, 0), nil
|
||
case "south", "bottom":
|
||
return rect(-hw, 0, hw, hh), nil
|
||
case "east", "right":
|
||
return rect(0, -hh, hw, hh), nil
|
||
case "west", "left":
|
||
return rect(-hw, -hh, 0, hh), nil
|
||
case "nw", "northwest":
|
||
return rect(-hw, -hh, 0, 0), nil
|
||
case "ne", "northeast":
|
||
return rect(0, -hh, hw, 0), nil
|
||
case "sw", "southwest":
|
||
return rect(-hw, 0, 0, hh), nil
|
||
case "se", "southeast":
|
||
return rect(0, 0, hw, hh), nil
|
||
default:
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
}
|
||
|
||
// defaultPlopRadius is the radius a freshly-placed plop gets from its plant's
|
||
// spacing: max(1.5×spacing, 15cm) — matching the editor's placement default (#15).
|
||
func defaultPlopRadius(spacingCM float64) float64 {
|
||
return math.Max(1.5*spacingCM, 15)
|
||
}
|
||
|
||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||
// radius pitch, kept where the center is inside the region. A candidate is
|
||
// skipped when its plop would sit entirely inside an existing active plop (so
|
||
// re-filling doesn't stack duplicates). Returns the plops it created.
|
||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||
}
|
||
|
||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||
// already loaded and authorized (roleEditor). It clamps the region to the
|
||
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
|
||
// in one transaction rather than one round-trip per plop.
|
||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
if !o.Plantable {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
spacing := plant.SpacingCM
|
||
if spacingOverride != nil {
|
||
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
spacing = *spacingOverride
|
||
}
|
||
radius := defaultPlopRadius(spacing)
|
||
if !isFinite(radius) || radius <= 0 {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
|
||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||
centers := hexCenters(region, radius)
|
||
if len(centers) > maxFillPlops {
|
||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||
}
|
||
|
||
existing, err := s.store.ListActivePlantingsForObject(ctx, o.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
batch := make([]*domain.Planting, 0, len(centers))
|
||
for _, c := range centers {
|
||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||
continue
|
||
}
|
||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
||
batch = append(batch, p)
|
||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
||
}
|
||
created, err := s.store.CreatePlantings(ctx, batch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// One record call with every plop, so a fill auto-scopes into ONE change set
|
||
// with N revisions — undoing a fill is one click, not N.
|
||
changes := make([]change, 0, len(created))
|
||
for i := range created {
|
||
changes = append(changes, changeCreate(domain.EntityPlanting, created[i].ID, &created[i]))
|
||
}
|
||
s.record(ctx, o.GardenID, actorID,
|
||
fmt.Sprintf("Planted %d %s in %s", len(created), plant.Name, objectLabel(o)), changes...)
|
||
for i := range created {
|
||
created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing)
|
||
}
|
||
return created, nil
|
||
}
|
||
|
||
type localPoint struct{ x, y float64 }
|
||
|
||
// hexCenters returns hex-packed lattice centers whose center lies in the region.
|
||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
||
// radius inside the region's min corner so the first plop sits inside it.
|
||
func hexCenters(r Region, radius float64) []localPoint {
|
||
if radius <= 0 {
|
||
return nil
|
||
}
|
||
pitch := 2 * radius
|
||
rowH := pitch * math.Sqrt(3) / 2
|
||
const eps = 1e-6
|
||
var pts []localPoint
|
||
row := 0
|
||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
||
xStart := r.MinX + radius
|
||
if row%2 == 1 {
|
||
xStart += radius
|
||
}
|
||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
||
if r.contains(x, y) {
|
||
pts = append(pts, localPoint{x, y})
|
||
}
|
||
}
|
||
row++
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||
// entirely inside some existing active plop.
|
||
func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||
for _, e := range existing {
|
||
if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
region, err := NamedRegion(o, regionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||
}
|
||
|
||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||
// UPDATE), returning how many were cleared. Distinct from deleting the object.
|
||
// Unlike FillRegion it does NOT require the object be plantable: an object toggled
|
||
// non-plantable after it was planted must still be clearable (you can always
|
||
// remove existing plops, only not add new ones).
|
||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
|
||
// count — then clear exactly those ids. Clearing "every active plop" instead
|
||
// would let a plop created between this read and the UPDATE be removed with no
|
||
// revision recorded: cleared, with no way to undo it.
|
||
before, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
ids := make([]int64, 0, len(before))
|
||
for i := range before {
|
||
ids = append(ids, before[i].ID)
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids)
|
||
if err != nil || n == 0 {
|
||
return n, err
|
||
}
|
||
|
||
// From here the clear HAS happened. A failure to build the history entry must
|
||
// not be reported as a failed clear — the caller would retry an operation that
|
||
// already applied. Log the gap and report success, matching how record()
|
||
// treats its own write failures.
|
||
after, err := s.store.ListPlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
slog.Error("service: clear succeeded but history could not be recorded",
|
||
"error", err, "object", objectID, "cleared", n)
|
||
return n, nil
|
||
}
|
||
afterByID := make(map[int64]*domain.Planting, len(after))
|
||
for i := range after {
|
||
afterByID[after[i].ID] = &after[i]
|
||
}
|
||
changes := make([]change, 0, len(before))
|
||
for i := range before {
|
||
b := before[i]
|
||
a, ok := afterByID[b.ID]
|
||
if !ok {
|
||
continue // deleted outright between the two reads; nothing coherent to record
|
||
}
|
||
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
|
||
}
|
||
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...)
|
||
return n, nil
|
||
}
|
||
|
||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||
type DescribeResult struct {
|
||
GardenID int64 `json:"gardenId"`
|
||
Name string `json:"name"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
UnitPref string `json:"unitPref"`
|
||
Objects []DescribeObject `json:"objects"`
|
||
}
|
||
|
||
// DescribeObject is one object plus its active plantings, for DescribeResult.
|
||
// Version is included so an agent can move/edit the object (the mutation guard).
|
||
type DescribeObject struct {
|
||
ID int64 `json:"id"`
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Shape string `json:"shape"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
XCM float64 `json:"xCm"`
|
||
YCM float64 `json:"yCm"`
|
||
RotationDeg float64 `json:"rotationDeg"`
|
||
Plantable bool `json:"plantable"`
|
||
Version int64 `json:"version"`
|
||
Plantings []DescribePlanting `json:"plantings"`
|
||
}
|
||
|
||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||
type DescribePlanting struct {
|
||
PlantID int64 `json:"plantId"`
|
||
Plant string `json:"plant"`
|
||
Count int `json:"count"`
|
||
Location string `json:"location"`
|
||
RadiusCM float64 `json:"radiusCm"`
|
||
}
|
||
|
||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||
// object's active plantings (plant, effective count, rough location) — for a
|
||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
plantByID := make(map[int64]domain.Plant, len(full.Plants))
|
||
for _, p := range full.Plants {
|
||
plantByID[p.ID] = p
|
||
}
|
||
plopsByObject := make(map[int64][]domain.Planting)
|
||
for _, pl := range full.Plantings {
|
||
plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl)
|
||
}
|
||
|
||
res := &DescribeResult{
|
||
GardenID: full.Garden.ID,
|
||
Name: full.Garden.Name,
|
||
WidthCM: full.Garden.WidthCM,
|
||
HeightCM: full.Garden.HeightCM,
|
||
UnitPref: full.Garden.UnitPref,
|
||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||
}
|
||
for _, o := range full.Objects {
|
||
do := DescribeObject{
|
||
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
|
||
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
|
||
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
|
||
Plantings: []DescribePlanting{},
|
||
}
|
||
for _, pl := range plopsByObject[o.ID] {
|
||
count := pl.DerivedCount
|
||
if pl.Count != nil {
|
||
count = *pl.Count
|
||
}
|
||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||
PlantID: pl.PlantID,
|
||
Plant: plantByID[pl.PlantID].Name,
|
||
Count: count,
|
||
Location: describeLocation(pl.XCM, pl.YCM),
|
||
RadiusCM: pl.RadiusCM,
|
||
})
|
||
}
|
||
res.Objects = append(res.Objects, do)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// describeLocation reverse-maps a local point to a rough compass location — the
|
||
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
|
||
func describeLocation(x, y float64) string {
|
||
const eps = 1e-6
|
||
ns := ""
|
||
switch {
|
||
case y < -eps:
|
||
ns = "N"
|
||
case y > eps:
|
||
ns = "S"
|
||
}
|
||
ew := ""
|
||
switch {
|
||
case x < -eps:
|
||
ew = "W"
|
||
case x > eps:
|
||
ew = "E"
|
||
}
|
||
switch {
|
||
case ns == "" && ew == "":
|
||
return "center"
|
||
case ns != "" && ew != "":
|
||
return ns + ew + " corner"
|
||
case ns == "N":
|
||
return "north"
|
||
case ns == "S":
|
||
return "south"
|
||
case ew == "E":
|
||
return "east"
|
||
default:
|
||
return "west"
|
||
}
|
||
}
|