Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #66.
This commit is contained in:
2026-07-21 05:47:34 +00:00
committed by steve
parent b96ca1ec0a
commit 8c5ddb21ec
17 changed files with 556 additions and 36 deletions
+60 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"math"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -197,28 +198,80 @@ func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) err
}
// GardenFull returns the whole editor payload for a garden the actor can view.
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*FullGarden, error) {
// year nil means "what's planted now"; a year means the season view (#54).
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64, year *int) (*FullGarden, error) {
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
if err != nil {
return nil, err
}
return s.assembleFull(ctx, g)
if year != nil && (*year < minSeasonYear || *year > maxSeasonYear) {
return nil, domain.ErrInvalidInput
}
return s.assembleFullFor(ctx, g, year)
}
// Seasons are bounded to keep a typo'd year from producing a confidently empty
// garden; the range is wide enough to cover any real record.
const (
minSeasonYear = 1900
maxSeasonYear = 2200
)
// GardenYears lists the calendar years a garden has planting data for, newest
// first, always including the current one.
//
// This exists so the year selector can offer only years that actually hold
// something. A free numeric field invites a typo and an empty view that looks
// like data loss rather than a mistake.
func (s *Service) GardenYears(ctx context.Context, actorID, gardenID int64) ([]int, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, err
}
years, err := s.store.GardenPlantingYears(ctx, gardenID)
if err != nil {
return nil, err
}
current := s.now().UTC().Year()
for _, y := range years {
if y == current {
return years, nil
}
}
// Newest first, and this year is newest unless the data runs into the future.
out := append([]int{current}, years...)
sort.Sort(sort.Reverse(sort.IntSlice(out)))
return out, nil
}
// assembleFull builds the read-only /full payload for an already-authorized
// garden: its objects, active plops (with DerivedCount filled in), and the
// plants those plops reference. Shared by the authenticated GardenFull and the
// unauthenticated PublicGarden read, so both return the identical shape.
// garden as it stands NOW. Shared by the unauthenticated PublicGarden read,
// which never offers a season view.
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
return s.assembleFullFor(ctx, g, nil)
}
// assembleFullFor builds the /full payload: objects, plops (with DerivedCount
// filled in), and the plants those plops reference.
//
// year nil is today's behaviour — active plops only. A year widens it to every
// plop whose time in the ground overlapped that year, which necessarily includes
// plops since removed, so the referenced-plant lookup has to widen with it or
// those plops would render with no icon and no colour.
func (s *Service) assembleFullFor(ctx context.Context, g *domain.Garden, year *int) (*FullGarden, error) {
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
if err != nil {
return nil, err
}
plantings, err := s.store.ListActivePlantingsForGarden(ctx, g.ID)
var plantings []domain.Planting
if year != nil {
plantings, err = s.store.ListPlantingsForGardenYear(ctx, g.ID, *year)
} else {
plantings, err = s.store.ListActivePlantingsForGarden(ctx, g.ID)
}
if err != nil {
return nil, err
}
plants, err := s.store.ListReferencedPlants(ctx, g.ID)
plants, err := s.store.ListReferencedPlants(ctx, g.ID, year)
if err != nil {
return nil, err
}