3 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 32cbe36817 Address Gadfly review on the journal UI
Build image / build-and-push (push) Successful in 17s
The prop names were backwards. `canEdit` meant "may delete" and `canWrite` meant
"may rewrite the text" — which is the opposite of how either word reads, on a
component where the distinction is the entire permission model. They are now
canDelete and canRewrite.

An open edit draft never re-synced with its entry, so a refetch after someone
else's edit left a stale draft and a Cancel that restored the wrong text. It now
resyncs when the entry changes underneath, but only while not editing, so a
background refetch can't clobber what's being typed — the version guard is what
catches a genuine collision.

A 409 from an edit had no handling, so the component kept its stale version and
every retry conflicted again: a button that just doesn't work. A conflict now
invalidates, so the fresh version arrives.

Saving an empty edit returned silently, which also reads as a broken button. It
says why nothing happened, and points at Delete if that's what was meant. A
failed delete's error now clears on retry rather than making a second attempt
look like a second failure.

formatObservedAt let out-of-range parts roll over — 2026-13-45 rendering as
February 2027, confidently wrong. It shows the raw string instead.

Also: the scope select guards against a non-numeric value becoming a NaN
objectId and a cryptic 400; the journal total is memoized rather than
spread-and-reduced every render; EditorRail's doc comment no longer calls the
journal "later" now that it's here; and a static class list stopped being split
across two cn() arguments for no reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:55:14 -04:00
steveandClaude Opus 4.8 d94c967310 Journal UI: write and read season notes where you're already looking (#53)
happens. Notes get written standing in the garden holding a phone, usually
one-handed — if it takes more than a couple of taps from looking at a bed to
typing a sentence, the log stays empty.

So the journal is a tab in the rail #49 settled, and selecting a bed puts a
"📝 Add note" button in the inspector that scopes the journal to that bed and
switches to it. The composer is already open in there rather than behind an
"add" button, which makes it two taps from a selected bed to typing.

The observed date defaults to today in the VIEWER's timezone, not UTC — "today"
means the day you are standing in the garden — and stays editable, because you
write up Saturday's observations on Sunday.

Discoverability is the other half. A log you have to remember exists is a log
nobody reads, so the inspector button carries the note count for that bed and
the Journal tab carries the garden's total: a garden with a season's notes in it
no longer looks identical to an empty one. Those counts come from their own
endpoint, deliberately — the indicator has to work while the journal panel is
closed, which is exactly when the entry list hasn't loaded.

Permissions match the service: anyone who can edit the garden can write, the
author can edit their own text, and the author or the garden owner can delete.
A viewer sees the entries and the count but no composer.

The empty state says what the journal is for rather than just "no entries",
since it starts empty for a whole season's worth of gardens and the reason to
start is the thing worth saying.

Closes #53

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 01:55:14 -04:00
steve 8c5ddb21ec 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]>
2026-07-21 05:47:34 +00:00
17 changed files with 556 additions and 36 deletions
+3 -1
View File
@@ -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.
+1
View File
@@ -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.
+34 -1
View File
@@ -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})
}
+3 -3
View File
@@ -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)
}
+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
}
+221 -3
View File
@@ -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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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, "[email protected]")
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)
}
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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))
}
+3 -3
View File
@@ -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))
}
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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.
+62
View File
@@ -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.
+19 -7
View File
@@ -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)
+69
View File
@@ -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 (
<label className="flex items-center gap-1.5 text-xs text-muted">
<span>Season</span>
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
className={cn(
'rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none',
'focus-visible:ring-2 focus-visible:ring-accent/40',
)}
>
<option value="">Now</option>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</label>
)
}
/**
* 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 (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-sm">
<span className="font-medium text-amber-800 dark:text-amber-300">Viewing {year}</span>
<span className="text-xs text-amber-800/80 dark:text-amber-300/80">
read-only, including plantings since removed
</span>
<button
type="button"
onClick={onExit}
className="ml-auto rounded px-1.5 py-0.5 text-xs font-medium text-amber-900 underline outline-none hover:no-underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-amber-200"
>
Back to now
</button>
</div>
)
}
+10
View File
@@ -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
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
@@ -85,6 +91,9 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null,
setRailTab: (tab) => set({ railTab: tab }),
seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
@@ -113,6 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
liveObject: null,
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
}),
}))
+32
View File
@@ -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<FullGarden> =>
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<number[]> =>
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
+34 -6
View File
@@ -13,12 +13,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 { useJournalCounts } from '@/lib/journal'
@@ -32,7 +41,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')
@@ -77,7 +93,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
@@ -364,9 +384,14 @@ export function GardenEditorPage() {
Share
</Button>
)}
{!canEdit && (
{!canEdit && seasonYear === null && (
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
)}
{years.data && years.data.length > 0 && (
<div className="mb-2">
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
</div>
)}
{focusedObjectId == null && canEdit && <Palette />}
<Button
variant="ghost"
@@ -387,7 +412,8 @@ export function GardenEditorPage() {
</Button>
</div>
<div className="relative min-h-0 flex-1">
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
{focusedObject && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
@@ -424,7 +450,9 @@ export function GardenEditorPage() {
))}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
</div>
{/* Empty-state hints (non-interactive overlays). */}
{canEdit && focusedObjectId == null && objects.length === 0 && (