import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; /** * The board's data, per project. * * ── Which project is in the URL ── * * `?project=`, per `docs/navigation-audit.md`: what is on screen is addressable, so a board someone * is looking at can be linked, survives a reload, and both panels read the same answer without talking * to each other. The platform's version had no such state because it had no such choice. */ export type TodoItem = { id: string; title: string; theme: string; status: string; created: string; priority: string; due: string; order: number; file: string; }; export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] }; export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number }; export type KanbanProject = { id: number; name: string; path: string; createdAt: string }; /** The selected project id, or null. Null is a real state: a fresh install has no projects at all. */ export const useSelectedProject = () => { const [params, setParams] = useSearchParams(); const raw = Number(params.get('project')); const selected = Number.isInteger(raw) && raw > 0 ? raw : null; const select = (id: number) => { const next = new URLSearchParams(params); next.set('project', String(id)); setParams(next, { replace: true }); }; return { selected, select }; }; export const useProjects = () => { const client = useClient(); const queryClient = useQueryClient(); const query = useQuery({ queryKey: ['kanban', 'projects'], queryFn: () => client.get<{ projects: KanbanProject[] }>('/kanban/projects'), }); const add = useMutation({ mutationFn: (input: { name: string; path: string }) => client.post('/kanban/projects', input), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['kanban', 'projects'] }), }); const remove = useMutation({ mutationFn: (id: number) => client.delete<{ ok: boolean }>(`/kanban/projects/${id}`), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['kanban', 'projects'] }), }); return { projects: query.data?.projects ?? EMPTY, isLoading: query.isLoading, add, remove }; }; // Module-level so an empty result is the same array every render — a fresh `[]` is a new identity and // re-runs every effect that depends on it. const EMPTY: KanbanProject[] = []; export const useTodoTree = (project: number | null) => { const client = useClient(); return useQuery({ queryKey: ['kanban', 'tree', project], queryFn: () => client.get<{ themes: TodoTheme[] }>(`/kanban?project=${project}`), enabled: project !== null, // The tree is files on disk that agents edit while the board is open. Polling keeps it honest // without a watcher — the cheapest thing that works, and what the format's own contract allows. refetchInterval: 30_000, }); }; export const useTodoItem = (project: number | null, id: string | null) => { const client = useClient(); return useQuery({ queryKey: ['kanban', 'item', project, id], queryFn: () => client.get<{ meta: Record; body: string }>( `/kanban/item?project=${project}&id=${encodeURIComponent(id!)}`, ), enabled: project !== null && id !== null, }); }; export const useTodoPatch = (project: number | null) => { const client = useClient(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>(`/kanban/item?project=${project}`, patch), onSuccess: (_data, patch) => { void queryClient.invalidateQueries({ queryKey: ['kanban', 'tree', project] }); void queryClient.invalidateQueries({ queryKey: ['kanban', 'item', project, patch.id] }); }, }); };