Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffe32d58cf | ||
|
|
83a651e659 |
@@ -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`.
|
- **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, 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.
|
- **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.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
// 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,27 +71,6 @@ 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,46 +155,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,15 +84,6 @@ 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
|
||||||
|
|||||||
@@ -123,38 +123,6 @@ 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,
|
||||||
|
|||||||
@@ -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, journal, and (later) the chat panel all want the same
|
* The inspector, history, and (later) the journal and chat panel all want the
|
||||||
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
|
* same strip of screen. Rather than each bolting on its own chrome, they are
|
||||||
* one rail — which keeps the canvas one width instead of a different
|
* 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.
|
* width per panel, and means adding the journal or chat is adding a tab.
|
||||||
*
|
*
|
||||||
* Two constraints shaped it:
|
* Two constraints shaped it:
|
||||||
|
|||||||
@@ -31,20 +31,12 @@ 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)
|
||||||
@@ -142,16 +134,6 @@ 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">
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -38,12 +38,6 @@ interface EditorState {
|
|||||||
seasonYear: number | null
|
seasonYear: number | null
|
||||||
setSeasonYear: (year: number | null) => void
|
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
|
||||||
@@ -97,9 +91,6 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
seasonYear: null,
|
seasonYear: null,
|
||||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||||
|
|
||||||
journalObjectId: null,
|
|
||||||
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
|
||||||
|
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
armedLotId: null,
|
armedLotId: null,
|
||||||
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||||
@@ -128,6 +119,5 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
livePlanting: null,
|
livePlanting: null,
|
||||||
railTab: null,
|
railTab: null,
|
||||||
seasonYear: null,
|
seasonYear: null,
|
||||||
journalObjectId: null,
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
// 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' })
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ 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'
|
||||||
@@ -30,7 +29,6 @@ import {
|
|||||||
} from '@/lib/objects'
|
} 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 { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
@@ -61,13 +59,6 @@ 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)
|
||||||
|
|
||||||
@@ -337,13 +328,6 @@ 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
|
||||||
@@ -360,24 +344,6 @@ 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',
|
||||||
@@ -408,16 +374,6 @@ export function GardenEditorPage() {
|
|||||||
<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
|
||||||
|
|||||||
Reference in New Issue
Block a user