Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s

Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
2026-07-19 07:07:14 +00:00
committed by steve
parent 9968c06243
commit e74fb308c1
23 changed files with 581 additions and 88 deletions
+35 -16
View File
@@ -13,11 +13,12 @@ import (
// sanity range that also rejects NaN/Inf and absurd values. Name/notes are
// length-capped so untrusted input can't balloon storage.
const (
defaultGardenCM = 1000 // 10 m
minGardenCM = 1 // 1 cm
maxGardenCM = 10_000 // 100 m
maxGardenNameLen = 200
maxGardenNotesLen = 10_000
defaultGardenCM = 1000 // 10 m
minGardenCM = 1 // 1 cm
maxGardenCM = 10_000 // 100 m
defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid
maxGardenNameLen = 200
maxGardenNotesLen = 10_000
)
// gardenRole ranks a user's access to a garden. Higher includes lower
@@ -48,11 +49,13 @@ func (r gardenRole) String() string {
// GardenInput is the mutable field set for creating or updating a garden.
type GardenInput struct {
Name string
WidthCM float64
HeightCM float64
UnitPref string
Notes string
Name string
WidthCM float64
HeightCM float64
UnitPref string
Notes string
GridSizeCM float64
SnapToGrid bool
}
// requireGardenRole loads a garden and enforces that the actor holds at least
@@ -163,7 +166,9 @@ func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) err
// gardenFromInput validates and normalizes input into a domain.Garden (without
// identity/ownership fields). With applyDefaults, absent dimensions/unit are
// filled in (create); without it, every field must be supplied (update).
// filled in (create); without it, dimensions and unit must be supplied (update).
// The grid size is the exception: 0 always means "unset" and is defaulted on both
// paths, so an older client that omits it can't wipe the column to an invalid 0.
func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) {
name := strings.TrimSpace(in.Name)
if name == "" || len(name) > maxGardenNameLen {
@@ -198,12 +203,26 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
return nil, domain.ErrInvalidInput
}
// Grid spacing shares the garden dimension range [1 cm, 100 m]. 0 means
// "unset" — defaulted (not just on create: an older client that omits it must
// not wipe the column to an invalid 0), so a saved garden always has a usable
// grid even if snapping is off.
grid := in.GridSizeCM
if grid == 0 {
grid = defaultGardenGridCM
}
if !validDimensionCM(grid) {
return nil, domain.ErrInvalidInput
}
return &domain.Garden{
Name: name,
WidthCM: width,
HeightCM: height,
UnitPref: unit,
Notes: notes,
Name: name,
WidthCM: width,
HeightCM: height,
UnitPref: unit,
Notes: notes,
GridSizeCM: grid,
SnapToGrid: in.SnapToGrid,
}, nil
}