diff --git a/DESIGN.md b/DESIGN.md index d4bd5f5..c3654f6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -55,6 +55,8 @@ POST /auth/register | /auth/login | /auth/logout GET /auth/me GET /auth GET /auth/oidc/login GET /auth/oidc/callback (authorization-code + PKCE) GET,POST /gardens GET,PATCH,DELETE /gardens/:id GET /gardens/:id/full ← one-shot editor load: garden + objects + plantings + referenced plants +GET /gardens/:id/full?year=YYYY ← season view: plops whose time in the ground overlapped that year +GET /gardens/:id/years ← years this garden has planting data for GET /gardens/:id/history ← change sets, newest first (paginated) POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts it skipped POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link) @@ -127,7 +129,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac 1. Plop `count` derived from area ÷ spacing², explicit override allowed. 2. Shapes: rect + circle only (polygon reserved in schema). -3. Seasons = planted/removed dates; a scenario/year-plan layer is future work these dates don't block. +3. Seasons = planted/removed dates. `?year=` filters `/full` to the plops whose `[planted_at, removed_at]` interval overlapped that calendar year, so garlic planted in October and pulled in July shows in both — and undated plops show in every year, since everything predating the feature has a null `planted_at`. Deliberately **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. Past seasons are read-only; #46's garden copy is the scenario-planning half. 4. Emoji plant icons (zero assets); SVG icon set later if wanted. 5. No background/satellite image tracing (cheap to add later as a garden background field). 6. 409-and-refetch conflict handling; no real-time sync. diff --git a/internal/api/api.go b/internal/api/api.go index 3e3c6f8..e4b8a2c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -84,6 +84,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first + gardens.GET("/:id/years", h.getGardenYears) // years with planting data gardens.POST("/:id/objects", h.createObject) // The grow journal hangs off a garden even for entries about one bed or one // plop, so it inherits the ordinary garden-role check. diff --git a/internal/api/objects.go b/internal/api/objects.go index 7b52d75..7cbff6f 100644 --- a/internal/api/objects.go +++ b/internal/api/objects.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -171,15 +172,47 @@ func (h *handlers) deleteObject(c *gin.Context) { c.Status(http.StatusNoContent) } +// getGardenFull serves the editor's one-shot load. ?year=YYYY switches it to the +// season view: every plop whose time in the ground overlapped that calendar +// year, INCLUDING ones since removed. +// +// Undated plantings are returned for every year. Everything planted before this +// feature existed has a null planted_at, so excluding them would empty every +// garden the moment a year was picked — technically defensible, useless in +// practice. Without the param the behaviour is exactly what it has always been. func (h *handlers) getGardenFull(c *gin.Context) { gardenID, ok := parseIDParam(c, "id") if !ok { return } - full, err := h.svc.GardenFull(c.Request.Context(), mustActor(c).ID, gardenID) + var year *int + if raw := c.Query("year"); raw != "" { + y, err := strconv.Atoi(raw) + if err != nil { + writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "year must be a four-digit year") + return + } + year = &y + } + full, err := h.svc.GardenFull(c.Request.Context(), mustActor(c).ID, gardenID, year) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusOK, full) } + +// getGardenYears lists the years this garden has planting data for, so the year +// selector can offer only years that hold something. +func (h *handlers) getGardenYears(c *gin.Context) { + gardenID, ok := parseIDParam(c, "id") + if !ok { + return + } + years, err := h.svc.GardenYears(c.Request.Context(), mustActor(c).ID, gardenID) + if err != nil { + writeServiceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"years": years}) +} diff --git a/internal/service/gardens_test.go b/internal/service/gardens_test.go index d9d4fc2..a7af2d3 100644 --- a/internal/service/gardens_test.go +++ b/internal/service/gardens_test.go @@ -293,7 +293,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) { t.Errorf("settings not carried over: %+v vs source %+v", dup, src) } - full, err := s.GardenFull(ctx, owner, dup.ID) + full, err := s.GardenFull(ctx, owner, dup.ID, nil) if err != nil { t.Fatalf("GardenFull(copy): %v", err) } @@ -328,7 +328,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) { } // The source is untouched by the copy. - srcFull, err := s.GardenFull(ctx, owner, src.ID) + srcFull, err := s.GardenFull(ctx, owner, src.ID, nil) if err != nil { t.Fatalf("GardenFull(source): %v", err) } @@ -365,7 +365,7 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) { if err != nil { t.Fatalf("CopyGarden: %v", err) } - full, err := s.GardenFull(ctx, owner, dup.ID) + full, err := s.GardenFull(ctx, owner, dup.ID, nil) if err != nil { t.Fatalf("GardenFull: %v", err) } diff --git a/internal/service/objects.go b/internal/service/objects.go index c2357ca..a1a26ad 100644 --- a/internal/service/objects.go +++ b/internal/service/objects.go @@ -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 } diff --git a/internal/service/objects_test.go b/internal/service/objects_test.go index 1871305..b6f7b41 100644 --- a/internal/service/objects_test.go +++ b/internal/service/objects_test.go @@ -220,7 +220,7 @@ func TestObjectCrossUserIsNotFound(t *testing.T) { if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) { t.Errorf("bob delete err = %v, want ErrNotFound", err) } - if _, err := s.GardenFull(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) { + if _, err := s.GardenFull(context.Background(), bob, g.ID, nil); !errors.Is(err, domain.ErrNotFound) { t.Errorf("bob /full err = %v, want ErrNotFound", err) } } @@ -236,7 +236,7 @@ func TestDeleteObject(t *testing.T) { if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil { t.Fatalf("DeleteObject: %v", err) } - full, err := s.GardenFull(context.Background(), owner, g.ID) + full, err := s.GardenFull(context.Background(), owner, g.ID, nil) if err != nil { t.Fatalf("GardenFull: %v", err) } @@ -252,7 +252,7 @@ func TestGardenFullShape(t *testing.T) { s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100}) s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60}) - full, err := s.GardenFull(context.Background(), owner, g.ID) + full, err := s.GardenFull(context.Background(), owner, g.ID, nil) if err != nil { t.Fatalf("GardenFull: %v", err) } @@ -270,3 +270,221 @@ func TestGardenFullShape(t *testing.T) { t.Errorf("plants = %v, want empty non-nil", full.Plants) } } + +// TestSeasonViewIncludesRemovedAndSpanningPlantings — a season is a date range +// over data that already existed; the point is seeing what WAS there. +func TestSeasonViewIncludesRemovedAndSpanningPlantings(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 15) + ctx := context.Background() + + // Planted and pulled inside 2025. + past, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: -50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-04-01"), + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdatePlanting(ctx, owner, past.ID, PlantingPatch{ + SetRemovedAt: true, RemovedAt: strPtr("2025-09-01"), + }, past.Version); err != nil { + t.Fatalf("remove: %v", err) + } + + // Garlic: in the ground October 2025, pulled July 2026. Belongs to both. + spanning, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-10-15"), + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdatePlanting(ctx, owner, spanning.ID, PlantingPatch{ + SetRemovedAt: true, RemovedAt: strPtr("2026-07-10"), + }, spanning.Version); err != nil { + t.Fatalf("remove: %v", err) + } + + // Undated: predates the feature, belongs to every year. + undated, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-01-01"), + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdatePlanting(ctx, owner, undated.ID, PlantingPatch{ + SetPlantedAt: true, PlantedAt: nil, + }, undated.Version); err != nil { + t.Fatalf("clear planted_at: %v", err) + } + + ids := func(year int) map[int64]bool { + t.Helper() + full, err := s.GardenFull(ctx, owner, g.ID, &year) + if err != nil { + t.Fatalf("GardenFull(%d): %v", year, err) + } + out := map[int64]bool{} + for _, p := range full.Plantings { + out[p.ID] = true + } + return out + } + + y2025 := ids(2025) + if !y2025[past.ID] || !y2025[spanning.ID] || !y2025[undated.ID] { + t.Errorf("2025 = %v, want all three", y2025) + } + y2026 := ids(2026) + if y2026[past.ID] { + t.Error("a planting pulled in 2025 showed up in 2026") + } + if !y2026[spanning.ID] { + t.Error("a planting spanning the year boundary is missing from 2026") + } + if !y2026[undated.ID] { + t.Error("an undated planting must appear in every year") + } + y2024 := ids(2024) + if y2024[past.ID] || y2024[spanning.ID] { + t.Errorf("2024 predates both dated plantings but returned %v", y2024) + } + if !y2024[undated.ID] { + t.Error("an undated planting must appear in every year, including 2024") + } + + // Without a year, /full is exactly what it always was: active plops only. + now, err := s.GardenFull(ctx, owner, g.ID, nil) + if err != nil { + t.Fatalf("GardenFull(now): %v", err) + } + if len(now.Plantings) != 1 || now.Plantings[0].ID != undated.ID { + t.Errorf("live view = %+v, want only the one still in the ground", now.Plantings) + } + + // A past season's plops must still render, so their plants come along even + // though none of them is active. + if len(ids(2025)) > 0 { + y := 2025 + full, _ := s.GardenFull(ctx, owner, g.ID, &y) + if len(full.Plants) == 0 { + t.Error("season view returned plops with no plants to render them") + } + } +} + +// TestGardenYearsOffersOnlyYearsWithData +func TestGardenYearsOffersOnlyYearsWithData(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 15) + ctx := context.Background() + + pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2023-05-01"), + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{ + SetRemovedAt: true, RemovedAt: strPtr("2024-06-01"), + }, pl.Version); err != nil { + t.Fatalf("remove: %v", err) + } + + years, err := s.GardenYears(ctx, owner, g.ID) + if err != nil { + t.Fatalf("GardenYears: %v", err) + } + current := s.now().UTC().Year() + want := map[int]bool{2023: true, 2024: true, current: true} + if len(years) != len(want) { + t.Fatalf("years = %v, want exactly %v", years, want) + } + for _, y := range years { + if !want[y] { + t.Errorf("unexpected year %d in %v", y, years) + } + } + // Newest first, so the selector reads in the order people scan. + for i := 1; i < len(years); i++ { + if years[i-1] < years[i] { + t.Errorf("years not newest-first: %v", years) + } + } +} + +// TestSeasonYearIsBounded — a typo'd year should be refused, not answered with a +// confidently empty garden. +func TestSeasonYearIsBounded(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + ctx := context.Background() + + for _, y := range []int{0, 202, 20260, -5} { + if _, err := s.GardenFull(ctx, owner, g.ID, &y); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("year %d accepted (err=%v)", y, err) + } + } +} + +// TestSeasonPlantsAreScopedToTheYear — the plant list has to match the plops it +// is there to render. A garden with a decade of history shouldn't load a decade +// of catalog to draw one season, and a season shouldn't carry plants that had +// nothing in the ground that year. +func TestSeasonPlantsAreScopedToTheYear(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + ctx := context.Background() + + oldPlant := seedOwnPlant(t, s, owner, 15) + newPlant, err := s.CreatePlant(ctx, owner, PlantInput{ + Name: "Later", Category: domain.CategoryHerb, SpacingCM: 20, Color: "#4a7c3f", Icon: "🌱", + }) + if err != nil { + t.Fatalf("CreatePlant: %v", err) + } + + plant2020, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: oldPlant.ID, XCM: -40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2020-04-01"), + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if _, err := s.UpdatePlanting(ctx, owner, plant2020.ID, PlantingPatch{ + SetRemovedAt: true, RemovedAt: strPtr("2020-09-01"), + }, plant2020.Version); err != nil { + t.Fatalf("remove: %v", err) + } + if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ + PlantID: newPlant.ID, XCM: 40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2026-04-01"), + }); err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + + names := func(year int) []string { + t.Helper() + full, err := s.GardenFull(ctx, owner, g.ID, &year) + if err != nil { + t.Fatalf("GardenFull(%d): %v", year, err) + } + out := []string{} + for _, p := range full.Plants { + out = append(out, p.Name) + } + return out + } + if got := names(2020); len(got) != 1 || got[0] != oldPlant.Name { + t.Errorf("2020 plants = %v, want just %q", got, oldPlant.Name) + } + if got := names(2026); len(got) != 1 || got[0] != "Later" { + t.Errorf("2026 plants = %v, want just \"Later\"", got) + } +} diff --git a/internal/service/ops.go b/internal/service/ops.go index 9fd6d0a..90be1d4 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -318,7 +318,7 @@ type DescribePlanting struct { // 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) + full, err := s.GardenFull(ctx, actorID, gardenID, nil) if err != nil { return nil, err } diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index 733ac95..b078188 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -169,7 +169,7 @@ func TestClearObject(t *testing.T) { if n < 1 { t.Fatalf("cleared %d, want ≥ 1", n) } - full, _ := s.GardenFull(ctx, owner, g.ID) + full, _ := s.GardenFull(ctx, owner, g.ID, nil) if len(full.Plantings) != 0 { t.Errorf("plantings after clear = %d, want 0", len(full.Plantings)) } diff --git a/internal/service/plantings_test.go b/internal/service/plantings_test.go index 437511b..8a4f0f9 100644 --- a/internal/service/plantings_test.go +++ b/internal/service/plantings_test.go @@ -227,7 +227,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) { }) // It shows in /full, with a derived count. - full, _ := s.GardenFull(context.Background(), owner, g.ID) + full, _ := s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 1 { t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings)) } @@ -244,7 +244,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) { PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil { t.Fatalf("soft-remove: %v", err) } - full, _ = s.GardenFull(context.Background(), owner, g.ID) + full, _ = s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 0 { t.Errorf("removed plop still in /full: %d", len(full.Plantings)) } @@ -371,7 +371,7 @@ func TestDeletePlanting(t *testing.T) { if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil { t.Fatalf("DeletePlanting: %v", err) } - full, _ := s.GardenFull(context.Background(), owner, g.ID) + full, _ := s.GardenFull(context.Background(), owner, g.ID, nil) if len(full.Plantings) != 0 { t.Errorf("planting still present after delete: %d", len(full.Plantings)) } diff --git a/internal/service/seed_lots_test.go b/internal/service/seed_lots_test.go index e7a2eed..a9cb75d 100644 --- a/internal/service/seed_lots_test.go +++ b/internal/service/seed_lots_test.go @@ -461,7 +461,7 @@ func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) { if err != nil { t.Fatalf("CopyGarden: %v", err) } - full, err := s.GardenFull(ctx, owner, copied.ID) + full, err := s.GardenFull(ctx, owner, copied.ID, nil) if err != nil { t.Fatalf("GardenFull: %v", err) } diff --git a/internal/service/shares_test.go b/internal/service/shares_test.go index c256eda..0c03e45 100644 --- a/internal/service/shares_test.go +++ b/internal/service/shares_test.go @@ -54,7 +54,7 @@ func TestGardenACLMatrix(t *testing.T) { actor int64 want error }{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} { - _, err := s.GardenFull(ctx, tc.actor, g.ID) + _, err := s.GardenFull(ctx, tc.actor, g.ID, nil) wantErr(t, "readFull", err, tc.want) } @@ -194,7 +194,7 @@ func TestUpdateAndRemoveShare(t *testing.T) { t.Errorf("self-leave: %v", err) } // After leaving, the garden is invisible again. - if _, err := s.GardenFull(ctx, friend, g.ID); !errors.Is(err, domain.ErrNotFound) { + if _, err := s.GardenFull(ctx, friend, g.ID, nil); !errors.Is(err, domain.ErrNotFound) { t.Errorf("after leaving, read = %v, want ErrNotFound", err) } // A non-participant can't remove someone else's share. diff --git a/internal/store/plantings.go b/internal/store/plantings.go index b2077ba..838e8f4 100644 --- a/internal/store/plantings.go +++ b/internal/store/plantings.go @@ -62,6 +62,68 @@ func queryPlantings(ctx context.Context, q queryer, query string, args ...any) ( return plantings, nil } +// ListPlantingsForGardenYear returns every plop in a garden whose time in the +// ground overlaps the given calendar year — the season view (#54). +// +// The interval is [planted_at, removed_at]: planted before the year ended, and +// either still in the ground or removed after the year began. Garlic planted in +// October and pulled the following July therefore appears in BOTH years, which +// is what actually happened. +// +// Undated plantings are always included. Every planting 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 — an unhelpful way to be +// technically correct. +func (d *DB) ListPlantingsForGardenYear(ctx context.Context, gardenID int64, year int) ([]domain.Planting, error) { + start := fmt.Sprintf("%04d-01-01", year) + end := fmt.Sprintf("%04d-12-31", year) + return queryPlantings(ctx, d.sql, + `SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl + JOIN garden_objects o ON o.id = pl.object_id + WHERE o.garden_id = ? + AND (pl.planted_at IS NULL OR pl.planted_at <= ?) + AND (pl.removed_at IS NULL OR pl.removed_at >= ?) + ORDER BY pl.id`, + gardenID, end, start) +} + +// GardenPlantingYears lists the calendar years a garden has planting data for, +// newest first — every year touched by a [planted_at, removed_at] interval. +// +// Undated plantings contribute no year, deliberately: they belong to every year +// the selector offers, so adding one here would be noise. +func (d *DB) GardenPlantingYears(ctx context.Context, gardenID int64) ([]int, error) { + rows, err := d.sql.QueryContext(ctx, + `SELECT DISTINCT CAST(substr(y, 1, 4) AS INTEGER) AS year FROM ( + SELECT pl.planted_at AS y FROM plantings pl + JOIN garden_objects o ON o.id = pl.object_id + WHERE o.garden_id = ? AND pl.planted_at IS NOT NULL + UNION + SELECT pl.removed_at AS y FROM plantings pl + JOIN garden_objects o ON o.id = pl.object_id + WHERE o.garden_id = ? AND pl.removed_at IS NOT NULL + ) + ORDER BY year DESC`, + gardenID, gardenID) + if err != nil { + return nil, fmt.Errorf("store: garden planting years: %w", err) + } + defer rows.Close() + + years := []int{} + for rows.Next() { + var y int + if err := rows.Scan(&y); err != nil { + return nil, fmt.Errorf("store: scan planting year: %w", err) + } + years = append(years, y) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate planting years: %w", err) + } + return years, nil +} + // ListActivePlantingsForObject returns an object's currently-planted plops // (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid // stacking new plops inside existing ones. diff --git a/internal/store/plants.go b/internal/store/plants.go index f6b06b9..149ffb5 100644 --- a/internal/store/plants.go +++ b/internal/store/plants.go @@ -24,18 +24,30 @@ func scanPlant(s scanner) (*domain.Plant, error) { return &p, nil } -// ListReferencedPlants returns the distinct plants used by a garden's active -// plantings — the catalog subset the editor needs to render them. Always a -// non-nil slice (empty when the garden has no active plantings). This is the -// /full read side; full plant CRUD/seeding lives in plants.go / plantings.go. -func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) { +// ListReferencedPlants returns the distinct plants used by a garden's plantings +// — the catalog subset the editor needs to render them. Always a non-nil slice. +// +// year nil means the active plops only. A year widens it to the plops that were +// in the ground THAT YEAR, which is what a past season needs: a plant pulled +// last July is not active, but its plops still have to render with the right +// icon and colour. It is scoped to the same year as the plops rather than to the +// garden's whole history, so the two selections always agree and a decade-old +// garden doesn't load a decade of catalog to draw one season. +func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64, year *int) ([]domain.Plant, error) { + where := ` AND pl.removed_at IS NULL` + args := []any{gardenID} + if year != nil { + where = ` AND (pl.planted_at IS NULL OR pl.planted_at <= ?) + AND (pl.removed_at IS NULL OR pl.removed_at >= ?)` + args = append(args, fmt.Sprintf("%04d-12-31", *year), fmt.Sprintf("%04d-01-01", *year)) + } rows, err := d.sql.QueryContext(ctx, `SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p JOIN plantings pl ON pl.plant_id = p.id JOIN garden_objects o ON o.id = pl.object_id - WHERE o.garden_id = ? AND pl.removed_at IS NULL + WHERE o.garden_id = ?`+where+` ORDER BY p.id`, - gardenID, + args..., ) if err != nil { return nil, fmt.Errorf("store: list referenced plants: %w", err) diff --git a/web/src/editor/SeasonPicker.tsx b/web/src/editor/SeasonPicker.tsx new file mode 100644 index 0000000..1da9c0e --- /dev/null +++ b/web/src/editor/SeasonPicker.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/lib/cn' + +/** + * Which season the canvas is showing. + * + * "Now" is the live, editable garden — what is in the ground today. A year is a + * read-only view of everything whose time in the ground overlapped that calendar + * year, plops since removed included. They are genuinely different questions: + * "what's growing" versus "what did I grow", and only the first one is editable. + * + * Only years the garden holds data for are offered. 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. + */ +export function SeasonPicker({ + years, + value, + onChange, +}: { + years: number[] + value: number | null + onChange: (year: number | null) => void +}) { + if (years.length === 0) return null + return ( + + ) +} + +/** + * The banner that stops you thinking you're looking at now. Editing the past by + * accident is the failure mode this whole feature introduces, so the state is + * stated rather than implied by a dropdown you set a while ago — with the way + * back to the live garden right next to it. + */ +export function SeasonBanner({ year, onExit }: { year: number; onExit: () => void }) { + return ( +
+ Viewing {year} + + read-only, including plantings since removed + + +
+ ) +} diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index d479bcd..c9c51f7 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -32,6 +32,12 @@ interface EditorState { railTab: string | null setRailTab: (tab: string | null) => void + // Which season the canvas is showing: null is "now" (live and editable), a + // year is a read-only view of what was in the ground that year. Ephemeral — + // which year you were last looking at is not a property of the garden. + seasonYear: number | null + setSeasonYear: (year: number | null) => void + // The plant armed for placing plops (set after the PlantPicker choice); stays // armed for repeat-placement until cleared (Escape / done). null = not placing. armedPlant: Plant | null @@ -79,6 +85,9 @@ export const useEditorStore = create((set) => ({ railTab: null, setRailTab: (tab) => set({ railTab: tab }), + seasonYear: null, + setSeasonYear: (year) => set({ seasonYear: year }), + armedPlant: null, setArmedPlant: (p) => set({ armedPlant: p }), @@ -104,5 +113,6 @@ export const useEditorStore = create((set) => ({ liveObject: null, livePlanting: null, railTab: null, + seasonYear: null, }), })) diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index 322414c..e2e4125 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -86,6 +86,38 @@ export function useGardenFull(gardenId: number) { return useQuery(gardenFullQueryOptions(gardenId)) } +// A past season is a SEPARATE query under its own key, deliberately. The +// optimistic mutations below all patch gardenFullKey(gardenId); if a year were +// folded into that key they would write into whichever season happened to be on +// screen. Season views are read-only, so they never need that machinery — and +// keeping them out of it means they can't accidentally join it. +export const gardenSeasonKey = (gardenId: number, year: number) => + ['garden-season', gardenId, year] as const + +/** A garden as it stood in a given calendar year: every plop whose time in the + * ground overlapped it, including ones since removed. Read-only. */ +export function useGardenSeason(gardenId: number, year: number | null) { + return useQuery({ + queryKey: gardenSeasonKey(gardenId, year ?? 0), + enabled: year !== null, + queryFn: async (): Promise => + fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`, { params: { year } })), + }) +} + +const yearsSchema = z.object({ years: z.array(z.number().int()) }) + +/** The years this garden holds planting data for, newest first, always + * including the current one. Offering only years with data keeps the selector + * from inviting a typo that produces a confidently empty garden. */ +export function useGardenYears(gardenId: number) { + return useQuery({ + queryKey: ['garden-years', gardenId] as const, + queryFn: async (): Promise => + yearsSchema.parse(await api.get(`/gardens/${gardenId}/years`)).years, + }) +} + /** * Ensure a plant is present in the /full cache's `plants` list. The full payload * carries only the garden's *referenced* plants (ListReferencedPlants), so a diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index 92541a2..b9bd201 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -12,12 +12,21 @@ import { Palette } from '@/editor/Palette' import { SeedTray } from '@/editor/SeedTray' import { ClearBedModal } from '@/editor/ClearBedModal' import { EditorHint } from '@/editor/EditorHint' +import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker' import { objectDisplayName } from '@/editor/kinds' import { useEditorStore } from '@/editor/store' import type { EditorGarden } from '@/editor/types' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { useMe } from '@/lib/auth' -import { toEditorObject, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects' +import { + toEditorObject, + useEnsurePlantInFull, + useGardenFull, + useGardenSeason, + useGardenYears, + useUpdateObject, + useUpdatePlanting, +} from '@/lib/objects' import { toEditorPlanting } from '@/lib/plantings' import type { Plant } from '@/lib/plants' import { useSeedTray } from '@/lib/seedTray' @@ -30,7 +39,14 @@ export function GardenEditorPage() { const gid = Number(gardenId) const { focus } = routeApi.useSearch() const navigate = routeApi.useNavigate() - const full = useGardenFull(gid) + const seasonYear = useEditorStore((s) => s.seasonYear) + const setSeasonYear = useEditorStore((s) => s.setSeasonYear) + // Two queries, one shown. The live one stays mounted so returning to "now" is + // instant and so the optimistic mutation cache it owns is never displaced. + const live = useGardenFull(gid) + const season = useGardenSeason(gid, seasonYear) + const full = seasonYear === null ? live : season + const years = useGardenYears(gid) const me = useMe() usePageTitle(full.data?.garden.name ?? 'Garden') @@ -68,7 +84,11 @@ export function GardenEditorPage() { // Role gating, computed before the effects/returns so the nudge handler can use // it. Ownership is the authoritative ownerId==me check. const gd = full.data?.garden - const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor') + // A past season is read-only: it is a record of what happened, and editing it + // would be editing the past. This is the single gate — the palette, inspector, + // nudging, placement and drag handles all key off canEdit. + const canEdit = + seasonYear === null && gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor') const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id // Latest values for the mount-once nudge keydown handler to read, so its effect @@ -330,9 +350,14 @@ export function GardenEditorPage() { Share )} - {!canEdit && ( + {!canEdit && seasonYear === null && (

👁 View only

)} + {years.data && years.data.length > 0 && ( +
+ +
+ )} {focusedObjectId == null && canEdit && }