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). */}
diff --git a/web/src/editor/JournalPanel.tsx b/web/src/editor/JournalPanel.tsx new file mode 100644 index 0000000..d37c193 --- /dev/null +++ b/web/src/editor/JournalPanel.tsx @@ -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 ( +
+
+

Journal

+ {objects.length > 0 && ( + + )} +
+ + {canEdit && ( + + )} + + {journal.isPending &&

Loading…

} + {journal.isError && entries.length === 0 && ( + {errorMessage(journal.error, 'Could not load the journal.')} + )} + + {journal.isSuccess && entries.length === 0 && ( +

+ {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.'} +

+ )} + +
    + {entries.map((e) => ( + + ))} +
+ + {journal.hasNextPage && ( + + )} + {journal.isError && entries.length > 0 && ( +

+ {errorMessage(journal.error, "Couldn't load older entries.")} +

+ )} +
+ ) +} + +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(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 ( +
+