133 lines
4.8 KiB
TypeScript
133 lines
4.8 KiB
TypeScript
import { useMutation, useQuery } from '@tanstack/react-query'
|
|||
|
|
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||
|
|
import { isRecord } from '../api/errors.ts'
|
||
|
|
import { oneOf } from '../dinners/api.ts'
|
||
|
|
|
||
|
|
export const PLANS_PATH = '/api/v1/plans'
|
||
|
|
|
||
|
|
// planPath is the plan of the week starting on Monday weekStart.
|
||
|
|
export function planPath(weekStart: string): string {
|
||
|
|
return `${PLANS_PATH}/${encodeURIComponent(weekStart)}`
|
||
|
|
}
|
||
|
|
|
||
|
|
// planKey is the query of the plan of the week starting on weekStart.
|
||
|
|
export function planKey(weekStart: string) {
|
||
|
|
return ['plans', weekStart] as const
|
||
|
|
}
|
||
|
|
|
||
|
|
export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
|
||
|
|
|
||
|
|
// PlanStatus is draft until Push to Calendar syncs the plan; an edit of a
|
||
|
|
// synced plan makes it changed.
|
||
|
|
export type PlanStatus = (typeof PLAN_STATUSES)[number]
|
||
|
|
|
||
|
|
// PlanDay is one day of a plan: its household-local date, the dinner
|
||
|
|
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
|
||
|
|
// dinner of known minutes, the instant it must start by.
|
||
|
|
export type PlanDay = {
|
||
|
|
date: string
|
||
|
|
dinner: { id: string; name: string }
|
||
|
|
is_new: boolean
|
||
|
|
start_by: string | null
|
||
|
|
}
|
||
|
|
|
||
|
|
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
|
||
|
|
// Sunday, and the AI's note on the draft, if any. cached says the service
|
||
|
|
// worker answered from its cache: the server may have changed the plan since.
|
||
|
|
export type Plan = {
|
||
|
|
id: string
|
||
|
|
week_start: string
|
||
|
|
status: PlanStatus
|
||
|
|
ai_note: string | null
|
||
|
|
days: PlanDay[]
|
||
|
|
cached: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
// DRAFT_JOB_KIND is the kind of the job drafting a week's plan.
|
||
|
|
export const DRAFT_JOB_KIND = 'draft_week'
|
||
|
|
|
||
|
|
// DraftResult is the draft_week job's result: the week drafted and its plan.
|
||
|
|
export type DraftResult = { week_start: string; plan_id: string }
|
||
|
|
|
||
|
|
function parseDay(where: string, body: unknown): PlanDay {
|
||
|
|
if (
|
||
|
|
!isRecord(body) ||
|
||
|
|
typeof body.date !== 'string' ||
|
||
|
|
!isRecord(body.dinner) ||
|
||
|
|
typeof body.dinner.id !== 'string' ||
|
||
|
|
typeof body.dinner.name !== 'string' ||
|
||
|
|
typeof body.is_new !== 'boolean' ||
|
||
|
|
(body.start_by !== null && typeof body.start_by !== 'string')
|
||
|
|
) {
|
||
|
|
throw new Error(`${where}: response has a day that is not a plan day`)
|
||
|
|
}
|
||
|
|
return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by }
|
||
|
|
}
|
||
|
|
|
||
|
|
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||
|
|
if (
|
||
|
|
!isRecord(body) ||
|
||
|
|
typeof body.id !== 'string' ||
|
||
|
|
body.week_start !== weekStart ||
|
||
|
|
(body.ai_note !== null && typeof body.ai_note !== 'string') ||
|
||
|
|
!Array.isArray(body.days) ||
|
||
|
|
body.days.length !== 7
|
||
|
|
) {
|
||
|
|
throw new Error(`${where}: response is not the plan of the week of ${weekStart}`)
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
id: body.id,
|
||
|
|
week_start: weekStart,
|
||
|
|
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
|
||
|
|
ai_note: body.ai_note,
|
||
|
|
days: body.days.map((day) => parseDay(where, day)),
|
||
|
|
cached,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseDraftResult checks a draft_week job's result, which useJob passes on
|
||
|
|
// unread, is the draft of the week starting on weekStart.
|
||
|
|
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
|
||
|
|
if (!isRecord(body) || body.week_start !== weekStart || typeof body.plan_id !== 'string') {
|
||
|
|
throw new Error(`${where}: result is not the draft of the week of ${weekStart}`)
|
||
|
|
}
|
||
|
|
return { week_start: weekStart, plan_id: body.plan_id }
|
||
|
|
}
|
||
|
|
|
||
|
|
// usePlan is the plan of the week starting on Monday weekStart (GET
|
||
|
|
// /api/v1/plans/{weekStart}), or null when the week has none (404).
|
||
|
|
export function usePlan(weekStart: string) {
|
||
|
|
const { apiRead } = useApi()
|
||
|
|
return useQuery({
|
||
|
|
queryKey: planKey(weekStart),
|
||
|
|
queryFn: async (): Promise<Plan | null> => {
|
||
|
|
const path = planPath(weekStart)
|
||
|
|
let read: ApiRead<unknown>
|
||
|
|
try {
|
||
|
|
read = await apiRead<unknown>(path)
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof ApiError && error.status === 404) return null
|
||
|
|
throw error
|
||
|
|
}
|
||
|
|
return parsePlan(`GET ${path}`, weekStart, read.body, read.cached)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// useStartDraft asks the AI to draft the week starting on weekStart (POST
|
||
|
|
// /api/v1/plans/{weekStart}/draft) and resolves with the id of the
|
||
|
|
// draft_week job, which the server answers with the draft already running
|
||
|
|
// when there is one. Drafting over a plan pushed to Calendar (synced or
|
||
|
|
// changed) needs confirm.
|
||
|
|
export function useStartDraft() {
|
||
|
|
const { apiFetch } = useApi()
|
||
|
|
return useMutation({
|
||
|
|
mutationFn: async ({ weekStart, confirm }: { weekStart: string; confirm: boolean }): Promise<string> => {
|
||
|
|
const path = `${planPath(weekStart)}/draft`
|
||
|
|
const body = await apiFetch<unknown>(path, { method: 'POST', body: confirm ? { confirm: true } : {} })
|
||
|
|
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${path}: response has no jobId`)
|
||
|
|
return body.jobId
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|