From 6e54f223e65737b9a9a720381844236cc3e83a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 03:27:56 +0100 Subject: [PATCH] =?UTF-8?q?Kanban=20=E2=80=94=20the=20todo=20board,=20extr?= =?UTF-8?q?acted,=20over=20as=20many=20projects=20as=20you=20like?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 33 ++++++ api/projects.ts | 88 ++++++++++++++++ api/router.ts | 142 ++++++++++++++++++++++++++ api/tree.test.ts | 62 ++++++++++++ api/tree.ts | 190 +++++++++++++++++++++++++++++++++++ db/schema.ts | 66 ++++++++++++ manifest.ts | 64 ++++++++++++ web/BoardPanel.tsx | 228 ++++++++++++++++++++++++++++++++++++++++++ web/ProjectsPanel.tsx | 152 ++++++++++++++++++++++++++++ web/layout.ts | 18 ++++ web/panels.ts | 30 ++++++ web/useKanban.ts | 106 ++++++++++++++++++++ 12 files changed, 1179 insertions(+) create mode 100644 README.md create mode 100644 api/projects.ts create mode 100644 api/router.ts create mode 100644 api/tree.test.ts create mode 100644 api/tree.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 web/BoardPanel.tsx create mode 100644 web/ProjectsPanel.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/useKanban.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0a040c --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# Kanban + +A board over a folder of markdown todo files — as many projects as you point it at. + +Items are `/.md` with frontmatter and hand-written prose. **The files are the truth.** This +plugin stores no copy of them: its one table is a list of folders, so uninstalling it with "delete its +data" ticked loses the list and never a line of anybody's work. + +## Adding a project + +The path is the folder that holds the theme directories — usually `todo/`, not the repository root. It +must be absolute and inside your home; symlinks are resolved before that is checked. + +## What it does to your files + +- Writes **frontmatter only** — `status`, `priority`, `due`, `order`. The body is yours and no route here + can touch it. +- Regenerates `TODO.md` beside the folder on every write. +- Never deletes anything. Removing a project removes the row. + +## Where it came from + +`/todos` in the Officer platform, which read `resolve(process.cwd(), 'todo')` — one board, found by +assuming the server's working directory. The reader, the ordering and the frontmatter handling came +across unchanged, with their tests. What changed is that the directory is a setting. + +The platform kept its own `todo/` folder and `scripts/todo-index.ts`: that folder is its own backlog, +committed with its own code, and the CLI regenerates the index from a terminal with no Officer running. +Making the platform's own backlog depend on a plugin being installed would have been the wrong trade. + +**That leaves two copies of the tree reader** — this one, generalised over a root, and the platform's, +which serves only that CLI. They will drift. The alternative was worse in both directions, and the +honest fix on the day it matters is to delete the platform's CLI and let this own the format outright. diff --git a/api/projects.ts b/api/projects.ts new file mode 100644 index 0000000..5362a31 --- /dev/null +++ b/api/projects.ts @@ -0,0 +1,88 @@ +import { realpath, stat } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; +import { db } from 'officerdb/db'; +import { and, eq } from 'drizzle-orm'; +import { getOwnerHomeDir } from '@@/data-path'; +import { kanbanProjects, type KanbanProject } from '../db/schema'; + +/** + * The list of folders this board can show, and the rule about which folders those may be. + * + * ── Every path is confined to the owner's home ── + * + * A project path is caller-supplied and is then read from and WRITTEN to — `patchFrontmatter` edits + * files under it, and `writeIndex` creates one beside it. So it goes through the same three checks + * av-club's `paths.ts` applies, for the same reasons, and they are worth repeating rather than + * cross-referencing because the next person to add a path-taking route will read whichever file they + * are in: + * + * ABSOLUTE — a relative path resolves against a working directory the caller cannot see. That is + * precisely the bug this extraction exists to fix, so accepting one here would be reintroducing it + * through the front door. + * REALPATH FIRST — a symlink inside home pointing at `/etc` is resolved and then rejected. Checking + * the path as written and opening the resolved one is the classic way this fails. + * `root` OR `root + sep` — never a bare `startsWith`, which accepts `/home/user-backup` for a root + * of `/home/user`. + */ + +const inside = (root: string, target: string) => target === root || target.startsWith(root + sep); + +export class ProjectPathError extends Error {} + +/** + * Validate a directory a caller wants to add. + * + * It must EXIST and be a directory. A project pointing at nothing is a row that produces an empty board + * with no explanation, and the moment to say so is while somebody is looking at the form. + */ +export async function safeProjectPath(raw: unknown, email: string): Promise { + if (typeof raw !== 'string' || !raw.trim()) throw new ProjectPathError('A path is required.'); + + const home = getOwnerHomeDir(email); + const trimmed = raw.trim(); + const expanded = trimmed === '~' ? home : trimmed.startsWith('~/') ? resolve(home, trimmed.slice(2)) : trimmed; + if (!isAbsolute(expanded)) throw new ProjectPathError('The path must be absolute.'); + + const real = await realpath(resolve(expanded)).catch(() => null); + // One message for "outside" and "missing" together: distinguishing them turns this into an oracle for + // what exists elsewhere on the disk. + if (!real || !inside(home, real)) throw new ProjectPathError('That is not a folder this board can read.'); + + const info = await stat(real).catch(() => null); + if (!info?.isDirectory()) throw new ProjectPathError('That path is not a directory.'); + + return real; +} + +export const listProjects = (userId: number): Promise => + db.select().from(kanbanProjects).where(eq(kanbanProjects.userId, userId)).orderBy(kanbanProjects.name); + +/** + * One project, scoped to its owner. + * + * The `userId` is in the WHERE clause rather than checked after the read. A query that fetches by id + * and then compares is one `if` away from returning somebody else's row, and that `if` is exactly what + * gets dropped in a refactor. + */ +export async function getProject(userId: number, id: number): Promise { + const [row] = await db + .select() + .from(kanbanProjects) + .where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id))) + .limit(1); + return row ?? null; +} + +export async function addProject(userId: number, name: string, path: string): Promise { + const [row] = await db.insert(kanbanProjects).values({ userId, name, path }).returning(); + return row!; +} + +/** Removes the ROW. Never the folder — this plugin does not delete anybody's todo files, ever. */ +export async function removeProject(userId: number, id: number): Promise { + const rows = await db + .delete(kanbanProjects) + .where(and(eq(kanbanProjects.userId, userId), eq(kanbanProjects.id, id))) + .returning({ id: kanbanProjects.id }); + return rows.length > 0; +} diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..cd1feae --- /dev/null +++ b/api/router.ts @@ -0,0 +1,142 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { Context } from 'hono'; +import { createRouter, type HonoVariables } from '@@/create-router'; +import * as errors from '@@/custom-errors'; +import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './tree'; +import { ProjectPathError, addProject, getProject, listProjects, removeProject, safeProjectPath } from './projects'; + +/** + * Mounted at `/api/kanban`. + * + * ── Every item route takes a project ── + * + * That is the extraction, expressed in the URL. The platform's version had no such parameter because it + * had no such question: there was one folder and it was found from `process.cwd()`. + * + * The project is resolved to a row FIRST, scoped by the caller's own user id, and the path used for the + * filesystem work comes from that row — never from the request. So a caller cannot name a directory, + * only choose among ones already vetted by `safeProjectPath` when they were added. + * + * ── Who may do what ── + * + * The manifest makes `/projects` owner-only: pointing this plugin at a folder is a statement about the + * filesystem. Moving a card is not, so a member granted `kanban` can work the board of any project they + * can see. Nothing in this file checks again — two places deciding the same thing is how they come to + * disagree. + */ +export const router = createRouter(); + +// ── Projects ── + +router.get('/projects', async (ctx) => { + const user = ctx.get('user'); + return ctx.json({ projects: await listProjects(user.id as number) }); +}); + +router.post('/projects', async (ctx) => { + const user = ctx.get('user'); + const { name, path } = (ctx.get('body') ?? {}) as { name?: string; path?: string }; + if (!name?.trim()) throw errors.BAD_REQUEST('name is required'); + + try { + const resolved = await safeProjectPath(path, user.email as string); + return ctx.json(await addProject(user.id as number, name.trim(), resolved), 201); + } catch (error) { + // A bad path is the caller's words being unusable, not a fault. Anything else is a real failure and + // is left to the platform's error handler rather than flattened into a 400. + if (error instanceof ProjectPathError) throw errors.BAD_REQUEST(error.message); + throw error; + } +}); + +router.delete('/projects/:id', async (ctx) => { + const user = ctx.get('user'); + const removed = await removeProject(user.id as number, Number(ctx.req.param('id'))); + if (!removed) throw errors.NOT_FOUND('no such project'); + // Said plainly, because "delete" next to a folder full of somebody's work deserves to be unambiguous. + return ctx.json({ ok: true, note: 'The folder and its files were not touched.' }); +}); + +// ── The board ── + +/** + * Resolve `?project=` to a directory this caller owns. + * + * Every item route starts here, so there is one place where a request turns into a path on disk. + */ +async function rootFor(ctx: Context<{ Variables: HonoVariables }>): Promise { + const user = ctx.get('user'); + const id = Number(ctx.req.query('project')); + if (!Number.isInteger(id)) throw errors.BAD_REQUEST('project is required'); + + const project = await getProject(user.id as number, id); + if (!project) throw errors.NOT_FOUND('no such project'); + return project.path; +} + +/** `/`, both kebab-case — also the traversal guard, since it admits no dot or slash pair. */ +const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; + +const fileForId = (root: string, id: string): string => { + if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`); + const [theme, slug] = id.split('/'); + // Joined onto the ROW's path, which was vetted when the project was added — never onto anything from + // this request beyond the id, which the shape above has already constrained. + const path = resolve(root, join(theme!, `${slug}.md`)); + if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`); + return path; +}; + +/** GET /api/kanban?project= — the whole tree, no bodies. What the board renders. */ +router.get('/', async (ctx) => ctx.json(readTree(await rootFor(ctx)))); + +/** GET /api/kanban/item?project=&id=/ — one item, body included. */ +router.get('/item', async (ctx) => { + const text = readFileSync(fileForId(await rootFor(ctx), ctx.req.query('id') ?? ''), 'utf8'); + return ctx.json({ meta: frontmatter(text), body: bodyOf(text) }); +}); + +/** + * PATCH /api/kanban/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`. + * + * **The body is untouchable through this API, on purpose.** The prose is the owner's; only the machine's + * half of the file is writable here, and `patchFrontmatter` splices keys without touching a byte past + * the closing fence. + */ +router.patch('/item', async (ctx) => { + const root = await rootFor(ctx); + const { id, status, priority, due, order } = (ctx.get('body') ?? {}) as { + id: string; + status?: string; + priority?: string; + due?: string; + order?: number; + }; + const path = fileForId(root, id); + + const updates: Record = {}; + if (status !== undefined) { + if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`); + updates.status = status; + } + // `urgent` or nothing — the format deliberately has no scale. + if (priority !== undefined) { + if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`); + updates.priority = priority; + } + if (due !== undefined) { + if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`); + updates.due = due; + } + if (order !== undefined) { + if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`); + updates.order = String(order); + } + if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update'); + + writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates)); + // The generated map, regenerated on every write so it cannot drift from the tree it describes. + writeIndex(root); + return ctx.json({ ok: true }); +}); diff --git a/api/tree.test.ts b/api/tree.test.ts new file mode 100644 index 0000000..ff5ef4e --- /dev/null +++ b/api/tree.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { bodyOf, frontmatter, patchFrontmatter } from './tree'; + +const FILE = `--- +id: fleet/an-item +title: "An item, with: punctuation" +theme: fleet +status: open +created: 2026-08-21 +order: 2 +--- + +**The body.** Hand-written prose with --- inside it. + +- a list +`; + +describe('frontmatter', () => { + test('reads plain and JSON-quoted values', () => { + const fm = frontmatter(FILE); + expect(fm.id).toBe('fleet/an-item'); + expect(fm.title).toBe('An item, with: punctuation'); + expect(fm.order).toBe('2'); + }); + + test('accepts prettier-style single quotes', () => { + expect(frontmatter(`---\ntitle: 'A quoted title'\n---\n`).title).toBe('A quoted title'); + }); + + test('no frontmatter → empty', () => { + expect(frontmatter('just prose')).toEqual({}); + }); +}); + +describe('bodyOf', () => { + test('returns everything after the fence, trimmed of leading blank lines', () => { + expect(bodyOf(FILE).startsWith('**The body.**')).toBe(true); + }); +}); + +describe('patchFrontmatter', () => { + test('replaces an existing key in place', () => { + const out = patchFrontmatter(FILE, { status: 'done' }); + expect(frontmatter(out).status).toBe('done'); + expect(out.match(/^status:/gm)).toHaveLength(1); + }); + + test('appends a key that was absent', () => { + const out = patchFrontmatter(FILE, { priority: 'urgent' }); + expect(frontmatter(out).priority).toBe('urgent'); + }); + + test('never touches the body — byte for byte', () => { + const out = patchFrontmatter(FILE, { status: 'doing', order: '9' }); + expect(bodyOf(out)).toBe(bodyOf(FILE)); + }); + + test('a --- inside the body is not mistaken for the fence', () => { + const out = patchFrontmatter(FILE, { status: 'done' }); + expect(out).toContain('with --- inside it'); + }); +}); diff --git a/api/tree.ts b/api/tree.ts new file mode 100644 index 0000000..10918d0 --- /dev/null +++ b/api/tree.ts @@ -0,0 +1,190 @@ +import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// The todo tree: `todo//.md`, one item per file, frontmatter + hand-written prose. +// `todo/README.md` is the format's contract; the parts that matter here: +// +// - `id` (`/`) is the identity and the filename. Everything keys on it. +// - Frontmatter is the machine's half. The BODY IS THE OWNER'S and is never rewritten by code — +// `patchFrontmatter` below splices keys into the frontmatter block and must not touch a byte past +// the closing fence. +// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target. +// +// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos` +// router both import it, so the board and the generated index cannot disagree about what the tree holds. + +/* + THE ONE THING THAT CHANGED IN THIS FILE. + + It read `resolve(process.cwd(), 'todo')` — the platform's own todo folder, found by assuming the + server's working directory. That is not a setting; it is the belief that there is exactly one project + and it is the one the process happens to have been started in. + + Every function below now takes the directory. Everything else here — the frontmatter reader that + never touches the body, the index writer, the ordering — is unchanged and was already right. +*/ + +export const TODO_STATUSES = ['open', 'doing', 'blocked', 'backburner', 'done'] as const; +export type TodoStatus = (typeof TODO_STATUSES)[number]; + +export type TodoItem = { + id: string; + title: string; + theme: string; + status: string; + created: string; + /** `urgent` or empty — deliberately no scale, see todo/README.md. */ + priority: string; + due: string; + order: number; + file: string; +}; + +export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] }; + +/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */ +export function frontmatter(text: string): Record { + const end = text.indexOf('\n---', 4); + if (!text.startsWith('---') || end === -1) return {}; + const out: Record = {}; + for (const line of text.slice(4, end).split('\n')) { + const m = line.match(/^([a-z_]+):\s*(.*)$/); + if (!m || !m[1]) continue; + const raw = (m[2] ?? '').trim(); + out[m[1]] = raw.startsWith('"') || raw.startsWith("'") ? (JSON.parse(normalizeQuotes(raw)) as string) : raw; + } + return out; +} + +// Prettier rewrites the frontmatter's double quotes to single, which JSON.parse refuses. Both mean the +// same string; accept both rather than fighting the formatter. +const normalizeQuotes = (raw: string) => + raw.startsWith("'") ? JSON.stringify(raw.slice(1, -1).replace(/\\'/g, "'")) : raw; + +/** The body: everything after the frontmatter block's closing fence and its trailing blank lines. */ +export function bodyOf(text: string): string { + const end = text.indexOf('\n---', 4); + if (!text.startsWith('---') || end === -1) return text; + return text.slice(end + 4).replace(/^\n+/, ''); +} + +/** + * Splice `updates` into the frontmatter block, touching nothing else — the body is hand-written prose + * and a rewrite would produce a diff nobody can read. An existing key is replaced in place; a new one + * is appended just before the closing fence. Returns the text unchanged if there is no frontmatter. + */ +export function patchFrontmatter(text: string, updates: Record): string { + const end = text.indexOf('\n---', 4); + if (!text.startsWith('---') || end === -1) return text; + let block = text.slice(4, end); + for (const [key, value] of Object.entries(updates)) { + const line = `${key}: ${value}`; + const re = new RegExp(`^${key}:.*$`, 'm'); + block = re.test(block) ? block.replace(re, line) : `${block}\n${line}`; + } + return `---\n${block}${text.slice(end)}`; +} + +export function readTree(root: string): { themes: TodoTheme[] } { + const themes = readdirSync(root) + .filter((d) => statSync(join(root, d)).isDirectory()) + .sort() + .map((theme) => { + const files = readdirSync(join(root, theme)).filter((f) => f.endsWith('.md')); + const indexText = readFileSync(join(root, theme, '_index.md'), 'utf8'); + const meta = frontmatter(indexText); + const items = files + .filter((f) => f !== '_index.md') + .map((f) => { + const fm = frontmatter(readFileSync(join(root, theme, f), 'utf8')); + return { + id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`, + title: fm.title ?? f, + theme, + status: fm.status ?? 'open', + created: fm.created ?? '', + priority: fm.priority ?? '', + due: fm.due ?? '', + order: Number(fm.order ?? 0), + file: `todo/${theme}/${f}`, + }; + }) + .sort((a, b) => a.order - b.order); + return { theme, heading: meta.heading ?? theme, note: bodyOf(indexText).trim(), items }; + }); + return { themes }; +} + +const HEADER = `# TODO — the index + +Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map +over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here. +Format and the two-way-sync contract: \`todo/README.md\`. Browse and edit it at \`/todos\` in the app. + +**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from +multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant +indirection as accidental complexity. **That direction was reversed.** The permission model shipped on +2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own +Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed +deleting it have been removed rather than left to mislead the next reader. + +What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as +the owner's OS user and can never be granted. Indirection there really is accidental complexity. +`; + +export function renderIndex(root: string): string { + const { themes } = readTree(root); + const all = themes.flatMap((t) => t.items); + const open = all.filter((i) => i.status !== 'done').length; + const done = all.length - open; + + const urgent = all + .filter((i) => i.status !== 'done' && i.priority === 'urgent') + .sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999')) + .map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`); + + const rows = themes + .filter((t) => t.items.length) + .map((t) => { + const o = t.items.filter((i) => i.status !== 'done').length; + return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${t.items.length - o} |`; + }); + + // A theme holding only prose has no row above, and an entry nobody can see is the failure mode the + // whole split risks. List them explicitly rather than letting them age out of view. + const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`); + + const recent = all + .filter((i) => i.status !== 'done') + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, 10) + .map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`); + + return [ + HEADER, + ...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []), + `## Themes\n`, + `**${open} open, ${done} closed**, across ${rows.length} themes.\n`, + `| Theme | Open | Closed |`, + `| --- | --- | --- |`, + ...rows, + ``, + `## Records — no items, kept for the reasoning\n`, + ...notes, + ``, + `## Newest ten\n`, + ...recent, + ``, + ].join('\n'); +} + +/** + * The generated map, written beside the tree rather than at `process.cwd()`. + * + * It used to go to the platform's own repo root, which was the same assumption as `todoDir` and wrong + * the same way: with two projects it would have had every board overwriting one file. `/../TODO.md` + * keeps the map next to the folder it describes, which is where somebody reading that repository looks. + */ +export function writeIndex(root: string): void { + writeFileSync(resolve(root, '..', 'TODO.md'), renderIndex(root)); +} diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..74c1df1 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,66 @@ +import { pgTable, serial, text, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core'; + +/** + * The projects this board can show — one row per folder of todo files. + * + * ── Why this table exists at all ── + * + * The version of this in the platform read exactly one directory: `resolve(process.cwd(), 'todo')`, + * the platform's own. That is not a setting, it is the assumption that there is one project and it is + * the one the server happens to be running from. The moment somebody has a second repository with a + * `todo/` in it, there is nowhere to put it. + * + * So a project is a NAME and a PATH, and the board asks which one you are looking at. + * + * ── What is deliberately NOT here ── + * + * The items. They live on disk as markdown, one file each, and this table holds no copy of them — + * not a cache, not a mirror, not an index. That is the whole property the format was chosen for: the + * files are the truth, they are readable and diffable without Officer, and uninstalling this plugin + * with "delete its data" ticked loses a list of folders, never a line of anybody's work. + * + * A cache would be the thin end of it. If a board over a thousand items ever needs one, it belongs in + * memory or in the browser — not in a table that outlives the request and can disagree with the disk. + */ +export const kanbanProjects = pgTable( + 'kanban_projects', + { + id: serial('id').primaryKey(), + + /** + * Whose project it is. + * + * Not a foreign key onto `users`, deliberately: this plugin's schema is generated into the + * platform's barrel and a cross-schema reference would couple its migrations to the platform's. + * The routes scope every read and write by this column, which is where the enforcement belongs — + * see the note on `authorize` in api/router.ts. + */ + userId: integer('user_id').notNull(), + + /** What the selector shows. The owner's words, not derived from the path. */ + name: text('name').notNull(), + + /** + * Absolute, and containing the theme folders directly — the directory that holds `/.md`. + * + * Absolute for the same reason av-club's root is: a relative path resolves against a working + * directory the caller cannot see, and the old implementation's `process.cwd()` is exactly that + * mistake with the answer happening to be right on one machine. + */ + path: text('path').notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + /* + One row per path per person. + + `uniqueIndex`, never `unique().on(...)`: drizzle-kit mis-diffs named composite unique CONSTRAINTS + and re-creates them on every push, which stops `db:push` on a prompt nobody can answer + unattended. The platform's own databases/CLAUDE.md records this under "Composite keys". + */ + uniqueIndex('kanban_projects_user_path').on(table.userId, table.path), + ], +); + +export type KanbanProject = typeof kanbanProjects.$inferSelect; diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..d9efc7d --- /dev/null +++ b/manifest.ts @@ -0,0 +1,64 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Kanban — a board over a folder of markdown files. + * + * ── What it was ── + * + * `/todos` in the platform: a board, an API and a tree reader, all pointed at + * `resolve(process.cwd(), 'todo')` — the platform's own todo folder, found by assuming the server's + * working directory. It worked, for exactly one project, on exactly one checkout. + * + * ── What changed in the extraction ── + * + * One thing, and everything else was carried across untouched: **the directory is a setting**. A + * project is a name and a path, there can be as many as you like, and the board asks which one you are + * looking at. The frontmatter reader that never rewrites a body, the ordering, the generated index — + * all of it came over as it was, and its tests came with it and still pass. + * + * ── It stores no items ── + * + * The files on disk are the truth. This plugin's only table is a list of folders, so uninstalling it + * with "delete its data" ticked loses the list and never a line of anybody's work. That is the same + * property the markdown format was chosen for: readable, diffable and editable without Officer. + * + * ── What stayed in the platform, and why it is a seam rather than a loose end ── + * + * `scripts/todo-index.ts` and the platform's own `todo/` folder. That folder is the platform's own + * content — its deferred work, in its own repository, committed with its own code — and the CLI is how + * it is regenerated from a terminal with no Officer running. Moving either would have made the + * platform's own backlog depend on a plugin being installed. + * + * So the platform keeps its folder; this plugin points AT it as one project among however many. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '2026.08.29.01', + platform: '>=1.0.0', + + label: 'Kanban', + summary: 'A board over folders of markdown todo files — as many projects as you point it at', + tags: ['Development'], + icon: 'SquareKanban', + color: '#8b5cf6', + + // Nothing to declare. It reads and writes markdown with node's own fs; there is no binary on the + // path and no service to reach. + + permissions: [ + { + key: 'kanban', + label: 'Kanban', + description: 'Read and edit todo boards over folders you have added', + + /* + Which FOLDERS exist on the machine is an owner's decision; moving a card is not. + + Adding a project points this plugin at a directory and is therefore a statement about the + filesystem — the same class of decision as av-club's root location. Prefix-matched, so + `/projects/` is covered by the same rule as `/projects`. + */ + ownerOnly: ['/projects'], + }, + ], +}; diff --git a/web/BoardPanel.tsx b/web/BoardPanel.tsx new file mode 100644 index 0000000..0015378 --- /dev/null +++ b/web/BoardPanel.tsx @@ -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(null); + + const href = (changes: Record): 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 ( +
+ The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to + show. +
+ ); + } + if (isLoading || !data) { + return
Reading todo/…
; + } + + 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 ( +
+
+
+ + All · {items.filter((i) => i.status !== 'done').length} + + {themes.map((t) => { + const open = t.items.filter((i) => i.status !== 'done').length; + return ( + + {t.heading.split('—')[0]?.trim()} · {open} + + ); + })} +
+ +
+ {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 ( +
{ + 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', + )} + > +
+ {col.label} + {cards.length} +
+
+ {cards.map((item) => ( + + ))} +
+
+ ); + })} +
+
+ + {selected ? : null} +
+ ); +}; + +type TodoCardProps = { item: TodoItem; href: string; selected: boolean }; + +const TodoCard = ({ item, href, selected }: TodoCardProps) => ( + 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', + )} + > +
{item.title}
+
+ {item.theme} + {item.priority === 'urgent' && ( + + urgent + + )} + {item.due && ( + + {item.due} + + )} + {item.created} +
+ +); + +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 ( +
+
+ {id} +
+ + + + +
+
+
+ {isLoading || !data ? ( + Reading… + ) : ( + {data.body} + )} +
+
+ ); +}; diff --git a/web/ProjectsPanel.tsx b/web/ProjectsPanel.tsx new file mode 100644 index 0000000..55c8724 --- /dev/null +++ b/web/ProjectsPanel.tsx @@ -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=`. 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(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 ( + + ); +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..a929784 --- /dev/null +++ b/web/layout.ts @@ -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 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..a9d7d6e --- /dev/null +++ b/web/panels.ts @@ -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, + }, +]; diff --git a/web/useKanban.ts b/web/useKanban.ts new file mode 100644 index 0000000..138ad32 --- /dev/null +++ b/web/useKanban.ts @@ -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=`, 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] }); + }, + }); +};