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:
@@ -0,0 +1,138 @@
|
||||
// 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 { 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),
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
if (!y || !m || !d) return iso
|
||||
return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
Reference in New Issue
Block a user