was `/todos` in the platform: a board, an API and a tree reader pointed at `resolve(process.cwd(), 'todo')`. that is not a setting — it is the belief that there is one project and it is the one the server happens to have been started in. THE READER CAME ACROSS UNCHANGED except for that one line. the frontmatter parser that never rewrites a body, the ordering, the index writer — all of it as it was, and its 8 tests came too and still pass. everything that changed is that the directory is now a parameter. `writeIndex` no longer writes to `process.cwd()` either. that was the same assumption wearing a different hat, and with two projects it would have had every board overwriting one file. the map goes beside the folder it describes. A PROJECT IS A NAME AND A PATH, and paths are confined to the owner's home by the same three checks av-club uses — absolute, realpath BEFORE the containment test, and `root` or `root + sep` rather than a bare startsWith. spelled out again rather than cross-referenced, because the next person to add a path-taking route reads the file they are in. it stores NO ITEMS. one table, a list of folders. uninstalling with "delete its data" ticked loses the list and never a line of anybody's work — which is the property the markdown format was chosen for. `/projects` is owner-only: pointing this at a directory is a statement about the filesystem. moving a card is not, so a member granted `kanban` can work any board they can see. removing a project removes the ROW. the route says so in its response and the button says so in its tooltip, because "delete" beside a folder full of somebody's work should not be ambiguous. README records the seam honestly: the platform keeps its own todo/ and its CLI, so there are two copies of the reader and they will drift. the alternative was making the platform's own backlog depend on a plugin being installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
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=<id>`, 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<KanbanProject>('/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<string, string>; 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] });
|
|
},
|
|
});
|
|
};
|