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
25 changed files with 1193 additions and 40 deletions
+4 -2
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)
@@ -109,7 +111,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
- **One rail, tabs inside it.** The inspector, history, and later the journal and chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
- **One rail, tabs inside it.** The inspector, history, journal, and later the chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
## Roadmap
@@ -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.
+2
View File
@@ -84,11 +84,13 @@ 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.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
gardens.GET("/:id/journal/counts", h.getJournalCounts)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
+21
View File
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
}
// getJournalCounts powers the "there are notes about this bed" indicator, which
// has to work while the journal panel is closed — exactly when nothing else has
// loaded the entries. Key "0" is the garden itself.
func (h *handlers) getJournalCounts(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
// JSON object keys are strings; the client parses them back to ids.
out := make(map[string]int, len(counts))
for id, n := range counts {
out[strconv.FormatInt(id, 10)] = n
}
c.JSON(http.StatusOK, gin.H{"counts": out})
}
func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
+43
View File
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
t.Errorf("stranger delete: status %d, want 404", w.Code)
}
}
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
// work while the journal panel is closed, so it's its own endpoint.
func TestJournalCountsAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
counts := func() map[string]any {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
}
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
return c
}
if len(counts()) != 0 {
t.Errorf("a garden with no entries should report no counts")
}
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
c := counts()
// Key "0" is the garden itself; anything else is an object id.
if c["0"] != float64(1) {
t.Errorf("garden-level count = %v, want 1", c["0"])
}
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous counts: status %d, want 401", w.Code)
}
}
+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)
}
+9
View File
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
return entries, false, nil
}
// JournalCounts returns how many entries a garden holds per object (key 0 for
// entries about the garden itself), for an actor who can at least view it.
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, err
}
return s.store.JournalCounts(ctx, gardenID)
}
// CreateJournalEntry writes an entry against a garden the actor can edit.
//
// An entry may target the garden, one of its beds, or one plop in it — and the
+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.
+32
View File
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
return entries, nil
}
// JournalCounts returns how many entries a garden holds, keyed by the object
// they are about — key 0 for entries about the garden itself.
//
// A count per object rather than a flag, and one query rather than one per bed:
// the indicator has to be available when the journal panel is closed, which is
// exactly when nothing else has loaded the entries.
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT COALESCE(object_id, 0), COUNT(*)
FROM journal_entries WHERE garden_id = ?
GROUP BY COALESCE(object_id, 0)`,
gardenID)
if err != nil {
return nil, fmt.Errorf("store: journal counts: %w", err)
}
defer rows.Close()
counts := map[int64]int{}
for rows.Next() {
var objectID int64
var n int
if err := rows.Scan(&objectID, &n); err != nil {
return nil, fmt.Errorf("store: scan journal count: %w", err)
}
counts[objectID] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
}
return counts, nil
}
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/
// planting) and the author are immutable: an entry is a record of an observation,
+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)
+3 -3
View File
@@ -4,9 +4,9 @@ import { cn } from '@/lib/cn'
/**
* The editor's side rail, and the answer to "four things want one rail".
*
* The inspector, history, and (later) the journal and chat panel all want the
* same strip of screen. Rather than each bolting on its own chrome, they are
* tabs in one rail — which keeps the canvas one width instead of a different
* The inspector, history, journal, and (later) the chat panel all want the same
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
* one rail — which keeps the canvas one width instead of a different
* width per panel, and means adding the journal or chat is adding a tab.
*
* Two constraints shaped it:
+18
View File
@@ -31,12 +31,20 @@ export function Inspector({
gardenId,
unit,
onFocus,
onAddNote,
noteCount = 0,
readOnly = false,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
onFocus?: () => void
/** Opens the journal scoped to this object. Two taps from a selected bed to
* typing is the bar; anything more and the log stays empty. */
onAddNote?: () => void
/** How many journal entries are about this object, so the log is discoverable
* from the thing it's about rather than being a panel you have to remember. */
noteCount?: number
readOnly?: boolean
}) {
const update = useUpdateObject(gardenId)
@@ -134,6 +142,16 @@ export function Inspector({
</Button>
)}
{onAddNote && (
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
{noteCount > 0
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
: readOnly
? '📝 No notes'
: '📝 Add note'}
</Button>
)}
{/* A disabled fieldset makes every control below read-only for viewers in
one shot (no per-input disabled). */}
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
+309
View File
@@ -0,0 +1,309 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
formatObservedAt,
today,
useCreateJournalEntry,
useDeleteJournalEntry,
useJournal,
useUpdateJournalEntry,
type JournalEntry,
} from '@/lib/journal'
import type { EditorObject } from './types'
import { objectDisplayName } from './kinds'
/**
* The garden's journal: write an entry, read the season back.
*
* 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 composer is open by default rather than
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
*/
export function JournalPanel({
gardenId,
canEdit,
currentUserId,
isOwner,
objects,
scopeObjectId,
onScopeChange,
}: {
gardenId: number
canEdit: boolean
currentUserId?: number
isOwner: boolean
objects: EditorObject[]
/** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null
onScopeChange: (id: number | null) => void
}) {
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-fg">Journal</h2>
{objects.length > 0 && (
<select
value={scopeObjectId ?? ''}
onChange={(e) => {
const id = Number(e.target.value)
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
}}
className="max-w-[9rem] truncate 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="">Whole garden</option>
{objects.map((o) => (
<option key={o.id} value={o.id}>
{objectDisplayName(o)}
</option>
))}
</select>
)}
</div>
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
/>
)}
{journal.isPending && <p className="text-sm text-muted">Loading</p>}
{journal.isError && entries.length === 0 && (
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
)}
{journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted">
{scopedObject
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p>
)}
<ol className="flex flex-col gap-2">
{entries.map((e) => (
<Entry
key={e.id}
entry={e}
gardenId={gardenId}
objects={objects}
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
canRewrite={canEdit && e.authorId === currentUserId}
/>
))}
</ol>
{journal.hasNextPage && (
<Button
variant="ghost"
className="text-sm"
disabled={journal.isFetchingNextPage}
onClick={() => void journal.fetchNextPage()}
>
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
</Button>
)}
{journal.isError && entries.length > 0 && (
<p className="text-xs text-red-700 dark:text-red-400">
{errorMessage(journal.error, "Couldn't load older entries.")}
</p>
)}
</div>
)
}
function Composer({
gardenId,
objectId,
scopeLabel,
}: {
gardenId: number
objectId: number | null
scopeLabel: string | null
}) {
const create = useCreateJournalEntry(gardenId)
const [body, setBody] = useState('')
// Editable so an observation can be backdated: you write up Saturday on Sunday.
const [observedAt, setObservedAt] = useState(today())
const [error, setError] = useState<string | null>(null)
const submit = () => {
const text = body.trim()
if (!text) return
setError(null)
create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined },
{
onSuccess: () => {
setBody('')
setObservedAt(today())
},
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
},
)
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
<TextArea
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
name="journalBody"
rows={3}
placeholder="What happened?"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Observed"
name="observedAt"
type="date"
value={observedAt}
onChange={(e) => setObservedAt(e.target.value)}
/>
</div>
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
{create.isPending ? 'Saving…' : 'Save note'}
</Button>
</div>
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
</div>
)
}
function Entry({
entry,
gardenId,
objects,
canDelete,
canRewrite,
}: {
entry: JournalEntry
gardenId: number
objects: EditorObject[]
/** May remove it: the author, or the garden owner. */
canDelete: boolean
/** May rewrite the text: the author only — rewriting someone else's
* observation under their name is a different act from removing it. */
canRewrite: boolean
}) {
const update = useUpdateJournalEntry(gardenId)
const del = useDeleteJournalEntry(gardenId)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(entry.body)
const [error, setError] = useState<string | null>(null)
const about = objects.find((o) => o.id === entry.objectId) ?? null
// Re-sync the draft when the entry changes underneath — a refetch after
// someone else's edit, or this entry's own successful save. Skipped while
// editing so a background refetch can't clobber what's being typed; the
// version guard is what catches a genuine collision.
const [syncedBody, setSyncedBody] = useState(entry.body)
if (!editing && entry.body !== syncedBody) {
setSyncedBody(entry.body)
setDraft(entry.body)
}
const save = () => {
const text = draft.trim()
if (!text) {
// Silence here reads as a broken button; say why nothing happened.
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
return
}
setError(null)
update.mutate(
{ id: entry.id, version: entry.version, body: text },
{
onSuccess: () => {
setEditing(false)
setError(null)
},
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
},
)
}
return (
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
{editing ? (
<div className="flex flex-col gap-2">
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
<div className="flex justify-end gap-1">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setDraft(entry.body)
setEditing(false)
setError(null)
}}
>
Cancel
</Button>
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
)}
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
<span aria-hidden>·</span>
<span>{entry.authorName}</span>
{about && (
<>
<span aria-hidden>·</span>
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
</>
)}
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
</p>
{(canRewrite || canDelete) && !editing && (
<div className="mt-1 flex justify-end gap-1">
{canRewrite && (
<button
type="button"
onClick={() => setEditing(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Edit
</button>
)}
{canDelete && (
<button
type="button"
disabled={del.isPending}
onClick={() => {
setError(null) // clear a previous failure so a retry isn't read as another
del.mutate(entry.id, {
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
})
}}
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
>
Delete
</button>
)}
</div>
)}
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
</li>
)
}
+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>
)
}
+20
View File
@@ -32,6 +32,18 @@ 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.
journalObjectId: number | null
setJournalObjectId: (id: 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 +91,12 @@ 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 }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
@@ -104,5 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
liveObject: null,
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
}),
}))
+146
View File
@@ -0,0 +1,146 @@
// Grow journal data layer (#53): entries about a garden, a bed, or one plop.
//
// An entry is what HAPPENED and when, as opposed to the `notes` field on a
// garden/object/plant, which is what the thing IS. Entries accumulate; notes
// overwrite.
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const journalEntrySchema = z.object({
id: z.number(),
gardenId: z.number(),
objectId: z.number().optional(),
plantingId: z.number().optional(),
authorId: z.number(),
authorName: z.string().default(''),
body: z.string(),
// When it happened, which is not when it was written down.
observedAt: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type JournalEntry = z.infer<typeof journalEntrySchema>
const journalPageSchema = z.object({
entries: z.array(journalEntrySchema),
hasMore: z.boolean(),
})
/** Narrows the journal. Undefined fields don't filter. */
export interface JournalFilter {
objectId?: number
plantingId?: number
from?: string
to?: string
}
const PAGE_SIZE = 30
export function journalKey(gardenId: number, filter: JournalFilter = {}) {
return ['gardens', gardenId, 'journal', filter] as const
}
export function useJournal(gardenId: number, filter: JournalFilter = {}, enabled = true) {
return useInfiniteQuery({
queryKey: journalKey(gardenId, filter),
enabled,
initialPageParam: 0,
queryFn: async ({ pageParam }) =>
journalPageSchema.parse(
await api.get(`/gardens/${gardenId}/journal`, {
params: { ...filter, limit: PAGE_SIZE, offset: pageParam },
}),
),
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
})
}
const countsSchema = z.object({ counts: z.record(z.string(), z.number().int().nonnegative()) })
/**
* How many entries each object has, keyed by object id — 0 for the garden
* itself. Its own query rather than derived from the list, because the
* indicator has to work while the journal panel is closed, which is exactly when
* the list hasn't loaded.
*/
export function useJournalCounts(gardenId: number) {
return useQuery({
queryKey: ['gardens', gardenId, 'journal-counts'] as const,
queryFn: async (): Promise<Map<number, number>> => {
const { counts } = countsSchema.parse(await api.get(`/gardens/${gardenId}/journal/counts`))
return new Map(Object.entries(counts).map(([id, n]) => [Number(id), n]))
},
})
}
export interface JournalInput {
objectId?: number
plantingId?: number
body: string
observedAt?: string
}
// Every journal query for this garden, whatever its filter — a new entry may
// belong to several of them at once (the garden's, its bed's, a date range's),
// and working out which is more effort than refetching a short list.
function invalidateJournal(qc: ReturnType<typeof useQueryClient>, gardenId: number) {
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal'] })
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal-counts'] })
}
export function useCreateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: JournalInput): Promise<JournalEntry> =>
journalEntrySchema.parse(await api.post(`/gardens/${gardenId}/journal`, input)),
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
export function useUpdateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: { id: number; version: number; body?: string; observedAt?: string }): Promise<JournalEntry> => {
const { id, ...rest } = vars
return journalEntrySchema.parse(await api.patch(`/journal/${id}`, rest))
},
onSuccess: () => invalidateJournal(qc, gardenId),
onError: (err) => {
// A 409 means the row moved on. Refetch so the component receives the
// current version — otherwise a retry resends the stale one and conflicts
// forever, which reads as a button that simply doesn't work.
if (err instanceof ApiError && err.isConflict) invalidateJournal(qc, gardenId)
},
})
}
export function useDeleteJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/journal/${id}`)
},
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
* are standing in the garden, not the day it is in UTC. */
export function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */
export function formatObservedAt(iso: string): string {
const [y, m, d] = iso.split('-').map(Number)
// Out-of-range parts would silently roll over — 2026-13-45 becoming February
// 2027 — so show the raw string instead of a confidently wrong date.
if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return iso
return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
+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
+78 -6
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { EditorRail, type RailTab } from '@/editor/EditorRail'
import { HistoryPanel } from '@/editor/HistoryPanel'
import { JournalPanel } from '@/editor/JournalPanel'
import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
@@ -12,14 +13,24 @@ 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'
import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle'
@@ -30,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')
@@ -42,6 +60,13 @@ export function GardenEditorPage() {
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const railTab = useEditorStore((s) => s.railTab)
const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalCounts = useJournalCounts(gid)
const journalTotal = useMemo(
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
[journalCounts.data],
)
const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
@@ -68,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
@@ -296,6 +325,13 @@ export function GardenEditorPage() {
setFocusedObject(selectedObject.id)
select(null)
}}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
onAddNote={() => {
// Two taps from a selected bed to typing: scope the journal to it
// and switch tabs. The composer is already open in there.
setJournalObjectId(selectedObject.id)
setRailTab('journal')
}}
/>
) : selectedPlop ? (
<PlopInspector
@@ -312,6 +348,24 @@ export function GardenEditorPage() {
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
),
},
{
id: 'journal',
label: 'Journal',
// The total on the tab is the discoverability half: a garden with a
// season's notes in it shouldn't look identical to an empty one.
badge: journalTotal,
render: () => (
<JournalPanel
gardenId={gid}
canEdit={canEdit}
currentUserId={me.data?.id}
isOwner={isOwner}
objects={objects}
scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId}
/>
),
},
{
id: 'history',
label: 'History',
@@ -330,20 +384,36 @@ 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"
className="mt-2 w-full text-sm"
onClick={() => {
setJournalObjectId(null)
setRailTab(railTab === 'journal' ? null : 'journal')
}}
>
Journal
</Button>
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
>
History
</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">
@@ -380,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 && (