Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #67.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// 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<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),
|
||||
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<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)
|
||||
// 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' })
|
||||
}
|
||||
Reference in New Issue
Block a user