todos: a backburner column, and the parked items on it
backburner joins TODO_STATUSES and the board's columns; the three items in the backburner theme move onto the status, so parked is now a lane rather than a folder you have to remember to open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
**90 open, 29 closed**, across 31 themes.
|
**90 open, 29 closed**, across 31 themes.
|
||||||
|
|
||||||
| Theme | Open | Closed |
|
| Theme | Open | Closed |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||||
| [Agents coordinating agents](todo/agent-coordination/) | 2 | 0 |
|
| [Agents coordinating agents](todo/agent-coordination/) | 2 | 0 |
|
||||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { join, resolve } from 'node:path';
|
|||||||
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||||
|
|
||||||
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'done'] as const;
|
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'backburner', 'done'] as const;
|
||||||
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
||||||
|
|
||||||
export type TodoItem = {
|
export type TodoItem = {
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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 './useTodos';
|
||||||
|
import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board over `todo/` — the platform's own backlog, one file per item (todo/README.md).
|
||||||
|
*
|
||||||
|
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`), 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 /todos/item, which touches frontmatter only and regenerates TODO.md — 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 TodoBoardPanel = () => {
|
||||||
|
const { data, isLoading, error } = useTodoTree();
|
||||||
|
const patch = useTodoPatch();
|
||||||
|
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) => {
|
||||||
|
const { data, isLoading } = useTodoItem(id);
|
||||||
|
const patch = useTodoPatch();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+3
-2
@@ -38,8 +38,9 @@ process everywhere, report which host is behind…
|
|||||||
|
|
||||||
- **`id`** — `<theme>/<slug>`, and it is the filename. Stable: rename the title freely, leave the id
|
- **`id`** — `<theme>/<slug>`, and it is the filename. Stable: rename the title freely, leave the id
|
||||||
alone. Anything pointing at an item points at this.
|
alone. Anything pointing at an item points at this.
|
||||||
- **`status`** — `open` or `done` today. The board will want more (`doing`, `blocked`); adding one is a
|
- **`status`** — `open`, `doing`, `blocked`, `backburner` or `done`. The board (`/todos`) renders one
|
||||||
new value here, not a new file or a new directory.
|
column per value; adding one is a new entry in `TODO_STATUSES` (`api/todos/todo-tree.ts`), not a new
|
||||||
|
file or directory. `backburner` is parked-not-dead — still counted as open by the index.
|
||||||
- **`created`** — the date the item was raised. **`date_source`** says how much to trust it:
|
- **`created`** — the date the item was raised. **`date_source`** says how much to trust it:
|
||||||
`stated` (the prose named a date — true), `section` (inherited from its old section heading — close),
|
`stated` (the prose named a date — true), `section` (inherited from its old section heading — close),
|
||||||
`assumed` (unknown, floored at 2026-08-20, the day before the split — a bounded "before today"
|
`assumed` (unknown, floored at 2026-08-20, the day before the split — a bounded "before today"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: backburner/four-foreign-keys-are-over-postgres-s-63
|
id: backburner/four-foreign-keys-are-over-postgres-s-63
|
||||||
title: "Four foreign keys are over Postgres's 63-character limit and re-create on every `db:push`"
|
title: "Four foreign keys are over Postgres's 63-character limit and re-create on every `db:push`"
|
||||||
theme: backburner
|
theme: backburner
|
||||||
status: open
|
status: backburner
|
||||||
created: 2026-08-19
|
created: 2026-08-19
|
||||||
date_source: stated
|
date_source: stated
|
||||||
order: 1
|
order: 1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: backburner/music-tagging-feature
|
id: backburner/music-tagging-feature
|
||||||
title: "Music tagging feature"
|
title: "Music tagging feature"
|
||||||
theme: backburner
|
theme: backburner
|
||||||
status: open
|
status: backburner
|
||||||
created: 2026-08-20
|
created: 2026-08-20
|
||||||
date_source: assumed
|
date_source: assumed
|
||||||
order: 2
|
order: 2
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id: backburner/resurrect-projects-apps
|
id: backburner/resurrect-projects-apps
|
||||||
title: "Resurrect Projects/Apps"
|
title: "Resurrect Projects/Apps"
|
||||||
theme: backburner
|
theme: backburner
|
||||||
status: open
|
status: backburner
|
||||||
created: 2026-08-25
|
created: 2026-08-25
|
||||||
date_source: stated
|
date_source: stated
|
||||||
order: 3
|
order: 3
|
||||||
|
|||||||
Reference in New Issue
Block a user