diff --git a/docs/api.md b/docs/api.md index de319688..8ed77bea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -177,7 +177,8 @@ than the rest since it grows without bound. ```json Response: { "statuses": [{ "name": "pending", "label": "Pending", "color": "#...", ... }], - "lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }] + "lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }], + "status_since": { "2026-03-01-fix-bug": "2026-03-02T10:00:00Z" } } ``` @@ -223,6 +224,16 @@ Request: { "deadline": "" } Response: { "task": { ... }, "task_id": "2026-03-01-fix-bug", "updated": true } ``` +#### `GET /api/tasks/{id}/events` +A task's status history, oldest first. + +```json +Response: { "events": [ + { "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "system", "created_at": "..." }, + { "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "created_at": "..." } +] } +``` + #### `POST /api/tasks/{id}/move` Place a task in a lane — the board's drag-and-drop write. Send *intent* ("between these two cards"), not a computed rank: the server resolves the diff --git a/docs/tasks.md b/docs/tasks.md index 2ab541a8..44930ae5 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -114,6 +114,34 @@ Two rules keep ranks meaningful: lane, so a status change places the card at the top of its destination instead of carrying over a number it was never ordered against. +### Status History + +Every status transition is appended to `task_events` (`task_id`, +`from_status`, `to_status`, `actor`, `created_at`), added in migration v044 +and seeded with one origin row per existing task. `from_status` is NULL on +the row recording a task's creation, so an aging calculation can tell +"created here" from "moved here". + +Recording happens inside the same transaction as the status write, from all +three paths that can change one: `update_task_status`, `move_task`, and the +full-row `upsert_task` (which is how `task_update` flips status when it also +writes a note). A no-op transition records nothing — that single rule is +what keeps `reindex()` from doubling the table on every run, since it +rewrites every row with the status it already had. The one case where +reindex *does* change a status, resetting an orphaned row to match its +directory, is a real correction and gets a row. + +`actor` is the session id for agent-driven changes, `web` for the HTTP API, +`backfill` on the origin rows v044 seeded for tasks that predate the table, +and `system` otherwise. `backfill` is load-bearing rather than cosmetic: a +real creation also records a NULL `from_status`, so the actor is the only +thing distinguishing a synthesized origin from one that actually happened. + +This powers the aging indicator on board cards and the timeline in the task +detail view. Tasks whose last transition predates v044 report no entry time +rather than a guessed one, so the UI stays silent instead of showing an age +that isn't true. + ### Live Updates Every task mutation broadcasts a `task_updated` WebSocket event on the diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 79be2391..6575781e 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -256,6 +256,7 @@ async def task_create_handler(ctx: ToolContext, args: dict) -> ToolResult: deadline=deadline or None, tags=tags_to_string(tags), content=content, + actor=ctx.session_id, ) _tasks_read.add(task_id) @@ -427,6 +428,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: title=final_title, status=final_status, content=content, + actor=ctx.session_id, **edits, ) await _emit_task_event(ctx, task_id, "updated") @@ -434,7 +436,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: # Fall back to metadata updates when no task file was changed. if status: - await ctx.db.update_task_status(task_id, status) + await ctx.db.update_task_status(task_id, status, actor=ctx.session_id) if raw_tags is not None: await ctx.db.update_task_tags(task_id, new_tags_str) await _emit_task_event(ctx, task_id, "updated") @@ -540,7 +542,7 @@ async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult: if ctx.workspace: ensure_path_not_tracked_config(ctx.workspace / task["file_path"], "move") - await ctx.db.update_task_status(task_id, "done") + await ctx.db.update_task_status(task_id, "done", actor=ctx.session_id) # Mark any implementing plans for this task as done implementing_plans = await ctx.db.get_plans_for_task(task_id) @@ -635,7 +637,7 @@ async def task_reopen_handler(ctx: ToolContext, args: dict) -> ToolResult: is_error=True, ) - await ctx.db.update_task_status(task_id, status) + await ctx.db.update_task_status(task_id, status, actor=ctx.session_id) if src is not None: # Re-checked because the guard above is not atomic with the move. diff --git a/nerve/db/migrations/v044_task_events.py b/nerve/db/migrations/v044_task_events.py new file mode 100644 index 00000000..f3c030c4 --- /dev/null +++ b/nerve/db/migrations/v044_task_events.py @@ -0,0 +1,72 @@ +"""V44: status-transition history for tasks. + +``tasks`` stores only the current status, so every transition overwrote +the last one and nothing recorded that it happened. The markdown file's +``## Updates`` section was the closest thing to a history, and it only +gains a line when a note is passed or on done/reopen — a plain status +flip left no trace at all, at day granularity and with no actor. + +That was tolerable while status changes were rare and deliberate. The +task board makes them a drag, so they're frequent, and it raises the +questions this table answers: how long has this been in progress, which +cards are aging, how often does work bounce back out of review. + +Rows are append-only. ``from_status`` is NULL for the row recording a +task's creation, so a task's first event is its birth rather than an +implicit gap before the first transition. + +Timestamps are TEXT written Python-side as UTC ISO strings, matching every +other table here. They are not fixed width — ``isoformat()`` drops the +fractional part on an exact second — but lexicographic order is still +chronological, because the only characters that can follow the seconds +field are ``+`` (0x2B) and ``.`` (0x2E), and both sort below every digit. +""" + +from __future__ import annotations + +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + +SQL = """ +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + from_status TEXT, + to_status TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT 'system', + created_at TEXT NOT NULL +); + +-- The only read pattern: one task's history, oldest first. +CREATE INDEX IF NOT EXISTS idx_task_events_task + ON task_events(task_id, created_at); + +-- Aging queries sweep by time across all tasks. +CREATE INDEX IF NOT EXISTS idx_task_events_created + ON task_events(created_at); +""" + + +async def up(db: aiosqlite.Connection) -> None: + await db.executescript(SQL) + + # Seed one event per existing task so history has a floor rather than + # starting mid-air. created_at (not updated_at) is the honest stamp: + # we know when the task appeared, not when it reached its current + # status. The `backfill` actor is what marks these as synthesized — not + # the NULL from_status, which a genuine creation records too. + await db.execute( + """ + INSERT INTO task_events (task_id, from_status, to_status, actor, created_at) + SELECT id, NULL, status, 'backfill', COALESCE(created_at, updated_at) + FROM tasks + WHERE COALESCE(created_at, updated_at) IS NOT NULL + """ + ) + + async with db.execute("SELECT COUNT(*) FROM task_events") as cursor: + seeded = (await cursor.fetchone())[0] + logger.info("v044: task_events created, seeded %d origin row(s)", seeded) diff --git a/nerve/db/tasks.py b/nerve/db/tasks.py index 70deb02f..89665408 100644 --- a/nerve/db/tasks.py +++ b/nerve/db/tasks.py @@ -60,6 +60,7 @@ async def upsert_task( tags: str | _Keep = KEEP, content: str = "", position: float | None = None, + actor: str = "system", ) -> None: """Insert or update a task row and its FTS entry. @@ -84,14 +85,18 @@ async def upsert_task( """ now = datetime.now(timezone.utc).isoformat() async with self._atomic(): - # One read resolves every preserve-on-omit column. The statement - # below stays a plain full replace, and the FTS text sees the - # tags that actually land in the row. + # One read serves every column this method resolves before the + # write. The statement below stays a plain full replace, and the + # FTS text sees the tags that actually land in the row. stored = await self._stored_task_columns(task_id) source = _resolve_kept(source, stored, "source", None) source_url = _resolve_kept(source_url, stored, "source_url", None) deadline = _resolve_kept(deadline, stored, "deadline", None) tags = _resolve_kept(tags, stored, "tags", "") + # A full-row upsert is one of the paths that can change status + # (task_update with a note, the PATCH route saving content), so + # it has to record transitions like the dedicated methods do. + previous_status = stored["status"] if stored else None if position is None: position = await self._resolve_upsert_position(stored, status) await self.db.execute( @@ -109,6 +114,8 @@ async def upsert_task( # The FTS5 tokenizer already splits the hyphenated slug into words # ("2026-03-10-distribution" → 2026, 03, 10, distribution), so slug # search works without rewriting the key — and the key stays joinable. + await self._record_status_event(task_id, previous_status, status, actor) + fts_content = f"{content} {tags.replace(',', ' ')}" if tags else content await self.db.execute("DELETE FROM tasks_fts WHERE task_id = ?", (task_id,)) await self.db.execute( @@ -121,6 +128,79 @@ async def get_task(self, task_id: str) -> dict | None: row = await cursor.fetchone() return dict(row) if row else None + # ── Status history (task_events) ───────────────────────────────────── + + async def _record_status_event( + self, + task_id: str, + from_status: str | None, + to_status: str, + actor: str, + ) -> None: + """Append a transition to ``task_events``, if it is one. + + Called from inside ``_atomic()`` by every path that can change a + task's status, so the event and the status commit together — there + is no window in which a task has moved but its history hasn't. + + A no-op transition writes nothing, and that one rule is what keeps + ``TaskManager.reindex()`` from flooding the table: reindex rewrites + every row with the status it already had, so from == to and nothing + is recorded. The single case where reindex *does* change a status — + resetting an orphaned row to match its directory — is a real + correction and worth a row. + """ + if from_status == to_status: + return + await self.db.execute( + "INSERT INTO task_events (task_id, from_status, to_status, actor, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + task_id, from_status, to_status, actor or "system", + datetime.now(timezone.utc).isoformat(), + ), + ) + + async def list_task_events(self, task_id: str, limit: int = 200) -> list[dict]: + """A task's transitions, oldest first — the order a timeline reads.""" + async with self.db.execute( + "SELECT * FROM task_events WHERE task_id = ? " + "ORDER BY created_at ASC, id ASC LIMIT ?", + (task_id, limit), + ) as cursor: + return [dict(row) async for row in cursor] + + async def get_status_entry_times(self, task_ids: list[str]) -> dict[str, str]: + """When each task last *entered* its current status. + + The basis for card aging. Takes the newest event per task and keeps + it only if it still matches the live status: a row whose status was + last changed by a path predating this table, or by a direct DB edit, + has no truthful entry time. Omitting it leaves the indicator silent, + which beats inventing an age that reads as fact. + """ + if not task_ids: + return {} + placeholders = ",".join("?" * len(task_ids)) + async with self.db.execute( + f"SELECT task_id, to_status, MAX(created_at) FROM task_events " + f"WHERE task_id IN ({placeholders}) GROUP BY task_id", + tuple(task_ids), + ) as cursor: + newest = {row[0]: (row[1], row[2]) async for row in cursor} + + async with self.db.execute( + f"SELECT id, status FROM tasks WHERE id IN ({placeholders})", + tuple(task_ids), + ) as cursor: + live = {row[0]: row[1] async for row in cursor} + + return { + task_id: entered_at + for task_id, (to_status, entered_at) in newest.items() + if live.get(task_id) == to_status + } + # ── Board ordering (tasks.position) ────────────────────────────────── # # Lanes sort by ``position ASC``, so a *lower* rank is *higher* in the @@ -239,6 +319,7 @@ async def move_task( status: str | None = None, before_id: str | None = None, after_id: str | None = None, + actor: str = "system", ) -> dict | None: """Place a task at an explicit slot in a lane; return the new row. @@ -276,6 +357,10 @@ async def move_task( "UPDATE tasks SET status = ?, position = ?, updated_at = ? WHERE id = ?", (lane, position, now, task_id), ) + # A pure reorder leaves status alone, and _record_status_event + # drops the no-op — so dragging within a lane doesn't pollute + # the history with rows that say nothing changed. + await self._record_status_event(task_id, row[0], lane, actor) async with self.db.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,), ) as cursor: @@ -368,7 +453,9 @@ async def count_tasks( row = await cursor.fetchone() return row[0] if row else 0 - async def update_task_status(self, task_id: str, status: str) -> None: + async def update_task_status( + self, task_id: str, status: str, actor: str = "system", + ) -> None: """Move a task to another status, re-ranking it into the new lane. A rank only means anything relative to its own lane, so carrying @@ -396,6 +483,7 @@ async def update_task_status(self, task_id: str, status: str) -> None: "UPDATE tasks SET status = ?, position = ?, updated_at = ? WHERE id = ?", (status, position, now, task_id), ) + await self._record_status_event(task_id, row[0], status, actor) async def update_task_tags(self, task_id: str, tags: str) -> None: now = datetime.now(timezone.utc).isoformat() diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index 75fe79be..f246dac5 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -186,7 +186,14 @@ async def task_board( ) lanes.append({"status": name, "total": total, "tasks": tasks}) - return {"statuses": statuses, "lanes": lanes} + # When each visible card last entered its current status — one query + # for the whole board rather than one per card. Tasks whose last + # transition predates task_events are simply absent, so the UI shows + # no age rather than a wrong one. + visible = [t["id"] for lane in lanes for t in lane["tasks"]] + status_since = await deps.db.get_status_entry_times(visible) + + return {"statuses": statuses, "lanes": lanes, "status_since": status_since} @router.get("/api/tasks/tags") @@ -266,7 +273,10 @@ async def move_task( if target != task["status"]: result = await get_tool_registry().invoke( "task_update", - build_route_tool_context(), + # "web" rather than the default "system" sentinel: this string + # lands in task_events.actor, and the point of that column is + # telling a person dragging a card from the agent moving it. + build_route_tool_context("web"), {"task_id": task_id, "status": target}, ) if result.is_error: @@ -274,6 +284,7 @@ async def move_task( moved = await deps.db.move_task( task_id, status=target, before_id=req.before_id, after_id=req.after_id, + actor="web", ) if not moved: raise HTTPException(status_code=404, detail="Task not found") @@ -282,6 +293,16 @@ async def move_task( return {"task": moved} +@router.get("/api/tasks/{task_id}/events") +async def list_task_events( + task_id: str, limit: int = 200, user: dict = Depends(require_auth), +): + """A task's status history, oldest first.""" + deps = get_deps() + limit = max(1, min(limit, 500)) + return {"events": await deps.db.list_task_events(task_id, limit=limit)} + + @router.get("/api/tasks/{task_id}") async def get_task(task_id: str, user: dict = Depends(require_auth)): deps = get_deps() @@ -365,7 +386,7 @@ async def update_task(task_id: str, req: TaskUpdateRequest, user: dict = Depends if len(payload) > 1: result = await get_tool_registry().invoke( - "task_update", build_route_tool_context(), payload, + "task_update", build_route_tool_context("web"), payload, ) # Previously swallowed: an unknown status returned {"updated": true} # while nothing had changed. Surface the handler's own message. diff --git a/tests/test_task_board_api.py b/tests/test_task_board_api.py index 95978021..99f8161c 100644 --- a/tests/test_task_board_api.py +++ b/tests/test_task_board_api.py @@ -286,6 +286,52 @@ async def test_move_on_a_missing_task_is_404(self, setup): resp = setup.client.post("/api/tasks/nope/move", json={"status": "pending"}) assert resp.status_code == 404 + # ── History ────────────────────────────────────────────────────────── + + async def test_events_endpoint_returns_the_transition_history(self, setup): + task_id = await self._create(setup, "Tracked task") + setup.client.patch(f"/api/tasks/{task_id}", json={"status": "in_progress"}) + + resp = setup.client.get(f"/api/tasks/{task_id}/events") + + assert resp.status_code == 200 + events = resp.json()["events"] + assert [(e["from_status"], e["to_status"]) for e in events] == [ + (None, "pending"), ("pending", "in_progress"), + ] + + async def test_events_route_is_not_shadowed_by_the_id_route(self, setup): + task_id = await self._create(setup, "Shadow check") + body = setup.client.get(f"/api/tasks/{task_id}/events").json() + assert "events" in body, "resolved to the task-detail route instead" + + async def test_move_via_http_is_recorded_with_the_web_actor(self, setup): + task_id = await self._create(setup, "Dragged task") + + setup.client.post(f"/api/tasks/{task_id}/move", json={"status": "in_progress"}) + + events = setup.client.get(f"/api/tasks/{task_id}/events").json()["events"] + assert events[-1]["to_status"] == "in_progress" + # Distinguishes a human dragging a card from the agent moving it. + assert events[-1]["actor"] == "web" + + async def test_reorder_within_a_lane_records_no_event(self, setup): + first = await self._create(setup, "Reorder one") + second = await self._create(setup, "Reorder two") + + setup.client.post(f"/api/tasks/{second}/move", json={"before_id": first}) + + events = setup.client.get(f"/api/tasks/{second}/events").json()["events"] + assert len(events) == 1, "a pure reorder is not a status change" + + async def test_board_reports_when_cards_entered_their_status(self, setup): + task_id = await self._create(setup, "Aging card") + + body = setup.client.get("/api/tasks/board").json() + + # Drives the card aging indicator; absent means "unknown", not zero. + assert task_id in body["status_since"] + # ── Create ─────────────────────────────────────────────────────────── async def test_create_returns_the_structured_task(self, setup): diff --git a/tests/test_task_events.py b/tests/test_task_events.py new file mode 100644 index 00000000..afaf14ac --- /dev/null +++ b/tests/test_task_events.py @@ -0,0 +1,211 @@ +"""Status-transition history (``task_events``). + +Before v044 a task's status was overwritten in place and nothing recorded +that it had changed. The markdown ``## Updates`` list only gains a line +when a note is passed or on done/reopen, so a plain status flip left no +trace at all. + +Two properties matter and pull against each other: every *real* +transition must be recorded exactly once, and routine rewrites must +record nothing. The second is the fragile one — ``TaskManager.reindex()`` +rewrites every row on startup and through a full-row upsert, which is one +of the paths that can change status. Nothing but the no-op check stops it +from doubling the table every time it runs. +""" + +from __future__ import annotations + +import pytest + +from nerve.db import Database +from nerve.tasks.manager import TaskManager + + +async def _add(db: Database, task_id: str, status: str = "pending", **row) -> None: + row.setdefault("title", f"Task {task_id}") + row.setdefault("file_path", f"memory/tasks/active/{task_id}.md") + await db.upsert_task(task_id=task_id, status=status, **row) + + +async def _transitions(db: Database, task_id: str) -> list[tuple]: + events = await db.list_task_events(task_id) + return [(e["from_status"], e["to_status"]) for e in events] + + +@pytest.mark.asyncio +class TestRecording: + async def test_creation_records_an_origin_event(self, db: Database): + await _add(db, "t1") + # from_status NULL distinguishes "created here" from "moved here", + # which is what lets an aging calculation tell the two apart. + assert await _transitions(db, "t1") == [(None, "pending")] + + async def test_status_change_is_recorded(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "in_progress"), + ] + + async def test_move_across_lanes_is_recorded(self, db: Database): + await _add(db, "t1") + await db.move_task("t1", status="done") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "done"), + ] + + async def test_upsert_that_changes_status_is_recorded(self, db: Database): + """A full-row upsert is a status-change path too. + + ``task_update`` with a note and the PATCH route saving content both + flip status this way, never touching ``update_task_status``. + """ + await _add(db, "t1") + await _add(db, "t1", status="deferred") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "deferred"), + ] + + async def test_actor_is_recorded(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress", actor="sess-abc") + + events = await db.list_task_events("t1") + assert events[-1]["actor"] == "sess-abc" + + async def test_actor_defaults_rather_than_nulls(self, db: Database): + await _add(db, "t1") + assert (await db.list_task_events("t1"))[0]["actor"] == "system" + + async def test_a_real_creation_is_told_apart_by_actor_not_from_status( + self, db: Database, + ): + """The one thing separating a real origin from a seeded one. + + v044 backfills an origin row per pre-existing task with + ``actor='backfill'``, and both that row and a genuine creation carry + a NULL ``from_status`` — so anything reading NULL as "synthesized" + is wrong. docs/tasks.md documents ``backfill`` as the marker; this + pins the half of that contract the code owns. + """ + await _add(db, "t1") + + origin = (await db.list_task_events("t1"))[0] + assert origin["from_status"] is None + assert origin["actor"] == "system" + + async def test_history_is_ordered_oldest_first(self, db: Database): + await _add(db, "t1") + for status in ("in_progress", "deferred", "in_progress", "done"): + await db.update_task_status("t1", status) + + assert await _transitions(db, "t1") == [ + (None, "pending"), + ("pending", "in_progress"), + ("in_progress", "deferred"), + ("deferred", "in_progress"), + ("in_progress", "done"), + ] + + +@pytest.mark.asyncio +class TestNoOpsAreNotRecorded: + """The half that keeps the table meaningful rather than merely large.""" + + async def test_same_status_update_records_nothing(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "pending") + + assert len(await db.list_task_events("t1")) == 1 + + async def test_reorder_within_a_lane_records_nothing(self, db: Database): + await _add(db, "a") + await _add(db, "b") + await db.move_task("b", before_id="a") + + # Dragging within a lane is the most frequent board interaction; + # recording it would bury the actual transitions. + assert len(await db.list_task_events("b")) == 1 + + async def test_upsert_with_unchanged_status_records_nothing(self, db: Database): + await _add(db, "t1") + await _add(db, "t1", title="edited") + + assert len(await db.list_task_events("t1")) == 1 + + async def test_reindex_does_not_duplicate_history(self, db: Database, tmp_path): + """The flooding case: reindex rewrites every row, repeatedly.""" + directory = tmp_path / "memory" / "tasks" / "active" + directory.mkdir(parents=True) + (tmp_path / "memory" / "tasks" / "done").mkdir(parents=True) + (directory / "t1.md").write_text("# t1\n", encoding="utf-8") + await _add(db, "t1", status="in_progress") + + before = len(await db.list_task_events("t1")) + manager = TaskManager(tmp_path, db) + await manager.reindex() + await manager.reindex() + + assert len(await db.list_task_events("t1")) == before + + async def test_reindex_records_an_orphan_correction(self, db: Database, tmp_path): + """...but a reindex that *does* change status has changed something.""" + active = tmp_path / "memory" / "tasks" / "active" + done = tmp_path / "memory" / "tasks" / "done" + active.mkdir(parents=True) + done.mkdir(parents=True) + # File under done/ but the row claims otherwise — the orphan state + # reindex exists to correct. + (done / "t1.md").write_text("# t1\n", encoding="utf-8") + await _add( + db, "t1", status="in_progress", + file_path="memory/tasks/done/t1.md", + ) + + await TaskManager(tmp_path, db).reindex() + + assert await _transitions(db, "t1") == [ + (None, "in_progress"), ("in_progress", "done"), + ] + + +@pytest.mark.asyncio +class TestStatusEntryTimes: + async def test_reports_when_the_current_status_was_entered(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress") + + entered = await db.get_status_entry_times(["t1"]) + events = await db.list_task_events("t1") + assert entered["t1"] == events[-1]["created_at"] + + async def test_omits_tasks_with_no_history(self, db: Database): + # A row whose status last changed by some path predating this table + # has no truthful entry time. Silence beats a fabricated age. + await _add(db, "t1") + await db._write("DELETE FROM task_events WHERE task_id = 't1'") + + assert await db.get_status_entry_times(["t1"]) == {} + + async def test_omits_tasks_whose_history_disagrees_with_the_row( + self, db: Database, + ): + await _add(db, "t1") + # Status changed behind the table's back (direct DB edit). + await db._write("UPDATE tasks SET status = 'deferred' WHERE id = 't1'") + + assert await db.get_status_entry_times(["t1"]) == {} + + async def test_handles_an_empty_request(self, db: Database): + assert await db.get_status_entry_times([]) == {} + + async def test_covers_many_tasks_in_one_call(self, db: Database): + for tid in ("a", "b", "c"): + await _add(db, tid) + await db.update_task_status("b", "done") + + entered = await db.get_status_entry_times(["a", "b", "c"]) + assert set(entered) == {"a", "b", "c"} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c7b4f504..2cc2ff91 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,6 +10,16 @@ export interface TaskStatusDef { created_at?: string; } +export interface TaskEvent { + id: number; + task_id: string; + /** null on the row recording the task's creation. */ + from_status: string | null; + to_status: string; + actor: string; + created_at: string; +} + export interface Task { id: string; title: string; @@ -376,6 +386,9 @@ export const api = { return request<{ statuses: TaskStatusDef[]; lanes: { status: string; total: number; tasks: Task[] }[]; + /** task_id → ISO time it entered its current status. Absent for + tasks whose last transition predates the history table. */ + status_since: Record; }>(`/tasks/board${q ? '?' + q : ''}`); }, listTaskTags: (includeDone = false) => @@ -392,6 +405,8 @@ export const api = { method: 'POST', body: JSON.stringify(data), }), + listTaskEvents: (id: string) => + request<{ events: TaskEvent[] }>(`/tasks/${id}/events`), getTask: (id: string) => request(`/tasks/${id}`), createTask: (data: { title: string; content?: string; deadline?: string; diff --git a/web/src/components/Tasks/Board/BoardCard.tsx b/web/src/components/Tasks/Board/BoardCard.tsx index 1d76644f..96802fed 100644 --- a/web/src/components/Tasks/Board/BoardCard.tsx +++ b/web/src/components/Tasks/Board/BoardCard.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Calendar, Clock, ExternalLink } from 'lucide-react'; +import { Calendar, Clock, ExternalLink, Hourglass } from 'lucide-react'; import type { Task } from '../../../api/client'; import { formatTimeAgo } from '../../../utils/dateGroups'; @@ -26,8 +26,27 @@ function parseTags(tags: string | null | undefined): string[] { return (tags || '').split(',').map((t) => t.trim()).filter(Boolean); } +/** + * Days a card has sat in its current status, once that's long enough to + * be worth saying. Thresholds are deliberately coarse — the signal is + * "this has stalled", not a precise duration, and a badge on every card + * would be noise rather than information. + */ +function laneAge(since: string | undefined): { days: number; tone: string } | null { + if (!since) return null; + const ms = Date.now() - new Date(since).getTime(); + if (Number.isNaN(ms)) return null; + const days = Math.floor(ms / 86_400_000); + if (days < 3) return null; + if (days >= 14) return { days, tone: 'text-hue-red' }; + if (days >= 7) return { days, tone: 'text-hue-orange' }; + return { days, tone: 'text-text-faint' }; +} + export interface BoardCardProps { task: Task; + /** ISO time the task entered its current status; absent = unknown. */ + statusSince?: string; onOpen: (task: Task) => void; } @@ -39,12 +58,13 @@ export interface BoardCardProps { * is a drag handle shouldn't also contain a control that swallows pointer * events. Changing status here is the drag itself. */ -function BoardCardInner({ task, onOpen }: BoardCardProps) { +function BoardCardInner({ task, statusSince, onOpen }: BoardCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: task.id, data: { type: 'task', task } }); const tags = parseTags(task.tags); + const age = laneAge(statusSince); return (
{formatTimeAgo(task.updated_at)} )} + {age && ( + + {age.days}d + + )} {task.source && task.source !== 'manual' && ( {task.source} )} diff --git a/web/src/components/Tasks/Board/BoardColumn.tsx b/web/src/components/Tasks/Board/BoardColumn.tsx index c8e84a93..46a03cda 100644 --- a/web/src/components/Tasks/Board/BoardColumn.tsx +++ b/web/src/components/Tasks/Board/BoardColumn.tsx @@ -9,13 +9,14 @@ export interface BoardColumnProps { lane: Lane; status: TaskStatusDef | undefined; collapsed: boolean; + statusSince: Record; onToggleCollapse: (status: string) => void; onCreate: (status: string) => void; onOpenTask: (task: Task) => void; } export function BoardColumn({ - lane, status, collapsed, onToggleCollapse, onCreate, onOpenTask, + lane, status, collapsed, statusSince, onToggleCollapse, onCreate, onOpenTask, }: BoardColumnProps) { // A lane must accept a drop even with no cards in it, and an empty // SortableContext registers no droppable of its own — hence the explicit @@ -97,7 +98,12 @@ export function BoardColumn({ strategy={verticalListSortingStrategy} > {lane.tasks.map((task) => ( - + ))} diff --git a/web/src/components/Tasks/Board/TaskBoard.tsx b/web/src/components/Tasks/Board/TaskBoard.tsx index b5988e01..cd72b14f 100644 --- a/web/src/components/Tasks/Board/TaskBoard.tsx +++ b/web/src/components/Tasks/Board/TaskBoard.tsx @@ -43,6 +43,7 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void }) const lanes = useTaskStore((s) => s.lanes); const boardLoading = useTaskStore((s) => s.boardLoading); const boardError = useTaskStore((s) => s.boardError); + const statusSince = useTaskStore((s) => s.statusSince); const moveTask = useTaskStore((s) => s.moveTask); const setShowCreateDialog = useTaskStore((s) => s.setShowCreateDialog); const searchQuery = useTaskStore((s) => s.searchQuery); @@ -142,6 +143,7 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void }) lane={lane} status={statusByName.get(lane.status)} collapsed={collapsed.includes(lane.status)} + statusSince={statusSince} onToggleCollapse={handleToggleCollapse} onCreate={(status) => setShowCreateDialog(true, status)} onOpenTask={onOpenTask} diff --git a/web/src/components/Tasks/TaskDetailBody.tsx b/web/src/components/Tasks/TaskDetailBody.tsx index dee04e70..1bbc16d8 100644 --- a/web/src/components/Tasks/TaskDetailBody.tsx +++ b/web/src/components/Tasks/TaskDetailBody.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import { Calendar, Edit3, ExternalLink, Eye, Save } from 'lucide-react'; +import { Calendar, Edit3, ExternalLink, Eye, History, Save } from 'lucide-react'; import type { Task } from '../../api/client'; import { useTaskStore } from '../../stores/taskStore'; import { StatusBadge, StatusSelect } from './StatusControls'; import { MarkdownContent } from '../Chat/MarkdownContent'; +import { TaskTimeline } from './TaskTimeline'; /** * The task editor itself — metadata row, edit/preview toggle, markdown @@ -19,6 +20,7 @@ export function TaskDetailBody({ task }: { task: Task }) { const updateStatus = useTaskStore((s) => s.updateStatus); const [mode, setMode] = useState<'edit' | 'preview'>('preview'); + const [showHistory, setShowHistory] = useState(false); const [localContent, setLocalContent] = useState(''); const [dirty, setDirty] = useState(false); @@ -76,6 +78,16 @@ export function TaskDetailBody({ task }: { task: Task }) { )}
+ {dirty && (
+ {showHistory && ( +
+ +
+ )} + {mode === 'edit' ? (