From 7f8b5254d0f6810e94686529788bd6fecc8384b0 Mon Sep 17 00:00:00 2001
From: Steve Dudenhoeffer
Date: Tue, 21 Jul 2026 05:55:45 +0000
Subject: [PATCH] Journal UI: write and read season notes where you're already
looking (#53) (#67)
Co-authored-by: Steve Dudenhoeffer
---
DESIGN.md | 2 +-
internal/api/api.go | 1 +
internal/api/journal.go | 21 ++
internal/api/journal_test.go | 43 ++++
internal/service/journal.go | 9 +
internal/store/journal.go | 32 +++
web/src/editor/EditorRail.tsx | 6 +-
web/src/editor/Inspector.tsx | 18 ++
web/src/editor/JournalPanel.tsx | 309 +++++++++++++++++++++++++++++
web/src/editor/store.ts | 10 +
web/src/lib/journal.ts | 146 ++++++++++++++
web/src/pages/GardenEditorPage.tsx | 44 ++++
12 files changed, 637 insertions(+), 4 deletions(-)
create mode 100644 web/src/editor/JournalPanel.tsx
create mode 100644 web/src/lib/journal.ts
diff --git a/DESIGN.md b/DESIGN.md
index c3654f6..3f50849 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -111,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
diff --git a/internal/api/api.go b/internal/api/api.go
index e4b8a2c..a7e0339 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -90,6 +90,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// 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)
diff --git a/internal/api/journal.go b/internal/api/journal.go
index 60dbb85..4e03c87 100644
--- a/internal/api/journal.go
+++ b/internal/api/journal.go
@@ -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 {
diff --git a/internal/api/journal_test.go b/internal/api/journal_test.go
index 837b6f6..a49b824 100644
--- a/internal/api/journal_test.go
+++ b/internal/api/journal_test.go
@@ -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, "counts@example.com")
+ 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)
+ }
+}
diff --git a/internal/service/journal.go b/internal/service/journal.go
index 99085b3..d0bc7d6 100644
--- a/internal/service/journal.go
+++ b/internal/service/journal.go
@@ -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
diff --git a/internal/store/journal.go b/internal/store/journal.go
index 2ac598d..3792697 100644
--- a/internal/store/journal.go
+++ b/internal/store/journal.go
@@ -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,
diff --git a/web/src/editor/EditorRail.tsx b/web/src/editor/EditorRail.tsx
index 2aa76b4..a836274 100644
--- a/web/src/editor/EditorRail.tsx
+++ b/web/src/editor/EditorRail.tsx
@@ -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:
diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx
index cd2a65c..ffe67a1 100644
--- a/web/src/editor/Inspector.tsx
+++ b/web/src/editor/Inspector.tsx
@@ -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({
)}
+ {onAddNote && (
+
+ )}
+
{/* A disabled fieldset makes every control below read-only for viewers in
one shot (no per-input disabled). */}
),
},
+ {
+ 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: () => (
+
+ ),
+ },
{
id: 'history',
label: 'History',
@@ -362,6 +396,16 @@ export function GardenEditorPage() {
+