// 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 { ApiError, 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 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> => { 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, 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 => 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 => { const { id, ...rest } = vars return journalEntrySchema.parse(await api.patch(`/journal/${id}`, rest)) }, onSuccess: () => invalidateJournal(qc, gardenId), onError: (err) => { // A 409 means the row moved on. Refetch so the component receives the // current version — otherwise a retry resends the stale one and conflicts // forever, which reads as a button that simply doesn't work. if (err instanceof ApiError && err.isConflict) invalidateJournal(qc, gardenId) }, }) } export function useDeleteJournalEntry(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async (id: number): Promise => { 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) // Out-of-range parts would silently roll over — 2026-13-45 becoming February // 2027 — so show the raw string instead of a confidently wrong date. if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return iso return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) }