3 Commits
Author SHA1 Message Date
steve cac26286b1 Merge pull request 'Let plop notes be written from the UI (#85 item 5)' (#123) from feat/plop-journal-notes into main
Build image / build-and-push (push) Successful in 13s
2026-07-23 01:42:02 +00:00
steveandClaude Opus 4.8 f14875557b Address #123 review: viewers can read plop notes; tighten the API
Build image / build-and-push (push) Successful in 11s
- 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
steveandClaude Opus 4.8 7015148edf Let plop notes be written from the UI (#85 item 5)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 5m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m30s
journal_entries.planting_id was modelled, accepted by the API, and the
JournalPanel already rendered a "planting" badge for such entries — but
nothing in the UI ever created one. A badge for a state the UI couldn't
produce.

Give the plop the same "add note" affordance the bed inspector has:
- PlopInspector gains an onAddNote button ("📓 Add a note about this
  plant"), shown only to an editor (a viewer can't write notes).
- The editor store gains a journalPlantingId scope beside journalObjectId.
  The two are mutually exclusive — each setter clears the other — so the
  journal filter is never double-scoped.
- JournalPanel filters by plantingId when that scope is set, shows a
  "Notes about one planting · Show all" banner, and its composer attaches
  new notes to the plop. Empty-state copy updated to match.

Two taps from a selected plant to typing, mirroring the bed flow. No
backend change — the API already accepted plantingId.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:28:12 -04:00
4 changed files with 75 additions and 9 deletions
+40 -8
View File
@@ -37,6 +37,8 @@ export function JournalPanel({
objects, objects,
scopeObjectId, scopeObjectId,
onScopeChange, onScopeChange,
scopePlantingId,
onScopePlantingChange,
}: { }: {
gardenId: number gardenId: number
canEdit: boolean canEdit: boolean
@@ -46,19 +48,30 @@ export function JournalPanel({
/** Which bed the panel is filtered to, if any. */ /** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null scopeObjectId: number | null
onScopeChange: (id: number | null) => void 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 // Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter. // from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('') const [from, setFrom] = useState('')
const [to, setTo] = 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 = { const filter = {
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}), ...(scopePlantingId != null
? { plantingId: scopePlantingId }
: scopeObjectId != null
? { objectId: scopeObjectId }
: {}),
...(from ? { from } : {}), ...(from ? { from } : {}),
...(to ? { to } : {}), ...(to ? { to } : {}),
} }
const journal = useJournal(gardenId, filter) const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? [] const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -83,6 +96,19 @@ export function JournalPanel({
)} )}
</div> </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"> <div className="flex items-center gap-2 text-xs text-muted">
<label className="flex items-center gap-1"> <label className="flex items-center gap-1">
<span>From</span> <span>From</span>
@@ -121,8 +147,9 @@ export function JournalPanel({
{canEdit && ( {canEdit && (
<Composer <Composer
gardenId={gardenId} gardenId={gardenId}
objectId={scopeObjectId} objectId={scopePlantingId != null ? null : scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null} plantingId={scopePlantingId}
scopeLabel={scopeLabel}
/> />
)} )}
@@ -133,9 +160,11 @@ export function JournalPanel({
{journal.isSuccess && entries.length === 0 && ( {journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted"> <p className="text-sm text-muted">
{scopedObject {scopePlantingId != null
? `Nothing written about ${objectDisplayName(scopedObject)} yet.` ? 'Nothing written about this planting 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.'} : 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> </p>
)} )}
@@ -174,10 +203,13 @@ export function JournalPanel({
function Composer({ function Composer({
gardenId, gardenId,
objectId, objectId,
plantingId = null,
scopeLabel, scopeLabel,
}: { }: {
gardenId: number gardenId: number
objectId: number | null objectId: number | null
/** When set, the note attaches to this plop rather than a bed (#85). */
plantingId?: number | null
scopeLabel: string | null scopeLabel: string | null
}) { }) {
const create = useCreateJournalEntry(gardenId) const create = useCreateJournalEntry(gardenId)
@@ -191,7 +223,7 @@ function Composer({
if (!text) return if (!text) return
setError(null) setError(null)
create.mutate( create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined }, { body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
{ {
onSuccess: () => { onSuccess: () => {
setBody('') setBody('')
+12
View File
@@ -25,6 +25,7 @@ export function PlopInspector({
unit, unit,
onChangePlant, onChangePlant,
onClose, onClose,
onAddNote,
readOnly = false, readOnly = false,
}: { }: {
plop: EditorPlanting plop: EditorPlanting
@@ -33,6 +34,11 @@ export function PlopInspector({
unit: UnitPref unit: UnitPref
onChangePlant: () => void onChangePlant: () => void
onClose: () => void onClose: () => void
/** Scope the journal to this plop and open it — the plop parallel of the bed
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
* notes, like the bed inspector does); the journal's composer is separately
* gated on edit rights, so a viewer just sees the entries. */
onAddNote?: () => void
readOnly?: boolean readOnly?: boolean
}) { }) {
const update = useUpdatePlanting(gardenId) const update = useUpdatePlanting(gardenId)
@@ -172,6 +178,12 @@ export function PlopInspector({
/> />
</fieldset> </fieldset>
{onAddNote && (
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
</Button>
)}
{!readOnly && ( {!readOnly && (
<Button <Button
variant="ghost" variant="ghost"
+12 -1
View File
@@ -59,6 +59,12 @@ interface EditorState {
journalObjectId: number | null journalObjectId: number | null
setJournalObjectId: (id: number | null) => void setJournalObjectId: (id: number | null) => void
// Which single plop the journal is filtered to, if any — the parallel of
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
// (the setters clear each other), so the journal filter is never ambiguous.
journalPlantingId: number | null
setJournalPlantingId: (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
@@ -116,7 +122,11 @@ export const useEditorStore = create<EditorState>((set) => ({
setSeasonYear: (year) => set({ seasonYear: year }), setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null, journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }), // Scoping to a bed clears any plop scope, so only one is ever active.
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
journalPlantingId: null,
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
armedPlant: null, armedPlant: null,
armedLotId: null, armedLotId: null,
@@ -147,6 +157,7 @@ export const useEditorStore = create<EditorState>((set) => ({
railTab: null, railTab: null,
seasonYear: null, seasonYear: null,
journalObjectId: null, journalObjectId: null,
journalPlantingId: null,
mode: DEFAULT_MODE, mode: DEFAULT_MODE,
}), }),
})) }))
+11
View File
@@ -86,6 +86,8 @@ export function GardenEditorPage() {
const setRailTab = useEditorStore((s) => s.setRailTab) const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId) const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId) const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalPlantingId = useEditorStore((s) => s.journalPlantingId)
const setJournalPlantingId = useEditorStore((s) => s.setJournalPlantingId)
const journalCounts = useJournalCounts(gid) const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities() const capabilities = useCapabilities()
const journalTotal = useMemo( const journalTotal = useMemo(
@@ -502,6 +504,13 @@ export function GardenEditorPage() {
readOnly={!canEdit} readOnly={!canEdit}
onChangePlant={() => setPicker('change')} onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)} onClose={() => selectPlanting(null)}
onAddNote={() => {
// Parity with the bed inspector: scope the journal to this plop and
// open it. The plop stays selected, so the selection effect keeps the
// inspector reachable when you switch back.
setJournalPlantingId(selectedPlop.id)
setRailTab('journal')
}}
/> />
) : ( ) : (
<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>
@@ -522,6 +531,8 @@ export function GardenEditorPage() {
objects={objects} objects={objects}
scopeObjectId={journalObjectId} scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId} onScopeChange={setJournalObjectId}
scopePlantingId={journalPlantingId}
onScopePlantingChange={setJournalPlantingId}
/> />
), ),
}, },