Compare commits
3
Commits
c9bf7ee9a3
...
32cbe36817
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32cbe36817 | ||
|
|
d94c967310 | ||
|
|
8c5ddb21ec |
@@ -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 /auth/oidc/login GET /auth/oidc/callback (authorization-code + PKCE)
|
||||||
GET,POST /gardens GET,PATCH,DELETE /gardens/:id
|
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 ← 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)
|
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 /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)
|
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`.
|
- **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.
|
- **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`.
|
- **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
|
## 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.
|
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
||||||
2. Shapes: rect + circle only (polygon reserved in schema).
|
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.
|
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).
|
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.
|
6. 409-and-refetch conflict handling; no real-time sync.
|
||||||
|
|||||||
@@ -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.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
|
||||||
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
||||||
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
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)
|
gardens.POST("/:id/objects", h.createObject)
|
||||||
// The grow journal hangs off a garden even for entries about one bed or one
|
// The grow journal hangs off a garden even for entries about one bed or one
|
||||||
// plop, so it inherits the ordinary garden-role check.
|
// plop, so it inherits the ordinary garden-role check.
|
||||||
gardens.GET("/:id/journal", h.listJournal)
|
gardens.GET("/:id/journal", h.listJournal)
|
||||||
gardens.POST("/:id/journal", h.createJournalEntry)
|
gardens.POST("/:id/journal", h.createJournalEntry)
|
||||||
|
gardens.GET("/:id/journal/counts", h.getJournalCounts)
|
||||||
|
|
||||||
// Sharing (owner-managed; a recipient may remove their own share).
|
// Sharing (owner-managed; a recipient may remove their own share).
|
||||||
gardens.GET("/:id/shares", h.listShares)
|
gardens.GET("/:id/shares", h.listShares)
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
|
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) {
|
func (h *handlers) createJournalEntry(c *gin.Context) {
|
||||||
gardenID, ok := parseIDParam(c, "id")
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
|
|||||||
t.Errorf("stranger delete: status %d, want 404", w.Code)
|
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
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -171,15 +172,47 @@ func (h *handlers) deleteObject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
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) {
|
func (h *handlers) getGardenFull(c *gin.Context) {
|
||||||
gardenID, ok := parseIDParam(c, "id")
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeServiceError(c, err)
|
writeServiceError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, full)
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
|||||||
t.Errorf("settings not carried over: %+v vs source %+v", dup, src)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull(copy): %v", err)
|
t.Fatalf("GardenFull(copy): %v", err)
|
||||||
}
|
}
|
||||||
@@ -328,7 +328,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The source is untouched by the copy.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull(source): %v", err)
|
t.Fatalf("GardenFull(source): %v", err)
|
||||||
}
|
}
|
||||||
@@ -365,7 +365,7 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CopyGarden: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
|
|||||||
return entries, false, nil
|
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.
|
// 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
|
// An entry may target the garden, one of its beds, or one plop in it — and the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"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.
|
// 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)
|
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// assembleFull builds the read-only /full payload for an already-authorized
|
||||||
// garden: its objects, active plops (with DerivedCount filled in), and the
|
// garden as it stands NOW. Shared by the unauthenticated PublicGarden read,
|
||||||
// plants those plops reference. Shared by the authenticated GardenFull and the
|
// which never offers a season view.
|
||||||
// unauthenticated PublicGarden read, so both return the identical shape.
|
|
||||||
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
|
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)
|
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plants, err := s.store.ListReferencedPlants(ctx, g.ID)
|
plants, err := s.store.ListReferencedPlants(ctx, g.ID, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func TestObjectCrossUserIsNotFound(t *testing.T) {
|
|||||||
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
|
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||||
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
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)
|
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 {
|
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
|
||||||
t.Fatalf("DeleteObject: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
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.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})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,3 +270,221 @@ func TestGardenFullShape(t *testing.T) {
|
|||||||
t.Errorf("plants = %v, want empty non-nil", full.Plants)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ type DescribePlanting struct {
|
|||||||
// object's active plantings (plant, effective count, rough location) — for a
|
// 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.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func TestClearObject(t *testing.T) {
|
|||||||
if n < 1 {
|
if n < 1 {
|
||||||
t.Fatalf("cleared %d, want ≥ 1", n)
|
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 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
|
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// It shows in /full, with a derived count.
|
// 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 {
|
if len(full.Plantings) != 1 {
|
||||||
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
|
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 {
|
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
|
||||||
t.Fatalf("soft-remove: %v", err)
|
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 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
|
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 {
|
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
|
||||||
t.Fatalf("DeletePlanting: %v", err)
|
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 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CopyGarden: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestGardenACLMatrix(t *testing.T) {
|
|||||||
actor int64
|
actor int64
|
||||||
want error
|
want error
|
||||||
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
}{{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)
|
wantErr(t, "readFull", err, tc.want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ func TestUpdateAndRemoveShare(t *testing.T) {
|
|||||||
t.Errorf("self-leave: %v", err)
|
t.Errorf("self-leave: %v", err)
|
||||||
}
|
}
|
||||||
// After leaving, the garden is invisible again.
|
// 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)
|
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
// A non-participant can't remove someone else's share.
|
// A non-participant can't remove someone else's share.
|
||||||
|
|||||||
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
|
|||||||
return entries, nil
|
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.
|
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
|
||||||
// Same contract as every other mutable resource. The target (garden/object/
|
// 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,
|
// planting) and the author are immutable: an entry is a record of an observation,
|
||||||
|
|||||||
@@ -62,6 +62,68 @@ func queryPlantings(ctx context.Context, q queryer, query string, args ...any) (
|
|||||||
return plantings, nil
|
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
|
// ListActivePlantingsForObject returns an object's currently-planted plops
|
||||||
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
||||||
// stacking new plops inside existing ones.
|
// stacking new plops inside existing ones.
|
||||||
|
|||||||
@@ -24,18 +24,30 @@ func scanPlant(s scanner) (*domain.Plant, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListReferencedPlants returns the distinct plants used by a garden's active
|
// ListReferencedPlants returns the distinct plants used by a garden's plantings
|
||||||
// plantings — the catalog subset the editor needs to render them. Always a
|
// — the catalog subset the editor needs to render them. Always a non-nil slice.
|
||||||
// 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.
|
// year nil means the active plops only. A year widens it to the plops that were
|
||||||
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
|
// 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,
|
rows, err := d.sql.QueryContext(ctx,
|
||||||
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
|
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
|
||||||
JOIN plantings pl ON pl.plant_id = p.id
|
JOIN plantings pl ON pl.plant_id = p.id
|
||||||
JOIN garden_objects o ON o.id = pl.object_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`,
|
ORDER BY p.id`,
|
||||||
gardenID,
|
args...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list referenced plants: %w", err)
|
return nil, fmt.Errorf("store: list referenced plants: %w", err)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { cn } from '@/lib/cn'
|
|||||||
/**
|
/**
|
||||||
* The editor's side rail, and the answer to "four things want one rail".
|
* 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
|
* The inspector, history, journal, and (later) the chat panel all want the same
|
||||||
* same strip of screen. Rather than each bolting on its own chrome, they are
|
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
|
||||||
* tabs in one rail — which keeps the canvas one width instead of a different
|
* 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.
|
* width per panel, and means adding the journal or chat is adding a tab.
|
||||||
*
|
*
|
||||||
* Two constraints shaped it:
|
* Two constraints shaped it:
|
||||||
|
|||||||
@@ -31,12 +31,20 @@ export function Inspector({
|
|||||||
gardenId,
|
gardenId,
|
||||||
unit,
|
unit,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
onAddNote,
|
||||||
|
noteCount = 0,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
object: EditorObject
|
object: EditorObject
|
||||||
gardenId: number
|
gardenId: number
|
||||||
unit: UnitPref
|
unit: UnitPref
|
||||||
onFocus?: () => void
|
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
|
readOnly?: boolean
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateObject(gardenId)
|
const update = useUpdateObject(gardenId)
|
||||||
@@ -134,6 +142,16 @@ export function Inspector({
|
|||||||
</Button>
|
</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
|
{/* A disabled fieldset makes every control below read-only for viewers in
|
||||||
one shot (no per-input disabled). */}
|
one shot (no per-input disabled). */}
|
||||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,18 @@ interface EditorState {
|
|||||||
railTab: string | null
|
railTab: string | null
|
||||||
setRailTab: (tab: string | null) => void
|
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
|
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||||
armedPlant: Plant | null
|
armedPlant: Plant | null
|
||||||
@@ -79,6 +91,12 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
railTab: null,
|
railTab: null,
|
||||||
setRailTab: (tab) => set({ railTab: tab }),
|
setRailTab: (tab) => set({ railTab: tab }),
|
||||||
|
|
||||||
|
seasonYear: null,
|
||||||
|
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||||
|
|
||||||
|
journalObjectId: null,
|
||||||
|
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||||
|
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||||
|
|
||||||
@@ -104,5 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
liveObject: null,
|
liveObject: null,
|
||||||
livePlanting: null,
|
livePlanting: null,
|
||||||
railTab: null,
|
railTab: null,
|
||||||
|
seasonYear: null,
|
||||||
|
journalObjectId: null,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -86,6 +86,38 @@ export function useGardenFull(gardenId: number) {
|
|||||||
return useQuery(gardenFullQueryOptions(gardenId))
|
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
|
* 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
|
* carries only the garden's *referenced* plants (ListReferencedPlants), so a
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||||
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
||||||
import { HistoryPanel } from '@/editor/HistoryPanel'
|
import { HistoryPanel } from '@/editor/HistoryPanel'
|
||||||
|
import { JournalPanel } from '@/editor/JournalPanel'
|
||||||
import { Inspector } from '@/editor/Inspector'
|
import { Inspector } from '@/editor/Inspector'
|
||||||
import { PlopInspector } from '@/editor/PlopInspector'
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
@@ -12,14 +13,24 @@ import { Palette } from '@/editor/Palette'
|
|||||||
import { SeedTray } from '@/editor/SeedTray'
|
import { SeedTray } from '@/editor/SeedTray'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
|
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore } from '@/editor/store'
|
||||||
import type { EditorGarden } from '@/editor/types'
|
import type { EditorGarden } from '@/editor/types'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
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 { toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
@@ -30,7 +41,14 @@ export function GardenEditorPage() {
|
|||||||
const gid = Number(gardenId)
|
const gid = Number(gardenId)
|
||||||
const { focus } = routeApi.useSearch()
|
const { focus } = routeApi.useSearch()
|
||||||
const navigate = routeApi.useNavigate()
|
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()
|
const me = useMe()
|
||||||
usePageTitle(full.data?.garden.name ?? 'Garden')
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||||
|
|
||||||
@@ -42,6 +60,13 @@ export function GardenEditorPage() {
|
|||||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||||
const railTab = useEditorStore((s) => s.railTab)
|
const railTab = useEditorStore((s) => s.railTab)
|
||||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
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 armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
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
|
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||||
// it. Ownership is the authoritative ownerId==me check.
|
// it. Ownership is the authoritative ownerId==me check.
|
||||||
const gd = full.data?.garden
|
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
|
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
|
// Latest values for the mount-once nudge keydown handler to read, so its effect
|
||||||
@@ -296,6 +325,13 @@ export function GardenEditorPage() {
|
|||||||
setFocusedObject(selectedObject.id)
|
setFocusedObject(selectedObject.id)
|
||||||
select(null)
|
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 ? (
|
) : selectedPlop ? (
|
||||||
<PlopInspector
|
<PlopInspector
|
||||||
@@ -312,6 +348,24 @@ export function GardenEditorPage() {
|
|||||||
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
|
<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',
|
id: 'history',
|
||||||
label: 'History',
|
label: 'History',
|
||||||
@@ -330,20 +384,36 @@ export function GardenEditorPage() {
|
|||||||
Share
|
Share
|
||||||
</Button>
|
</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>
|
<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 />}
|
{focusedObjectId == null && canEdit && <Palette />}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mt-2 w-full text-sm"
|
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')}
|
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
|
||||||
>
|
>
|
||||||
History
|
History
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 && (
|
{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">
|
<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">
|
<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>
|
</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). */}
|
{/* Empty-state hints (non-interactive overlays). */}
|
||||||
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user