Let plop notes be written from the UI (#85 item 5)
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
This commit is contained in:
@@ -37,6 +37,8 @@ export function JournalPanel({
|
||||
objects,
|
||||
scopeObjectId,
|
||||
onScopeChange,
|
||||
scopePlantingId = null,
|
||||
onScopePlantingChange,
|
||||
}: {
|
||||
gardenId: number
|
||||
canEdit: boolean
|
||||
@@ -46,13 +48,23 @@ export function JournalPanel({
|
||||
/** 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). Mutually
|
||||
* exclusive with scopeObjectId — the editor store enforces that. */
|
||||
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('')
|
||||
// A plop scope wins over a bed scope — the two are mutually exclusive in the
|
||||
// store, but guard here too so the filter is never double-scoped.
|
||||
const filter = {
|
||||
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||
...(scopePlantingId != null
|
||||
? { plantingId: scopePlantingId }
|
||||
: scopeObjectId != null
|
||||
? { objectId: scopeObjectId }
|
||||
: {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
@@ -83,6 +95,21 @@ export function JournalPanel({
|
||||
)}
|
||||
</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>
|
||||
{onScopePlantingChange && (
|
||||
<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>
|
||||
@@ -121,8 +148,11 @@ export function JournalPanel({
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
objectId={scopeObjectId}
|
||||
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
|
||||
objectId={scopePlantingId != null ? null : scopeObjectId}
|
||||
plantingId={scopePlantingId}
|
||||
scopeLabel={
|
||||
scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -133,9 +163,11 @@ export function JournalPanel({
|
||||
|
||||
{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.'}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -174,10 +206,13 @@ export function JournalPanel({
|
||||
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)
|
||||
@@ -191,7 +226,7 @@ function Composer({
|
||||
if (!text) return
|
||||
setError(null)
|
||||
create.mutate(
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined },
|
||||
{ body: text, observedAt, objectId: objectId ?? undefined, plantingId: plantingId ?? undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setBody('')
|
||||
|
||||
@@ -25,6 +25,7 @@ export function PlopInspector({
|
||||
unit,
|
||||
onChangePlant,
|
||||
onClose,
|
||||
onAddNote,
|
||||
readOnly = false,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
@@ -33,6 +34,9 @@ export function PlopInspector({
|
||||
unit: UnitPref
|
||||
onChangePlant: () => void
|
||||
onClose: () => void
|
||||
/** Scope the journal to this plop and open it — the plop parallel of the bed
|
||||
* inspector's "add note" (#85). Absent for a viewer, who can't write notes. */
|
||||
onAddNote?: () => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const update = useUpdatePlanting(gardenId)
|
||||
@@ -172,6 +176,12 @@ export function PlopInspector({
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly && onAddNote && (
|
||||
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
|
||||
📓 Add a note about this plant
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
+12
-1
@@ -59,6 +59,12 @@ interface EditorState {
|
||||
journalObjectId: number | null
|
||||
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
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
@@ -116,7 +122,11 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||
|
||||
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,
|
||||
armedLotId: null,
|
||||
@@ -147,6 +157,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
railTab: null,
|
||||
seasonYear: null,
|
||||
journalObjectId: null,
|
||||
journalPlantingId: null,
|
||||
mode: DEFAULT_MODE,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -85,6 +85,8 @@ export function GardenEditorPage() {
|
||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
||||
const journalObjectId = useEditorStore((s) => s.journalObjectId)
|
||||
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
|
||||
const journalPlantingId = useEditorStore((s) => s.journalPlantingId)
|
||||
const setJournalPlantingId = useEditorStore((s) => s.setJournalPlantingId)
|
||||
const journalCounts = useJournalCounts(gid)
|
||||
const capabilities = useCapabilities()
|
||||
const journalTotal = useMemo(
|
||||
@@ -501,6 +503,13 @@ export function GardenEditorPage() {
|
||||
readOnly={!canEdit}
|
||||
onChangePlant={() => setPicker('change')}
|
||||
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>
|
||||
@@ -521,6 +530,8 @@ export function GardenEditorPage() {
|
||||
objects={objects}
|
||||
scopeObjectId={journalObjectId}
|
||||
onScopeChange={setJournalObjectId}
|
||||
scopePlantingId={journalPlantingId}
|
||||
onScopePlantingChange={setJournalPlantingId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user