Journal UI: write and read season notes where you're already looking (#53)

happens. Notes get written standing in the garden holding a phone, usually
one-handed — if it takes more than a couple of taps from looking at a bed to
typing a sentence, the log stays empty.

So the journal is a tab in the rail #49 settled, and selecting a bed puts a
"📝 Add note" button in the inspector that scopes the journal to that bed and
switches to it. The composer is already open in there rather than behind an
"add" button, which makes it two taps from a selected bed to typing.

The observed date defaults to today in the VIEWER's timezone, not UTC — "today"
means the day you are standing in the garden — and stays editable, because you
write up Saturday's observations on Sunday.

Discoverability is the other half. A log you have to remember exists is a log
nobody reads, so the inspector button carries the note count for that bed and
the Journal tab carries the garden's total: a garden with a season's notes in it
no longer looks identical to an empty one. Those counts come from their own
endpoint, deliberately — the indicator has to work while the journal panel is
closed, which is exactly when the entry list hasn't loaded.

Permissions match the service: anyone who can edit the garden can write, the
author can edit their own text, and the author or the garden owner can delete.
A viewer sees the entries and the count but no composer.

The empty state says what the journal is for rather than just "no entries",
since it starts empty for a whole season's worth of gardens and the reason to
start is the thing worth saying.

Closes #53

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 01:55:14 -04:00
co-authored by Claude Opus 4.8
parent 8c5ddb21ec
commit d94c967310
11 changed files with 603 additions and 1 deletions
+18
View File
@@ -31,12 +31,20 @@ export function Inspector({
gardenId,
unit,
onFocus,
onAddNote,
noteCount = 0,
readOnly = false,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
onFocus?: () => void
/** Opens the journal scoped to this object. Two taps from a selected bed to
* typing is the bar; anything more and the log stays empty. */
onAddNote?: () => void
/** How many journal entries are about this object, so the log is discoverable
* from the thing it's about rather than being a panel you have to remember. */
noteCount?: number
readOnly?: boolean
}) {
const update = useUpdateObject(gardenId)
@@ -134,6 +142,16 @@ export function Inspector({
</Button>
)}
{onAddNote && (
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
{noteCount > 0
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
: readOnly
? '📝 No notes'
: '📝 Add note'}
</Button>
)}
{/* A disabled fieldset makes every control below read-only for viewers in
one shot (no per-input disabled). */}
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
+290
View File
@@ -0,0 +1,290 @@
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 { cn } from '@/lib/cn'
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) => onScopeChange(e.target.value === '' ? null : Number(e.target.value))}
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}
canEdit={canEdit && (e.authorId === currentUserId || isOwner)}
canWrite={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,
canEdit,
canWrite,
}: {
entry: JournalEntry
gardenId: number
objects: EditorObject[]
/** May remove it: the author, or the garden owner. */
canEdit: boolean
/** May rewrite the text: the author only. */
canWrite: 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
const save = () => {
const text = draft.trim()
if (!text) return
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>
{(canWrite || canEdit) && !editing && (
<div className="mt-1 flex justify-end gap-1">
{canWrite && (
<button
type="button"
onClick={() => setEditing(true)}
className={cn('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>
)}
{canEdit && (
<button
type="button"
disabled={del.isPending}
onClick={() =>
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>
)
}
+10
View File
@@ -38,6 +38,12 @@ interface EditorState {
seasonYear: number | null
setSeasonYear: (year: number | null) => void
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
@@ -88,6 +94,9 @@ export const useEditorStore = create<EditorState>((set) => ({
seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
@@ -114,5 +123,6 @@ export const useEditorStore = create<EditorState>((set) => ({
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
}),
}))