Kanban — the todo board, extracted, over as many projects as you like

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>
This commit is contained in:
2026-08-29 03:27:56 +01:00
co-authored by Claude Opus 5
commit 6e54f223e6
12 changed files with 1179 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import type { DragEvent } from 'react';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AlertTriangle, CalendarClock, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TodoItem } from './useKanban';
import { useSelectedProject, useTodoItem, useTodoPatch, useTodoTree } from './useKanban';
/**
* The board over one project's todo folder — one file per item.
*
* ── Carried across from the platform almost unchanged ──
*
* This was `TodoBoard/TodoBoardPanel.tsx`. The columns, the drag handling, the ordering and the detail
* pane are as they were; what changed is where the data comes from — every read and write now names a
* project, because there is more than one.
*
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`, and now `?project=`), per the
* navigation audit: a card is a real link, a filtered board is an addressable page, and back means what
* it should.
*
* Writes go through `PATCH /kanban/item`, which touches frontmatter only and regenerates the index —
* this board never holds state the files do not; a drag is a file edit and a refetch.
*/
const COLUMNS = [
{ status: 'open', label: 'Open' },
{ status: 'doing', label: 'Doing' },
{ status: 'blocked', label: 'Blocked' },
{ status: 'backburner', label: 'Backburner' },
{ status: 'done', label: 'Done' },
] as const;
export const BoardPanel = () => {
// Read from the URL rather than passed in: this panel and the projects panel are siblings that never
// speak to each other, per the navigation audit. The selector writes `?project=`; this reads it.
const { selected: project } = useSelectedProject();
const { data, isLoading, error } = useTodoTree(project);
const patch = useTodoPatch(project);
const [params] = useSearchParams();
const selected = params.get('selected');
const themeFilter = params.get('theme');
const [dragOver, setDragOver] = useState<string | null>(null);
const href = (changes: Record<string, string | null>): string => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(changes)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
const qs = next.toString();
return qs ? `?${qs}` : '.';
};
if (error) {
return (
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to
show.
</div>
);
}
if (isLoading || !data) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading todo/…</div>;
}
const themes = data.themes.filter((t) => t.items.length > 0);
const items = themes.flatMap((t) => t.items);
const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items;
const drop = (status: string) => (ev: DragEvent) => {
ev.preventDefault();
setDragOver(null);
const id = ev.dataTransfer.getData('text/todo-id');
if (id) patch.mutate({ id, status });
};
return (
<div className="flex h-full min-h-0">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
<Link
to={href({ theme: null })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
!themeFilter
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
All · {items.filter((i) => i.status !== 'done').length}
</Link>
{themes.map((t) => {
const open = t.items.filter((i) => i.status !== 'done').length;
return (
<Link
key={t.theme}
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
themeFilter === t.theme
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
{t.heading.split('—')[0]?.trim()} · {open}
</Link>
);
})}
</div>
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{COLUMNS.map((col) => {
const cards = visible
.filter((i) => i.status === col.status)
.sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0));
return (
<div
key={col.status}
onDragOver={(ev) => {
ev.preventDefault();
setDragOver(col.status);
}}
onDragLeave={() => setDragOver(null)}
onDrop={drop(col.status)}
className={cn(
'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30',
dragOver === col.status && 'border-primary bg-primary/5',
)}
>
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
<span>{col.label}</span>
<span>{cards.length}</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{cards.map((item) => (
<TodoCard
key={item.id}
item={item}
href={href({ selected: item.id })}
selected={selected === item.id}
/>
))}
</div>
</div>
);
})}
</div>
</div>
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
</div>
);
};
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
<Link
to={href}
draggable
onDragStart={(ev) => ev.dataTransfer.setData('text/todo-id', item.id)}
className={cn(
'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50',
selected && 'border-primary',
)}
>
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
{item.priority === 'urgent' && (
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
<AlertTriangle className="h-2.5 w-2.5" /> urgent
</span>
)}
{item.due && (
<span className="flex items-center gap-0.5">
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
</span>
)}
<span>{item.created}</span>
</div>
</Link>
);
type TodoDetailProps = { id: string; closeHref: string };
const TodoDetail = ({ id, closeHref }: TodoDetailProps) => {
// Same source as the board above it. Threading it down as a prop would work equally well; reading the
// URL keeps the detail pane openable from a link without the board having rendered first.
const { selected: project } = useSelectedProject();
const { data, isLoading } = useTodoItem(project, id);
const patch = useTodoPatch(project);
const status = data?.meta.status ?? 'open';
return (
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
<div className="flex items-center gap-2">
<select
value={status}
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
>
{COLUMNS.map((c) => (
<option key={c.status} value={c.status}>
{c.label}
</option>
))}
</select>
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</Link>
</div>
</div>
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
{isLoading || !data ? (
<span className="text-sm text-muted-foreground">Reading…</span>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
)}
</div>
</div>
);
};
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { FolderPlus, FolderTree, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useProjects, useSelectedProject } from './useKanban';
/**
* Which board you are looking at, and the folders you can look at.
*
* ── This panel is the whole point of the extraction ──
*
* The version of this in the platform had no such panel and could not have had one: it read
* `resolve(process.cwd(), 'todo')`, so there was exactly one board and no question to ask. Everything
* else in this plugin came across nearly unchanged; this is the part that is new.
*
* ── Selecting writes the URL ──
*
* `?project=<id>`. The board is a sibling that reads it — the two never speak. So a board is linkable,
* survives a reload, and the detail pane can be opened from a link without this panel having rendered.
*/
export const ProjectsPanel = () => {
const { projects, isLoading, add, remove } = useProjects();
const { selected, select } = useSelectedProject();
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [path, setPath] = useState('');
const [confirming, setConfirming] = useState<number | null>(null);
/*
Land on something rather than on nothing.
With projects but none selected the board would render its empty state, which reads as "this project
has no items" when the truth is "no project is chosen". Only when nothing is selected — never
overriding a choice, and never fighting a link somebody followed to a specific board.
*/
useEffect(() => {
if (selected === null && projects.length > 0) select(projects[0]!.id);
}, [selected, projects, select]);
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (!name.trim() || !path.trim()) return;
add.mutate(
{ name: name.trim(), path: path.trim() },
{
onSuccess: (project) => {
setName('');
setPath('');
setAdding(false);
select(project.id);
},
onError: (err: unknown) => toast.error(err instanceof Error ? err.message : 'could not add that folder'),
},
);
};
return (
<nav className="flex size-full flex-col overflow-auto">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Projects</span>
<button
type="button"
onClick={() => setAdding((open) => !open)}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Add a folder"
>
<FolderPlus className="size-3.5" />
</button>
</div>
{adding && (
<form onSubmit={submit} className="flex flex-col gap-2 border-b border-border p-3">
<Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" className="text-sm" />
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="~/projects/thing/todo"
className="font-mono text-xs"
spellCheck={false}
/>
{/* Said before they press it, not after it fails: the folder holding the theme directories is
not the repository root, and the two are one level apart. */}
<p className="text-[11px] text-muted-foreground/70">
The folder that holds the theme directories — usually <code>todo/</code>, not the repo root.
</p>
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={add.isPending || !name.trim() || !path.trim()}>
{add.isPending && <Loader2 className="size-3.5 animate-spin" />}
Add
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setAdding(false)}>
Cancel
</Button>
</div>
</form>
)}
<div className="flex min-h-0 flex-1 flex-col">
{isLoading && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
{!isLoading && projects.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">
No folders yet. Add one that contains your <code>todo/</code> themes.
</p>
)}
{projects.map((project) => (
<div
key={project.id}
className={`group flex items-start gap-2.5 border-b border-border px-3 py-2.5 transition-colors ${
selected === project.id ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<FolderTree className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<button type="button" onClick={() => select(project.id)} className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm">{project.name}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">{project.path}</span>
</button>
{/* Two presses, because the word "delete" beside a folder full of somebody's work should not
be one. The confirmation says what is actually removed — see the route's own note. */}
{confirming === project.id ? (
<button
type="button"
onClick={() => {
remove.mutate(project.id);
setConfirming(null);
}}
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-destructive hover:bg-destructive/10"
title="Remove from this list. The folder and its files are not touched."
>
remove?
</button>
) : (
<button
type="button"
onClick={() => setConfirming(project.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
title="Remove from this list"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
))}
</div>
</nav>
);
};
+18
View File
@@ -0,0 +1,18 @@
import type { LayoutNode } from 'officerdev';
/**
* A narrow project list on the left, the board beside it.
*
* 22/78 rather than the 28/72 most of these use: a project row is a name and a path and nothing in it
* grows, while the board has five columns and wants every pixel. The path wraps rather than the columns
* getting cramped, which is the right way round for a panel you read once and a panel you work in.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'kanban-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'kanban-projects', appType: 'kanban-projects' }, size: 22 },
{ node: { type: 'panel', id: 'kanban-board', appType: 'kanban-board' }, size: 78 },
],
};
+30
View File
@@ -0,0 +1,30 @@
import type { AppRegistryMeta } from 'officerdev';
import { FolderTree, SquareKanban } from 'lucide-react';
import { ProjectsPanel } from './ProjectsPanel';
import { BoardPanel } from './BoardPanel';
/**
* Two panels: which project, and the board of it.
*
* Siblings that never speak to each other — the selector writes `?project=` and the board reads it.
* That is `docs/navigation-audit.md`'s rule, and it is what makes a board linkable and survive a reload.
*
* `availableOnPanel: false` on the selector: a project list on somebody's dashboard would be a list
* that changes a URL parameter nothing on that screen reads. The BOARD is offered, because a board of
* the project you last chose is a perfectly good dashboard panel.
*/
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'kanban-projects',
name: 'Projects',
icon: FolderTree,
component: ProjectsPanel,
availableOnPanel: false,
},
{
key: 'kanban-board',
name: 'Board',
icon: SquareKanban,
component: BoardPanel,
},
];
+106
View File
@@ -0,0 +1,106 @@
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] });
},
});
};