Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
// 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' })
|
|
}
|