Files
pansy/web/src/editor/JournalPanel.tsx
T
steveandClaude Opus 4.8 f14875557b
Build image / build-and-push (push) Successful in 11s
Address #123 review: viewers can read plop notes; tighten the API
- The "add note" affordance no longer hides from viewers (it claimed
  parity with the bed inspector but gated on !readOnly). A viewer now sees
  "📓 Notes about this plant" and can open the plop's journal to read it;
  the composer stays edit-gated, so they can't write. Real parity now.
- onScopePlantingChange is required, matching onScopeChange (its bed twin),
  so a caller can't pass a plop scope with no way to clear it. Dropped the
  now-dead guard on the "Show all" button.
- Pulled the plop-over-bed scope-label priority into one `scopeLabel`,
  shared by the filter's sibling logic and the composer, so they can't
  drift; trimmed the invariant comment that was restated a third time.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:41:11 -04:00

390 lines
13 KiB
TypeScript

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'
// Shared styling for the small From/To date inputs, so the two stay in step and
// don't drift from each other.
const dateInputClass =
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
/**
* 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,
scopePlantingId,
onScopePlantingChange,
}: {
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
/** Which single plop the panel is filtered to, if any (#85). The store keeps
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
scopePlantingId: number | null
onScopePlantingChange: (id: number | null) => void
}) {
// Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
// One source of scope priority — plop over bed — for both the filter and the
// composer's label, so they can't drift.
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
const filter = {
...(scopePlantingId != null
? { plantingId: scopePlantingId }
: scopeObjectId != null
? { objectId: scopeObjectId }
: {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
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>
{scopePlantingId != null && (
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
<span className="text-accent-strong">Notes about one planting</span>
<button
type="button"
onClick={() => onScopePlantingChange(null)}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Show all
</button>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted">
<label className="flex items-center gap-1">
<span>From</span>
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => setFrom(e.target.value)}
className={dateInputClass}
/>
</label>
<label className="flex items-center gap-1">
<span>To</span>
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => setTo(e.target.value)}
className={dateInputClass}
/>
</label>
{(from || to) && (
<button
type="button"
onClick={() => {
setFrom('')
setTo('')
}}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Clear
</button>
)}
</div>
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopePlantingId != null ? null : scopeObjectId}
plantingId={scopePlantingId}
scopeLabel={scopeLabel}
/>
)}
{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">
{scopePlantingId != null
? 'Nothing written about this planting yet.'
: 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,
plantingId = null,
scopeLabel,
}: {
gardenId: number
objectId: number | null
/** When set, the note attaches to this plop rather than a bed (#85). */
plantingId?: 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, plantingId: plantingId ?? 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>
)
}