From f75f6d25db7a53f548f0cfd516ec62390b48ea3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:56:38 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(projects):=20WS-27v=20watchers=20?= =?UTF-8?q?=E2=80=94=20auto-subscribe,=20mention=20diffing,=20split=20unre?= =?UTF-8?q?ad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §9.1 WS-27v (plane_pm_research_2026-08.md §3.2, P-2 + P-20). Shape re-derived in this repo's idiom behind the AGPL wall; the one deliberate divergence from Plane is load-bearing: delivery stays gated per recipient by resolve_visibility_for, never by membership — a watcher row is an intent to hear, not a right to see. Migration 165: pm_task_watchers(task_id, watcher, organization_id), UNIQUE(task_id, watcher), watcher folded and human (R10 + WS-27j rule 2 as CHECKs, mirroring pm_notifications), tenant filled and verified by 161's pm_organization_from_parent trigger. Seeds task creators as watchers so the old assignees-plus-author audience survives the switch. Gateway: * watchers.py — ensure_watchers (the ONE idempotent subscribe helper), PUT/DELETE /projects/tasks/{id}/watch, GET .../watchers; R5 throughout (invisible task is 404, never 403), identity from the session only (R3). * Auto-subscribe from all four triggers: commenting, editing (only when something actually changed), being assigned (added set only, agents fenced), being @mentioned (delivered mentions only). * task_audience becomes watchers ∪ assignees; the actor-exclusion and the per-recipient visibility gate re-assert unchanged over it. * Mention diffing: editing a comment or a description notifies only the NEWLY added mentions (new_mentions, set-differenced on the folded address). Description edits fan out to the diffed mentions and nobody else. * The unread count splits into {total, mentions} in one FILTERed query so the two numbers can never describe different row sets. UI: bell badge = total with a distinct @ marker and panel count for unread mentions (tokens only); TaskPanel gains a watch/unwatch toggle (Button primitive, optimistic, hidden until the state is read); unreadSplit/afterRead pure and tested, tolerating the pre-split payload during a deploy window. Tests: shared fake learns pm_task_watchers and mirrors the audience UNION arm-by-arm off the statement text; test_projects_watchers.py proves the four triggers idempotent, watch/unwatch + R5, audience minus actor gated by the recipient's visibility (including a watcher who LOST visibility), the edit-twice mention diff, and pins migration 165 as text (found by content, R1). 910 projects/tenancy tests green; vitest + tsc green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/routes/projects/__init__.py | 1 + .../gateway/routes/projects/activities.py | 49 +- .../gateway/routes/projects/notifications.py | 55 ++- .../gateway/gateway/routes/projects/tasks.py | 39 +- .../gateway/routes/projects/watchers.py | 166 +++++++ infra/postgres/165_projects_watchers.sql | 85 ++++ tests/unit/_projects_fakes.py | 23 + tests/unit/test_projects_notifications.py | 21 + tests/unit/test_projects_watchers.py | 449 ++++++++++++++++++ .../projects/components/NotificationBell.tsx | 40 +- .../src/app/projects/components/TaskPanel.tsx | 61 ++- .../control_plane/src/app/projects/lib/api.ts | 34 +- .../app/projects/lib/notifications.test.ts | 62 +++ .../src/app/projects/lib/notifications.ts | 43 ++ 14 files changed, 1092 insertions(+), 36 deletions(-) create mode 100644 apps/services/gateway/gateway/routes/projects/watchers.py create mode 100644 infra/postgres/165_projects_watchers.sql create mode 100644 tests/unit/test_projects_watchers.py diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index 195022c65..80f68eb51 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -39,6 +39,7 @@ from gateway.routes.projects import tasks as _tasks # noqa: F401 from gateway.routes.projects import tree as _tree # noqa: F401 from gateway.routes.projects import views as _views # noqa: F401 +from gateway.routes.projects import watchers as _watchers # noqa: F401 from gateway.routes.projects.core import router __all__ = ["router"] diff --git a/apps/services/gateway/gateway/routes/projects/activities.py b/apps/services/gateway/gateway/routes/projects/activities.py index 4f602c0af..ee529de01 100644 --- a/apps/services/gateway/gateway/routes/projects/activities.py +++ b/apps/services/gateway/gateway/routes/projects/activities.py @@ -39,9 +39,11 @@ from gateway.routes.projects.notifications import ( excerpt_of, mention_targets, + new_mentions, notify, task_audience, ) +from gateway.routes.projects.watchers import ensure_watchers from pydantic import BaseModel from sqlalchemy import text @@ -128,17 +130,27 @@ async def add_comment( db, activity_type="comment", created_by=actor(user), task_id=task_id, body=body, ) + # WS-27v — commenting subscribes the commenter, BEFORE the audience is + # read so their row exists for the next event too. Harmless for this + # one: `notify` never addresses the actor (rule 1). + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) # WS-27j. Two audiences, and the order matters: a person who is BOTH - # mentioned and an assignee should get the mention, because "you were - # named" is a stronger claim on their attention than "the task you hold - # got a comment". `notifiable` dedupes within a call, so the assignee - # pass is handed only whoever the mention pass did not take. + # mentioned and a watcher/assignee should get the mention, because "you + # were named" is a stronger claim on their attention than "the task you + # follow got a comment". `notifiable` dedupes within a call, so the + # audience pass is handed only whoever the mention pass did not take. mentioned = mention_targets(body) snippet = excerpt_of(body) mention_result = await notify( db, recipients=mentioned, kind="mention", task_id=task_id, actor_id=actor(user), activity_id=str(row.id), excerpt=snippet, ) + # Being @mentioned subscribes you — but only the DELIVERED mentions: + # a person who cannot open the task must not be silently enrolled in a + # stream of its titles they could never have asked for. + await ensure_watchers( + db, task_id, mention_result["notified"], by=actor(user), + ) rest = [ who for who in await task_audience(db, task_id) if who.strip().lower() not in set(mentioned) @@ -206,8 +218,35 @@ async def edit_comment( vis = await resolve_visibility(db, user) await load_visible_task(db, vis, str(comment.task_id)) row = await update_row(db, "pm_activities", activity_id, {"body": body}) + task_id = str(comment.task_id) + # WS-27v — mention DIFFING: only the addresses this edit ADDED are + # notified. The old body is the one loaded above, before the update, so + # fixing a typo next to `@priya@…` re-pings nobody, while appending + # `@ravi@…` reaches exactly Ravi. Editing your own comment is a touch, + # so the editor (re-)subscribes too — idempotently, since they already + # subscribed when they commented. + added = new_mentions(getattr(comment, "body", None), body) + mention_result = await notify( + db, recipients=added, kind="mention", task_id=task_id, + actor_id=actor(user), activity_id=activity_id, + excerpt=excerpt_of(body), + ) + await ensure_watchers( + db, task_id, [actor(user), *mention_result["notified"]], + by=actor(user), + ) + if mention_result["notified"]: + # On the timeline, as add_comment does: why a colleague turned up + # must be readable by everyone, not only in one person's bell. + await record_activity( + db, activity_type="mention", created_by=actor(user), + task_id=task_id, + meta={"mentioned": mention_result["notified"]}, + ) await db.commit() - return row_to_dict(row, ActivityModel) + result = row_to_dict(row, ActivityModel) + result["not_notified"] = mention_result["skipped"] + return result finally: await db.close() diff --git a/apps/services/gateway/gateway/routes/projects/notifications.py b/apps/services/gateway/gateway/routes/projects/notifications.py index 3e97d688c..a9a583556 100644 --- a/apps/services/gateway/gateway/routes/projects/notifications.py +++ b/apps/services/gateway/gateway/routes/projects/notifications.py @@ -109,6 +109,19 @@ def mention_targets(body: str | None) -> list[str]: return list(seen) +def new_mentions(old_body: str | None, new_body: str | None) -> list[str]: + """Only the addresses an edit ADDED, in first-seen order (WS-27v). + + Editing a comment or description must notify the *newly named* people and + nobody twice: without this diff, fixing a typo in a comment that mentions + three colleagues re-pings all three, and a bell that repeats itself is a + bell people mute. Set-differenced on the folded address, the same identity + every other mention read uses (R10). + """ + before = set(mention_targets(old_body)) + return [who for who in mention_targets(new_body) if who not in before] + + def excerpt_of(body: str | None) -> str | None: """A single-line snippet, ellipsised. @@ -219,20 +232,27 @@ async def notify( async def task_audience(db: Any, task_id: str) -> list[str]: - """Who a comment on this task concerns: its assignees and its author. - - Derived rather than subscribed. A `pm_task_watchers` table would be the - fuller answer and is not this ticket — and the derived audience is the one - a watcher list would be seeded with anyway, so nothing here has to be undone - when one arrives. + """Who an event on this task concerns: watchers ∪ assignees (WS-27v). + + The watcher table arrived exactly as WS-27j predicted, seeded from the + audience it replaces (migration 165 subscribes every task's author), so the + author keeps hearing without being a special case here. Assignees stay in + the audience in their OWN right rather than through a watcher row: holding + the work is the claim, and unwatching must not silence an assignment. + + This is the audience, not the delivery list — ``notify`` still drops the + actor and agents (rules 1 and 2) and filters every recipient through + ``resolve_visibility_for`` (rule 3). A watcher who has lost the project's + grant keeps their row and hears nothing, which is the deliberate divergence + from Plane's membership-only check. """ rows = (await db.execute( text( - "SELECT assignee AS who FROM pm_task_assignees " + "SELECT watcher AS who FROM pm_task_watchers " "WHERE task_id = CAST(:tid AS uuid) " "UNION " - "SELECT created_by AS who FROM pm_tasks " - "WHERE id = CAST(:tid AS uuid)" + "SELECT assignee AS who FROM pm_task_assignees " + "WHERE task_id = CAST(:tid AS uuid)" ), {"tid": task_id}, )).fetchall() @@ -285,10 +305,15 @@ async def list_notifications( {"me": me, "limit": page.limit, "offset": page.offset, **vis.params}, )).fetchall() # Counted with the same visibility clause, so the badge can never - # promise more than the list can show. + # promise more than the list can show. WS-27v splits the count in the + # SAME query rather than a second one: `mentions` is a subset of + # `total` by construction here, so the two numbers can never disagree + # about which rows they describe. unread = (await db.execute( text( - "SELECT count(*) AS n FROM pm_notifications n " + "SELECT count(*) AS total, " + "count(*) FILTER (WHERE n.kind = 'mention') AS mentions " + "FROM pm_notifications n " "JOIN pm_tasks t ON t.id = n.task_id " f"WHERE n.recipient = :me AND n.read_at IS NULL AND {clause}" ), @@ -299,7 +324,13 @@ async def list_notifications( return { "rows": [_row(r) for r in rows], "total": len(rows), - "unread": int(getattr(unread, "n", 0) or 0), + # `{total, mentions}`, not a bare number: "you were named" is a + # stronger claim on attention than "something you follow moved", and + # the bell renders the two distinctly (P-20). + "unread": { + "total": int(getattr(unread, "total", 0) or 0), + "mentions": int(getattr(unread, "mentions", 0) or 0), + }, } diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index 00497d72d..8c5bcf0f2 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -62,7 +62,12 @@ attach_relation_counts, build_task_filters, ) -from gateway.routes.projects.notifications import notify +from gateway.routes.projects.notifications import ( + excerpt_of, + new_mentions, + notify, +) +from gateway.routes.projects.watchers import ensure_watchers from gateway.routes.projects.relations import ( DIRECTED_TYPES, assert_no_block_cycle, @@ -286,6 +291,10 @@ async def create_task( db, activity_type="system", created_by=actor(user), task_id=task_id, body="Task created", ) + # WS-27v — the creator watches their own task, which is what keeps the + # WS-27j author-hears-about-comments behaviour once the audience is + # watchers ∪ assignees (migration 165 seeds the same for older tasks). + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) await db.commit() result = row_to_dict(row, TaskModel) finally: @@ -376,6 +385,28 @@ async def patch_task( ) after = moved["row"] + if values or moved is not None: + # WS-27v — editing a task subscribes the editor (idempotent). Only + # when something actually changed: a no-op PATCH is not a touch. + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) + if "description" in values: + # WS-27v — mention DIFFING on the description, same rule as a + # comment edit: only the addresses this edit ADDED are notified, so + # rewording a description that already names two colleagues pings + # neither, and the newly delivered mentions become watchers. + # Watchers at large deliberately hear nothing about a description + # edit — the diffed mentions are the whole fan-out. + added = new_mentions( + getattr(before, "description", None), values["description"], + ) + mentioned = await notify( + db, recipients=added, kind="mention", task_id=task_id, + actor_id=actor(user), excerpt=excerpt_of(values["description"]), + ) + await ensure_watchers( + db, task_id, mentioned["notified"], by=actor(user), + ) + await db.commit() result = row_to_dict(after, TaskModel) finally: @@ -579,6 +610,12 @@ async def set_assignees( db, recipients=sorted(added), kind="assigned", task_id=task_id, actor_id=actor(user), ) + # WS-27v — a newly added assignee becomes a watcher. Idempotent, and + # only `added` for the same reason the event carries added: a re-assert + # is not a touch. Agents are dropped inside the helper (they are + # dispatched, never subscribed), and the rows survive a later + # unassignment — having held the work is a reason to keep hearing. + await ensure_watchers(db, task_id, sorted(added), by=actor(user)) await db.commit() finally: await db.close() diff --git a/apps/services/gateway/gateway/routes/projects/watchers.py b/apps/services/gateway/gateway/routes/projects/watchers.py new file mode 100644 index 000000000..092cdfaf1 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/watchers.py @@ -0,0 +1,166 @@ +"""Projects · watchers — who follows a task (WS-27v). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1, WS-27v. + + PUT /projects/tasks/{id}/watch → subscribe the caller + DELETE /projects/tasks/{id}/watch → unsubscribe the caller + GET /projects/tasks/{id}/watchers → {watchers, watching} + +A watcher row is an **intent to hear, never a right to see**. The fan-out in +``notifications.py`` still filters every recipient through +``resolve_visibility_for`` — Plane's membership-only check is the +counterexample, not the model — so a watcher who has lost the project's grant +keeps their row and stops hearing, exactly as they stop seeing. + +Auto-subscribe has ONE implementation: :func:`ensure_watchers`, called from +every write site that means "this person now cares about this task" — +commenting, editing, being assigned, being @mentioned, and the explicit watch +endpoint below. One helper rather than an INSERT per site, because the +casefolding (R10) and the agent fence are rules, and a rule copied five times +is a rule that drifts in one of them. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends +from gateway.routes.projects.core import ( + _get_db, + actor, + load_visible_task, + resolve_visibility, + router, +) +from sqlalchemy import text + + +def watchable(recipients: object) -> list[str]: + """The addresses a watcher row may carry: folded, deduped, human. + + Pure, and deliberately the same three fences as migration 165's CHECKs — + blanks, ``agent:`` and non-addresses are dropped here so a bad value + is silently skipped rather than surfacing as an IntegrityError 500 from a + comment that otherwise succeeded. Agents are excluded for WS-27j's rule 2: + they are handed work by the dispatch sink, and a subscription would feed an + inbox nobody ever opens. + """ + seen: dict[str, None] = {} + for raw in recipients or (): + clean = (raw or "").strip().lower() + if clean and "@" in clean and not clean.startswith("agent:"): + seen.setdefault(clean, None) + return list(seen) + + +async def ensure_watchers( + db: Any, task_id: str, recipients: object, *, by: str, +) -> list[str]: + """Idempotently subscribe people to a task; returns who was newly added. + + Idempotent by the UNIQUE(task_id, watcher) pair, not by a read-then-write: + two comments landing together must not race their way into a duplicate. + Does **not** commit — it runs inside the write that caused it, so an edit + and the subscription it implies land together or not at all. + + Deliberately no visibility check here: the write sites that call this have + already loaded the task through the caller's own visibility, and delivery + to the *watcher* is gated per event by ``resolve_visibility_for`` at + fan-out time — the one place that answer is allowed to live. + """ + added: list[str] = [] + for who in watchable(recipients): + row = (await db.execute( + text( + "INSERT INTO pm_task_watchers (task_id, watcher, created_by) " + "VALUES (CAST(:tid AS uuid), :who, :by) " + "ON CONFLICT (task_id, watcher) DO NOTHING RETURNING id" + ), + {"tid": task_id, "who": who, "by": by}, + )).fetchone() + if row is not None: + added.append(who) + return added + + +async def watchers_of(db: Any, task_id: str) -> list[str]: + """Everyone subscribed to one task, sorted.""" + rows = (await db.execute( + text( + "SELECT watcher FROM pm_task_watchers " + "WHERE task_id = CAST(:tid AS uuid) ORDER BY watcher" + ), + {"tid": task_id}, + )).fetchall() + return [r.watcher for r in rows if getattr(r, "watcher", None)] + + +@router.put("/tasks/{task_id}/watch") +async def watch_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Subscribe the caller. Idempotent — watching twice is watching. + + The identity is the session's (R3), never a body field: an endpoint that + accepted a ``watcher`` parameter would let anyone subscribe anyone else to + a stream of that task's titles. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + # R5: an invisible task is 404, never 403 — "not yours" and "no such + # task" must be one answer, or this endpoint becomes an oracle for + # which ids exist. + await load_visible_task(db, vis, task_id) + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) + await db.commit() + finally: + await db.close() + return {"task_id": task_id, "watching": True} + + +@router.delete("/tasks/{task_id}/watch") +async def unwatch_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Unsubscribe the caller. Idempotent — a row that is not there stays gone. + + Unwatching does not silence assignment: the audience is watchers ∪ + assignees, so somebody who holds the work keeps hearing about it. That is + the contract's own shape, not an oversight. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + await db.execute( + text( + "DELETE FROM pm_task_watchers " + "WHERE task_id = CAST(:tid AS uuid) AND watcher = :who" + ), + {"tid": task_id, "who": actor(user).lower()}, + ) + await db.commit() + finally: + await db.close() + return {"task_id": task_id, "watching": False} + + +@router.get("/tasks/{task_id}/watchers") +async def list_watchers( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Who watches this task, and whether the caller does — one read, so the + panel's toggle can render without a second round trip.""" + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + watchers = await watchers_of(db, task_id) + finally: + await db.close() + return { + "watchers": watchers, + "watching": actor(user).lower() in set(watchers), + } diff --git a/infra/postgres/165_projects_watchers.sql b/infra/postgres/165_projects_watchers.sql new file mode 100644 index 000000000..11067e642 --- /dev/null +++ b/infra/postgres/165_projects_watchers.sql @@ -0,0 +1,85 @@ +-- 165_projects_watchers.sql — watchers, and mentions that behave (WS-27v). +-- +-- What: pm_task_watchers — one row per person following one task. +-- Why: spec §9.1 WS-27v (P-2, P-20 in plane_pm_research_2026-08.md §3.2): the +-- notification audience becomes watchers ∪ assignees, and commenting, +-- editing, assigning or being @mentioned subscribes you — the people who +-- touched a task keep hearing about it without opting in. +-- Depends on: 146_projects.sql (pm_tasks), 152 (pm_notifications, whose +-- recipient constraints this table mirrors), 161 (organization_id and +-- the pm_organization_from_parent trigger function). +-- +-- ⚠️ Shape re-derived in this repo's idiom, never translated: the research +-- doc's AGPL wall binds this file. One deliberate divergence is recorded +-- there and restated here: Plane gates delivery on project MEMBERSHIP; our +-- gate stays `resolve_visibility_for` — a watcher row is an intent to hear, +-- never a right to see, and the read path re-checks visibility per recipient. +-- +-- **A watcher is a HUMAN address, stored folded (R10).** Same two constraints +-- as pm_notifications' recipient, for the same reasons: an `agent:` row +-- would be an inbox nobody ever opens (agents are handed work by the WS-27f +-- dispatch sink), and every read compares folded, so a mixed-case row would be +-- a subscription that never fires. +-- +-- **The tenant key (D-MT-3).** Carried on the row like every pm_* table since +-- migration 161; filled and cross-checked against the task's by the same +-- pm_organization_from_parent trigger, so no INSERT site has to remember it. +-- +-- Idempotent per infra/postgres/README.md: IF NOT EXISTS everywhere, +-- CREATE OR REPLACE TRIGGER, and a seed whose ON CONFLICT makes the second +-- run a no-op. Pinned as TEXT by tests/unit/test_projects_watchers.py. + +BEGIN; + +CREATE TABLE IF NOT EXISTS pm_task_watchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- CASCADE: a watcher row on a deleted task is a subscription to nothing. + task_id UUID NOT NULL REFERENCES pm_tasks (id) ON DELETE CASCADE, + -- Lowercased email. Never `agent:` — see the header. + watcher TEXT NOT NULL, + organization_id UUID NOT NULL REFERENCES organization (id) ON DELETE CASCADE, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pm_task_watchers_watcher_is_human + CHECK (watcher NOT LIKE 'agent:%' AND watcher <> ''), + CONSTRAINT pm_task_watchers_watcher_lowercased + CHECK (watcher = lower(watcher)), + -- One subscription per person per task. This is what makes every + -- auto-subscribe site idempotent: ensure_watchers ends in ON CONFLICT + -- DO NOTHING against this pair, so the fourth comment does not stack a + -- fourth row. + UNIQUE (task_id, watcher) +); + +-- The fan-out asks "who watches THIS task"; the UNIQUE above already indexes +-- (task_id, watcher) and serves that read, so no second task-leading index. +-- "What do I watch" gets its own when a surface actually asks it (WS-29b's +-- rule: an index earns its place from a query that filters on it). + +-- Fill-and-verify the tenant from the task's, exactly as 161 attaches the +-- other 17 pm_* tables. The function is 161's; only the attachment is new. +CREATE OR REPLACE TRIGGER trg_pm_task_watchers_org_from_task + BEFORE INSERT OR UPDATE ON pm_task_watchers + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +-- ── Seed: task creators become watchers of their own tasks ────────────────── +-- +-- WS-27j's audience was assignees ∪ the task's author, derived per comment, +-- and its docstring promised "the derived audience is the one a watcher list +-- would be seeded with anyway, so nothing here has to be undone when one +-- arrives". This is that seeding: without it, every task created before this +-- migration would silently stop notifying its author the day the audience +-- switched to watchers ∪ assignees. Assignees need no seed — they stay in the +-- audience in their own right. +-- +-- Only human authors: `agent:` and non-address authors would fail the +-- CHECKs above, and an agent cannot receive a notification anyway (152). +INSERT INTO pm_task_watchers (task_id, watcher, organization_id, created_by) +SELECT t.id, lower(t.created_by), t.organization_id, 'system:migration_165' + FROM pm_tasks t + WHERE t.created_by LIKE '%@%' + AND t.created_by NOT LIKE 'agent:%' +ON CONFLICT (task_id, watcher) DO NOTHING; + +COMMIT; diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 3545a9a1a..8ab50a2b9 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -177,6 +177,7 @@ "pm_tasks": ("pm_projects", "project_id"), "pm_activities": ("pm_tasks", "task_id"), "pm_task_assignees": ("pm_tasks", "task_id"), + "pm_task_watchers": ("pm_tasks", "task_id"), "pm_task_links": ("pm_tasks", "source_task_id"), "pm_task_attachments": ("pm_tasks", "task_id"), "pm_task_personal": ("pm_tasks", "task_id"), @@ -355,6 +356,7 @@ def _now() -> datetime: "clickup_synced_at": None, "task_number": 1, }, "pm_task_assignees": {}, + "pm_task_watchers": {"created_by": None}, "pm_task_links": {}, "pm_activities": {"body": None, "meta": None, "task_id": None, "project_id": None, "deleted_at": None}, @@ -568,6 +570,27 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: # be mistaken for a column predicate and drop every row. if "END AS rank" in statement: return _Result(self._search_hits(statement, args)) + # WS-27v's audience: watchers ∪ assignees, a UNION the generic WHERE + # reader cannot parse (it would filter watcher rows by the SECOND + # SELECT's predicates too). Each arm is honoured ONLY when the + # statement carries it — the `_select` convention — so an audience that + # loses either arm loses it here as well and the fan-out test goes red. + if "AS who" in statement and "pm_task_watchers" in statement: + wanted = str(args.get("tid")) + people: set[str] = set() + if "watcher AS who" in statement: + people |= { + str(r.get("watcher")) + for r in self.rows("pm_task_watchers") + if str(r.get("task_id")) == wanted + } + if "assignee AS who" in statement: + people |= { + str(r.get("assignee")) + for r in self.rows("pm_task_assignees") + if str(r.get("task_id")) == wanted + } + return _Result([SimpleNamespace(who=w) for w in sorted(people)]) head = statement.split(None, 1)[0].upper() table = self._table(statement) if head == "INSERT": diff --git a/tests/unit/test_projects_notifications.py b/tests/unit/test_projects_notifications.py index c376171ca..5fff9d6cd 100644 --- a/tests/unit/test_projects_notifications.py +++ b/tests/unit/test_projects_notifications.py @@ -219,6 +219,10 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: who = args.get("vis_email") seen = who is None or who in self.visible_to return _Result([SimpleNamespace(x=1)] if seen else []) + # WS-27v's split badge count — one query, two aggregates, so the two + # numbers can never describe different row sets. + if "FILTER (WHERE n.kind = 'mention')" in statement: + return _Result([SimpleNamespace(total=3, mentions=1)]) if "assignee AS who" in statement: return _Result([SimpleNamespace(who=w) for w in self.audience]) if "UPDATE pm_notifications" in statement: @@ -415,6 +419,23 @@ def test_the_badge_count_cannot_promise_more_than_the_list_shows(monkeypatch): assert "read_at IS NULL" in counted +def test_the_unread_count_is_split_into_total_and_mentions(monkeypatch): + """WS-27v. `{total, mentions}` rather than a bare number: "you were named" + is a stronger claim on attention than "something you follow moved", and the + bell draws the two distinctly. Counted in ONE query with a FILTER, through + the same visibility clause and unread predicate as before, so mentions is a + subset of total by construction.""" + db = FakeDB() + bind(monkeypatch, db, pm_notify, pm_core) + result = run(pm_notify.list_notifications(user=ACTOR, page=page())) + assert result["unread"] == {"total": 3, "mentions": 1} + counted = next( + s for s in db.statements if "FILTER (WHERE n.kind = 'mention')" in s + ) + assert "pm_project_grants" in counted + assert "read_at IS NULL" in counted + + def test_unread_only_narrows_the_list(monkeypatch): db = FakeDB() bind(monkeypatch, db, pm_notify, pm_core) diff --git a/tests/unit/test_projects_watchers.py b/tests/unit/test_projects_watchers.py new file mode 100644 index 000000000..bdbef9975 --- /dev/null +++ b/tests/unit/test_projects_watchers.py @@ -0,0 +1,449 @@ +"""WS-27v — watchers, and mentions that behave. + +Spec: `ai-company-brain/specs/project_management_app.md` §9.1 (WS-27v), from +`plane_pm_research_2026-08.md` §3.2 (P-2, P-20). + +Four claims, each of which is the ticket: + +* **auto-subscribe is idempotent, from every trigger** — commenting, editing, + being assigned, being @mentioned. One helper (`ensure_watchers`), one UNIQUE + pair, so the fourth comment does not stack a fourth row; +* **the audience is watchers ∪ assignees, minus the actor, gated by the + RECIPIENT'S visibility** — Plane's membership-only check is the + counterexample: a watcher row is an intent to hear, never a right to see, + and `resolve_visibility_for` stays the gate; +* **mention diffing** — editing a comment or description notifies only the + NEWLY added mentions, proven by editing one comment twice; +* **watch/unwatch answer R5** — an invisible task is 404, never 403. + +Run against the shared `_projects_fakes.FakeProjectsDB` so the real route +functions execute end to end: the fake mirrors the audience UNION and the +visibility probe off the statement text, so a route that loses either arm +loses it here too. Hermetic: no Postgres, no TestClient. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import notifications as pm_notify +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import watchers as pm_watchers +from gateway.routes.projects.notifications import new_mentions +from gateway.routes.projects.watchers import watchable + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_tasks, pm_activities, pm_notify, pm_watchers) +USER = projects_user() +MIGRATIONS = Path(__file__).resolve().parents[2] / "infra" / "postgres" +PACKAGE = Path(pm_watchers.__file__).parent + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + silence_events(monkeypatch, MODULES) + return fake + + +def _task(db: FakeProjectsDB, *, subject: str | None = "org"): + project = db.seed_project(name="Ops", subject=subject) + todo = db.seed_status(project.id, name="To do", category="todo") + task = db.seed_task(project.id, todo.id) + return project, todo, task + + +def _watchers(db: FakeProjectsDB, task_id: str) -> list[str]: + return sorted( + r["watcher"] for r in db.rows("pm_task_watchers") + if str(r["task_id"]) == str(task_id) + ) + + +def _notified(db: FakeProjectsDB, kind: str | None = None) -> list[str]: + return [ + str(r["recipient"]) for r in db.rows("pm_notifications") + if kind is None or r["kind"] == kind + ] + + +# ── The pure half ─────────────────────────────────────────────────────────── + +def test_watchable_folds_dedupes_and_drops_agents_and_blanks(): + """R10 on the way in, and WS-27j's rule 2: an agent subscription would feed + an inbox nobody ever opens.""" + assert watchable( + ["Priya@Fracktal.IN", "priya@fracktal.in", "agent:builder", " ", None, + "not-an-address"], + ) == ["priya@fracktal.in"] + + +def test_new_mentions_is_the_set_difference_in_first_seen_order(): + assert new_mentions( + "ping @a@x.co", "ping @a@x.co then @c@x.co and @b@x.co", + ) == ["c@x.co", "b@x.co"] + + +def test_new_mentions_of_a_first_body_is_every_mention(): + assert new_mentions(None, "hey @a@x.co") == ["a@x.co"] + + +def test_a_removed_then_retyped_mention_is_not_new(): + """Case-insensitively (R10): re-spelling `@A@X.CO` is the same person.""" + assert new_mentions("hi @a@x.co", "hi @A@X.CO") == [] + + +# ── Auto-subscribe: all four triggers, each idempotent ────────────────────── + +async def test_commenting_subscribes_the_commenter_once(db: FakeProjectsDB): + _, _, task = _task(db) + for _ in range(2): + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="on it"), user=USER, + ) + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_editing_subscribes_the_editor_once(db: FakeProjectsDB): + _, _, task = _task(db) + for title in ("First", "Second"): + await pm_tasks.patch_task( + str(task.id), pm_tasks.TaskIn(title=title), user=USER, + ) + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_a_noop_patch_is_not_a_touch(db: FakeProjectsDB): + _, _, task = _task(db) + await pm_tasks.patch_task(str(task.id), pm_tasks.TaskIn(), user=USER) + assert _watchers(db, task.id) == [] + + +async def test_an_added_assignee_is_subscribed_once_and_agents_never( + db: FakeProjectsDB, +): + _, _, task = _task(db) + for _ in range(2): + await pm_tasks.set_assignees( + str(task.id), + pm_tasks.AssigneesIn( + assignees=["priya@fracktal.in", "agent:builder"], + ), + user=USER, + ) + assert _watchers(db, task.id) == ["priya@fracktal.in"] + + +async def test_a_delivered_mention_subscribes_its_target_once( + db: FakeProjectsDB, +): + _, _, task = _task(db) + for _ in range(2): + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="see @priya@fracktal.in"), + user=USER, + ) + assert "priya@fracktal.in" in _watchers(db, task.id) + assert _watchers(db, task.id).count("priya@fracktal.in") == 1 + + +async def test_an_undeliverable_mention_subscribes_nobody(db: FakeProjectsDB): + """A person who cannot open the task must not be enrolled in a stream of + its titles — the subscription follows the delivery, and the delivery is + gated by the recipient's own visibility.""" + _, _, task = _task(db, subject="owner@fracktal.in") + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="see @outsider@fracktal.in"), + user=USER, + ) + assert "outsider@fracktal.in" not in _watchers(db, task.id) + assert _notified(db, "mention") == [] + + +async def test_the_creator_watches_their_own_task(db: FakeProjectsDB): + """What keeps WS-27j's author-hears-about-comments once the audience is + watchers ∪ assignees; migration 165 seeds the same for existing tasks.""" + project, _, _ = _task(db) + created = await pm_tasks.create_task( + pm_tasks.TaskIn(project_id=str(project.id), title="Mine"), user=USER, + ) + assert _watchers(db, created["id"]) == ["owner@fracktal.in"] + + +# ── Watch / unwatch ───────────────────────────────────────────────────────── + +async def test_watch_is_idempotent_and_folded(db: FakeProjectsDB): + _, _, task = _task(db) + caller = projects_user(email="Owner@Fracktal.IN") + for _ in range(2): + result = await pm_watchers.watch_task(str(task.id), user=caller) + assert result == {"task_id": str(task.id), "watching": True} + # R10: stored folded, or the unwatch and the fan-out would both miss it. + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_unwatch_removes_only_the_caller(db: FakeProjectsDB): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="other@fracktal.in") + await pm_watchers.watch_task(str(task.id), user=USER) + result = await pm_watchers.unwatch_task(str(task.id), user=USER) + assert result == {"task_id": str(task.id), "watching": False} + assert _watchers(db, task.id) == ["other@fracktal.in"] + + +async def test_the_watchers_read_names_the_callers_own_state( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="owner@fracktal.in") + result = await pm_watchers.list_watchers(str(task.id), user=USER) + assert result == {"watchers": ["owner@fracktal.in"], "watching": True} + + +@pytest.mark.parametrize( + "route", [pm_watchers.watch_task, pm_watchers.unwatch_task, + pm_watchers.list_watchers], +) +async def test_an_invisible_task_is_404_never_403(db: FakeProjectsDB, route): + """R5. "not yours" and "no such task" must be one answer, or the endpoint + becomes an oracle for which ids exist in another department.""" + _, _, task = _task(db, subject=None) + with pytest.raises(HTTPException) as exc: + await route(str(task.id), user=member_user("nobody@fracktal.in")) + assert exc.value.status_code == 404 + + +def test_the_identity_comes_from_the_session_not_a_parameter(): + """R3 — a `watcher` parameter would let anyone subscribe anyone else to a + stream of a task's titles.""" + import inspect + + for route in (pm_watchers.watch_task, pm_watchers.unwatch_task): + assert "watcher" not in set(inspect.signature(route).parameters) + + +# ── The audience: watchers ∪ assignees, minus the actor, gated per person ─── + +async def test_a_comment_reaches_watchers_and_assignees_but_never_the_actor( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="watcher@fracktal.in") + db.seed( + "pm_task_assignees", task_id=task.id, assignee="holder@fracktal.in", + assigned_by="owner@fracktal.in", + ) + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="no names here"), user=USER, + ) + # The commenter auto-subscribed above and is STILL not notified — the + # actor-exclusion rule re-asserted over the new audience. + assert sorted(_notified(db, "comment")) == [ + "holder@fracktal.in", "watcher@fracktal.in", + ] + + +async def test_a_watcher_who_lost_visibility_is_not_notified( + db: FakeProjectsDB, +): + """The deliberate divergence from Plane: the watcher row survives, the + delivery does not. `resolve_visibility_for` is the gate, per recipient.""" + _, _, task = _task(db, subject="insider@fracktal.in") + db.seed("pm_task_watchers", task_id=task.id, watcher="insider@fracktal.in") + db.seed("pm_task_watchers", task_id=task.id, watcher="gone@fracktal.in") + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="quarterly update"), + user=USER, + ) + assert _notified(db, "comment") == ["insider@fracktal.in"] + # The row is intact: visibility is checked at delivery, never persisted. + assert "gone@fracktal.in" in _watchers(db, task.id) + + +async def test_a_mentioned_watcher_gets_the_mention_not_the_comment_too( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="priya@fracktal.in") + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="over to @priya@fracktal.in"), + user=USER, + ) + mine = [ + r["kind"] for r in db.rows("pm_notifications") + if r["recipient"] == "priya@fracktal.in" + ] + assert mine == ["mention"] + + +def test_the_audience_is_the_one_helper_not_a_second_query(): + """`task_audience` is the single place "who hears" is derived; a second + UNION grown beside it is how the arms start disagreeing.""" + source = Path(pm_notify.__file__).read_text(encoding="utf-8") + body = source.split("async def task_audience", 1)[1] + body = body.split("\nasync def ", 1)[0].split("\n# ──", 1)[0] + assert "pm_task_watchers" in body + assert "pm_task_assignees" in body + + +# ── Mention diffing: the edit-twice proof ─────────────────────────────────── + +async def test_editing_a_comment_twice_notifies_only_the_new_mention( + db: FakeProjectsDB, +): + """THE ticket's test. First edit adds @b — b is notified. Second edit keeps + @b and adds @c — ONLY c is notified, or every typo fix re-pings the whole + thread.""" + _, _, task = _task(db) + posted = await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="draft"), user=USER, + ) + await pm_activities.edit_comment( + posted["id"], + pm_activities.CommentIn(body="draft @b@fracktal.in"), + user=USER, + ) + assert _notified(db, "mention") == ["b@fracktal.in"] + + await pm_activities.edit_comment( + posted["id"], + pm_activities.CommentIn(body="draft @b@fracktal.in @c@fracktal.in"), + user=USER, + ) + # One NEW row, addressed to c alone — b was not re-notified. + assert _notified(db, "mention") == ["b@fracktal.in", "c@fracktal.in"] + # And both delivered mentions became watchers, once each. + assert set(_watchers(db, task.id)) >= {"b@fracktal.in", "c@fracktal.in"} + + +async def test_a_description_edit_notifies_only_added_mentions( + db: FakeProjectsDB, +): + """Same diff on the task body, and NOTHING else: watchers at large hear + nothing about a description edit — the diffed mentions are the fan-out.""" + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="watcher@fracktal.in") + await pm_tasks.patch_task( + str(task.id), + pm_tasks.TaskIn(description="scope for @b@fracktal.in"), user=USER, + ) + assert _notified(db) == ["b@fracktal.in"] + + await pm_tasks.patch_task( + str(task.id), + pm_tasks.TaskIn( + description="scope for @b@fracktal.in and @c@fracktal.in", + ), + user=USER, + ) + assert _notified(db) == ["b@fracktal.in", "c@fracktal.in"] + assert _notified(db, "comment") == [] + + +# ── One helper, one INSERT site ───────────────────────────────────────────── + +def test_every_subscription_goes_through_ensure_watchers(): + """The auto-subscribe rule lives once. A copy-pasted INSERT would casefold + or fence agents differently the day one of them is edited alone.""" + inserting = [ + path.name for path in sorted(PACKAGE.glob("*.py")) + if "INSERT INTO pm_task_watchers" in path.read_text(encoding="utf-8") + ] + assert inserting == ["watchers.py"] + for module in ("tasks.py", "activities.py"): + assert "ensure_watchers(" in (PACKAGE / module).read_text( + encoding="utf-8", + ) + + +# ── Migration 165, read as text (R1: found by content, never by number) ───── + +def _watchers_migration() -> Path: + found = [ + path for path in sorted(MIGRATIONS.glob("*.sql")) + if path.name != "schema.generated.sql" + and "CREATE TABLE IF NOT EXISTS pm_task_watchers" + in path.read_text(encoding="utf-8") + ] + assert len(found) == 1, ( + f"expected exactly one migration creating pm_task_watchers, found " + f"{[p.name for p in found]}" + ) + return found[0] + + +@pytest.fixture(scope="module") +def sql() -> str: + raw = _watchers_migration().read_text(encoding="utf-8") + # Comments stripped: an assertion the header's prose could satisfy is an + # assertion that fails open. + return "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + + +def test_the_table_is_guarded_and_unique_per_pair(sql: str): + assert "CREATE TABLE IF NOT EXISTS pm_task_watchers" in sql + assert re.search(r"UNIQUE\s*\(\s*task_id\s*,\s*watcher\s*\)", sql, re.I) + + +def test_a_watcher_is_stored_folded(sql: str): + """R10 — every read compares folded; a mixed-case row is a subscription + that never fires.""" + assert re.search(r"watcher\s*=\s*lower\s*\(\s*watcher\s*\)", sql) + + +def test_an_agent_cannot_be_a_watcher(sql: str): + assert re.search(r"watcher\s+NOT\s+LIKE\s+'agent:%'", sql) + + +def test_a_deleted_task_takes_its_watchers_with_it(sql: str): + block = sql.split("CREATE TABLE IF NOT EXISTS pm_task_watchers", 1)[1] + block = block.split(");", 1)[0] + assert re.search( + r"task_id\s+UUID\s+NOT\s+NULL\s+REFERENCES\s+pm_tasks\s*\(id\)" + r"\s+ON\s+DELETE\s+CASCADE", + block, re.I, + ) + + +def test_the_tenant_key_is_carried_and_derived(sql: str): + """D-MT-3 (the column) and migration 161's trigger (the fill-and-verify), + attached exactly as the other pm_* tables attach it.""" + block = sql.split("CREATE TABLE IF NOT EXISTS pm_task_watchers", 1)[1] + block = block.split(");", 1)[0] + assert re.search( + r"organization_id\s+UUID\s+NOT\s+NULL\s+REFERENCES\s+organization" + r"\s*\(id\)\s+ON\s+DELETE\s+CASCADE", + block, re.I, + ) + assert re.search( + r"CREATE OR REPLACE TRIGGER \S+\s+BEFORE INSERT OR UPDATE ON " + r"pm_task_watchers", + sql, + ) + assert "pm_organization_from_parent('pm_tasks', 'task_id')" in sql + + +def test_the_seed_replays_as_a_noop_and_seeds_only_humans(sql: str): + """The runner replays every migration on every deploy; and the seeded + authors must satisfy the table's own CHECKs or the second deploy dies.""" + seed = re.search(r"INSERT\s+INTO\s+pm_task_watchers(.*?);", sql, re.S) + assert seed is not None + assert re.search(r"ON\s+CONFLICT\s*\(\s*task_id\s*,\s*watcher\s*\)", seed.group(1)) + assert "NOT LIKE 'agent:%'" in seed.group(1) + assert "lower(t.created_by)" in seed.group(1) diff --git a/workbench/control_plane/src/app/projects/components/NotificationBell.tsx b/workbench/control_plane/src/app/projects/components/NotificationBell.tsx index 52a13c6b3..e5e053808 100644 --- a/workbench/control_plane/src/app/projects/components/NotificationBell.tsx +++ b/workbench/control_plane/src/app/projects/components/NotificationBell.tsx @@ -19,11 +19,14 @@ import Button from "@/components/ui/Button"; import { type NotificationRow, notificationsApi } from "../lib/api"; import { + type UnreadSplit, + afterRead, badge, describe, linkTo, order, unreadIds, + unreadSplit, } from "../lib/notifications"; /** How often the badge re-checks while the tab is open. */ @@ -34,7 +37,7 @@ export function NotificationBell({ onOpenTask }: { onOpenTask?: (taskId: string) => void; }) { const [rows, setRows] = useState([]); - const [unread, setUnread] = useState(0); + const [unread, setUnread] = useState({ total: 0, mentions: 0 }); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const box = useRef(null); @@ -43,7 +46,7 @@ export function NotificationBell({ onOpenTask }: { try { const res = await notificationsApi.list(); setRows(order(res.rows)); - setUnread(res.unread); + setUnread(unreadSplit(res.unread)); setError(null); } catch (err) { setError((err as Error).message); @@ -87,7 +90,7 @@ export function NotificationBell({ onOpenTask }: { r.id === row.id ? { ...r, read_at: new Date().toISOString() } : r, ), ); - setUnread((n) => Math.max(0, n - 1)); + setUnread((u) => afterRead(u, row.kind)); try { await notificationsApi.markRead([row.id]); } catch { @@ -101,7 +104,7 @@ export function NotificationBell({ onOpenTask }: { const dismissAll = async () => { const ids = unreadIds(rows); if (!ids.length) return; - setUnread(0); + setUnread({ total: 0, mentions: 0 }); setRows((prev) => prev.map((r) => (r.read_at ? r : { ...r, read_at: new Date().toISOString() })), ); @@ -112,7 +115,8 @@ export function NotificationBell({ onOpenTask }: { } }; - const count = badge(unread); + const count = badge(unread.total); + const mentionCount = badge(unread.mentions); return (
@@ -120,7 +124,13 @@ export function NotificationBell({ onOpenTask }: { variant="ghost" size="icon-sm" icon="Bell" - aria-label={count ? `Notifications (${count} unread)` : "Notifications"} + aria-label={ + count + ? `Notifications (${count} unread${ + mentionCount ? `, ${mentionCount} mentions` : "" + })` + : "Notifications" + } onClick={() => setOpen((v) => !v)} /> {count ? ( @@ -131,6 +141,17 @@ export function NotificationBell({ onOpenTask }: { {count} ) : null} + {/* WS-27v: mentions get their own marker, below the total so the two + never overlap. `@` rather than a second number at this size — the + count itself is in the aria-label and the panel header. */} + {mentionCount ? ( + + @ + + ) : null} {open ? (
Notifications + {unread.mentions > 0 ? ( + + {badge(unread.mentions)} @ + + ) : null} - {unread > 0 ? ( + {unread.total > 0 ? ( diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index b3e92e872..7780d422f 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -10,6 +10,8 @@ import Icon from "@/components/Icon"; import { useEffect, useRef, useState } from "react"; +import Button from "@/components/ui/Button"; + import { type ActivityRow, type AttachmentRow, @@ -19,6 +21,7 @@ import { type TaskRow, attachmentsApi, projectsApi, + watchersApi, } from "../lib/api"; import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; @@ -118,6 +121,9 @@ export function TaskPanel({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [files, setFiles] = useState([]); + // WS-27v — null until the read lands, so the toggle never renders a state + // it is only guessing at. + const [watching, setWatching] = useState(null); const assignees = task.assignees ?? []; // Agents are excluded: an agent cannot receive a notification (migration // 152's CHECK), so offering to mention one would promise nothing. @@ -139,11 +145,31 @@ export function TaskPanel({ // Attachments failing must not blank the panel: the timeline and the // status control are the reason somebody opened it. .catch(() => undefined); + setWatching(null); + watchersApi + .get(task.id) + .then((res) => { + if (live) setWatching(res.watching); + }) + // Same posture as attachments: no toggle beats a blank panel. + .catch(() => undefined); return () => { live = false; }; }, [task.id]); + async function toggleWatch() { + if (watching === null) return; + // Optimistic — both writes are idempotent, so a failure just reverts. + const next = !watching; + setWatching(next); + try { + await (next ? watchersApi.watch(task.id) : watchersApi.unwatch(task.id)); + } catch { + setWatching(!next); + } + } + async function uploadFiles(picked: FileList | null) { if (!picked || picked.length === 0) return; setBusy(true); @@ -300,14 +326,33 @@ export function TaskPanel({

{task.title}

- +
+ {/* WS-27v — watch/unwatch. Watching means the bell hears about this + task; assignees hear regardless, so unwatching never silences + work you hold. Hidden (not disabled) until the state is known. */} + {watching !== null ? ( + +
diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index e8c7dbf91..736d1f673 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -531,10 +531,14 @@ export interface NotificationRow { * request shape that reads somebody else's bell. */ export const notificationsApi = { + // `unread` split since WS-27v: mentions are the subset whose reason is a + // mention, drawn distinctly on the bell. list: (unreadOnly = false) => - call<{ rows: NotificationRow[]; total: number; unread: number }>( - `notifications${unreadOnly ? "?unread_only=true" : ""}` - ), + call<{ + rows: NotificationRow[]; + total: number; + unread: { total: number; mentions: number }; + }>(`notifications${unreadOnly ? "?unread_only=true" : ""}`), markRead: (ids: string[]) => call<{ marked: number }>("notifications/read", { @@ -549,6 +553,30 @@ export const notificationsApi = { }), }; +/** + * Watchers (WS-27v). + * + * The watcher is always the session's identity — no parameter, same contract + * as the bell above: there is no request shape that subscribes somebody else. + * Both writes are idempotent, so the toggle can be optimistic. + */ +export const watchersApi = { + get: (taskId: string) => + call<{ watchers: string[]; watching: boolean }>( + `tasks/${taskId}/watchers` + ), + + watch: (taskId: string) => + call<{ task_id: string; watching: boolean }>(`tasks/${taskId}/watch`, { + method: "PUT", + }), + + unwatch: (taskId: string) => + call<{ task_id: string; watching: boolean }>(`tasks/${taskId}/watch`, { + method: "DELETE", + }), +}; + export interface TaskAccountRow { id: string; provider: string; diff --git a/workbench/control_plane/src/app/projects/lib/notifications.test.ts b/workbench/control_plane/src/app/projects/lib/notifications.test.ts index 7ab7c4a7b..8f1b2e43f 100644 --- a/workbench/control_plane/src/app/projects/lib/notifications.test.ts +++ b/workbench/control_plane/src/app/projects/lib/notifications.test.ts @@ -13,6 +13,7 @@ import type { NotificationRow } from "./api"; import { BADGE_MAX, actorLabel, + afterRead, badge, describe, insertMention, @@ -22,6 +23,7 @@ import { order, taskLabel, unreadIds, + unreadSplit, } from "./notifications"; const row = (over: Partial = {}): NotificationRow => ({ @@ -57,6 +59,66 @@ suite("badge", () => { }); }); +suite("unreadSplit", () => { + it("passes a well-formed split through", () => { + expect(unreadSplit({ total: 5, mentions: 2 })).toEqual({ + total: 5, + mentions: 2, + }); + }); + + it("tolerates the pre-WS-27v bare number", () => { + // A stale gateway behind a fresh UI is a deploy-window reality; a bell + // that crashes during it notifies nobody about anything. + expect(unreadSplit(7)).toEqual({ total: 7, mentions: 0 }); + }); + + it("clamps mentions to total — a subset cannot outgrow its set", () => { + expect(unreadSplit({ total: 1, mentions: 9 })).toEqual({ + total: 1, + mentions: 1, + }); + }); + + it("zeroes nonsense instead of drawing it", () => { + expect(unreadSplit(null)).toEqual({ total: 0, mentions: 0 }); + expect(unreadSplit({ total: -3, mentions: NaN })).toEqual({ + total: 0, + mentions: 0, + }); + }); +}); + +suite("afterRead", () => { + it("shrinks both counts when the row read was a mention", () => { + expect(afterRead({ total: 3, mentions: 2 }, "mention")).toEqual({ + total: 2, + mentions: 1, + }); + }); + + it("shrinks only the total for any other kind", () => { + expect(afterRead({ total: 3, mentions: 2 }, "comment")).toEqual({ + total: 2, + mentions: 2, + }); + }); + + it("never goes negative on a double-click", () => { + expect(afterRead({ total: 0, mentions: 0 }, "mention")).toEqual({ + total: 0, + mentions: 0, + }); + }); + + it("keeps mentions a subset even from an inconsistent input", () => { + expect(afterRead({ total: 1, mentions: 1 }, "comment")).toEqual({ + total: 0, + mentions: 0, + }); + }); +}); + suite("insertMention", () => { it("writes the address form, because it is the only one parsed", () => { // Migration 148 dropped UNIQUE(name): `@Priya` has no answer. diff --git a/workbench/control_plane/src/app/projects/lib/notifications.ts b/workbench/control_plane/src/app/projects/lib/notifications.ts index bba12ca5c..1fad56b8d 100644 --- a/workbench/control_plane/src/app/projects/lib/notifications.ts +++ b/workbench/control_plane/src/app/projects/lib/notifications.ts @@ -24,6 +24,49 @@ export function badge(unread: number): string | null { return unread > BADGE_MAX ? `${BADGE_MAX}+` : String(unread); } +/** + * The unread badge, split (WS-27v): `mentions` is the subset of `total` whose + * reason is a mention — "you were named", the stronger claim on attention. + */ +export interface UnreadSplit { + total: number; + mentions: number; +} + +const sane = (n: unknown): number => + typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + +/** + * Normalise the server's unread payload into a split the bell can draw. + * + * Tolerates the pre-WS-27v bare number (a stale gateway behind a fresh UI is + * a deploy-window reality, and a bell that crashes during it notifies nobody + * about anything) and clamps `mentions` to `total`, because a subset larger + * than its set is a payload bug the badge must not amplify. + */ +export function unreadSplit( + raw: number | Partial | null | undefined, +): UnreadSplit { + if (typeof raw === "number") return { total: sane(raw), mentions: 0 }; + const total = sane(raw?.total); + return { total, mentions: Math.min(sane(raw?.mentions), total) }; +} + +/** + * The split after one row of `kind` is read — the optimistic update the bell + * applies before the server confirms. Mentions only shrink when the row read + * was a mention; both floors are zero so a double-click cannot go negative. + */ +export function afterRead(split: UnreadSplit, kind: string): UnreadSplit { + return { + total: Math.max(0, split.total - 1), + mentions: + kind === "mention" + ? Math.max(0, split.mentions - 1) + : Math.min(split.mentions, Math.max(0, split.total - 1)), + }; +} + /** The verb for each kind. */ const VERB: Record = { assigned: "assigned you", From 0f13f9031b90aee5f0faed734c850204a98d702e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:00:05 +0000 Subject: [PATCH 2/8] feat(projects): WS-27w read-path and history hardening (archive guard, activity labels, coalescing, semantic sorts, picker exclusions, human IDs) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/routes/projects/activities.py | 29 +- .../gateway/routes/projects/automation.py | 11 +- .../gateway/gateway/routes/projects/core.py | 206 ++++++- .../gateway/gateway/routes/projects/search.py | 104 +++- .../gateway/gateway/routes/projects/tasks.py | 103 +++- .../gateway/gateway/routes/projects/tree.py | 11 +- tests/unit/_projects_fakes.py | 44 ++ tests/unit/test_projects_hardening.py | 523 ++++++++++++++++++ tests/unit/test_projects_routes.py | 8 +- .../src/app/projects/components/TaskBoard.tsx | 4 +- .../src/app/projects/components/TaskList.tsx | 4 +- .../src/app/projects/components/TaskPanel.tsx | 38 +- .../src/app/projects/lib/card.test.ts | 30 +- .../src/app/projects/lib/card.ts | 24 + 14 files changed, 1092 insertions(+), 47 deletions(-) create mode 100644 tests/unit/test_projects_hardening.py diff --git a/apps/services/gateway/gateway/routes/projects/activities.py b/apps/services/gateway/gateway/routes/projects/activities.py index 4f602c0af..95c07adb3 100644 --- a/apps/services/gateway/gateway/routes/projects/activities.py +++ b/apps/services/gateway/gateway/routes/projects/activities.py @@ -31,6 +31,7 @@ load_visible_task, now, record_activity, + record_field_change, resolve_visibility, router, row_to_dict, @@ -323,20 +324,20 @@ async def revert_change( ) await update_row(db, "pm_tasks", str(task.id), restore) - await record_activity( - db, activity_type="field_change", created_by=actor(user), - task_id=str(task.id), - meta={ - "changes": [ - {"field": f, "old": c.get("new"), "new": c.get("old")} - for c in changes for f in [c.get("field")] - if f in restore or ( - custom_restored is not None - and str(f or "").startswith(_CUSTOM_PREFIX) - ) - ], - "reverted_activity_id": activity_id, - }, + # The ONE field_change door (WS-27w). `extra_meta` marks this as a + # revert, which also exempts it from description coalescing — undoing + # an edit must never be folded into the edit it undoes. + await record_field_change( + db, created_by=actor(user), task_id=str(task.id), + changes=[ + {"field": f, "old": c.get("new"), "new": c.get("old")} + for c in changes for f in [c.get("field")] + if f in restore or ( + custom_restored is not None + and str(f or "").startswith(_CUSTOM_PREFIX) + ) + ], + extra_meta={"reverted_activity_id": activity_id}, ) await db.commit() task_id = str(task.id) diff --git a/apps/services/gateway/gateway/routes/projects/automation.py b/apps/services/gateway/gateway/routes/projects/automation.py index 6120d11b1..812a453d9 100644 --- a/apps/services/gateway/gateway/routes/projects/automation.py +++ b/apps/services/gateway/gateway/routes/projects/automation.py @@ -41,7 +41,7 @@ apply_status_transition, coerce_write_values, diff_changes, - record_activity, + record_field_change, require_row, update_row, ) @@ -154,9 +154,12 @@ async def apply_task_patch( diffs = diff_changes(task, after, _TRACKED) changed = [str(d["field"]) for d in diffs] if diffs: - await record_activity( - db, activity_type="field_change", created_by=actor, - task_id=task_id, meta={"changes": diffs}, + # The ONE field_change door (WS-27w): an automation's edit gets + # the same label resolution and description coalescing a human + # PATCH gets — a workflow that rewrites a description every run + # must not write a timeline row every run. + await record_field_change( + db, created_by=actor, task_id=task_id, changes=diffs, ) status_name: str | None = None diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index 1cfa69bef..06080dba1 100644 --- a/apps/services/gateway/gateway/routes/projects/core.py +++ b/apps/services/gateway/gateway/routes/projects/core.py @@ -286,16 +286,43 @@ def offset(self) -> int: return (self.page - 1) * self.page_size -#: Wire sort key → the column it may order by. This dict IS the allowlist: -#: anything not a key here is a 422, never a silent fall back to the default. +#: The deterministic tail EVERY sort ends with (WS-27w item 4, P-6). Without +#: it two tasks that tie on the sort key have no total order, and a tie +#: straddling a page boundary appears on both pages — or neither — depending +#: on the plan. ``{dir}`` is the direction slot the endpoint formats in. +SORT_TIEBREAK = "t.created_at {dir}, t.id {dir}" + +#: :data:`STATUS_CATEGORIES` in lifecycle order, as a SQL array literal for the +#: semantic status sort. Built from the tuple rather than written twice, so the +#: rank can never drift from the vocabulary the CHECK mirrors. +_CATEGORY_RANK_ARRAY = "ARRAY[" + ", ".join(f"'{c}'" for c in STATUS_CATEGORIES) + "]" + +#: Wire sort key → the ORDER BY fragment it may use, with ``{dir}`` as the +#: direction slot. This dict IS the allowlist: anything not a key here is a +#: 422, never a silent fall back to the default. +#: +#: Two rules, both pinned structurally (``test_projects_hardening``): +#: +#: * ``status`` is SEMANTIC — category rank in lifecycle order, then the lane's +#: own board position, never the status NAME (P-6). Alphabetical status sort +#: puts "Backlog" before "Done" only by accident of language, and every lane +#: rename reshuffles the list. +#: * every entry ends with :data:`SORT_TIEBREAK`, so the order is total and +#: pagination never straddles a tie. TASK_SORTS: dict[str, str] = { - "created_at": "t.created_at", - "updated_at": "t.updated_at", - "due_at": "t.due_at", - "importance": "t.importance", - "title": "t.title", - "task_number": "t.task_number", - "completed_at": "t.completed_at", + "created_at": SORT_TIEBREAK, + "updated_at": f"t.updated_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "due_at": f"t.due_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "importance": f"t.importance {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "title": f"t.title {{dir}}, {SORT_TIEBREAK}", + "task_number": f"t.task_number {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "completed_at": f"t.completed_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "status": ( + f"(SELECT array_position({_CATEGORY_RANK_ARRAY}, s.category)" + f" FROM pm_task_statuses s WHERE s.id = t.status_id) {{dir}} NULLS LAST, " + f"(SELECT s.position FROM pm_task_statuses s WHERE s.id = t.status_id)" + f" {{dir}} NULLS LAST, {SORT_TIEBREAK}" + ), } DIRECTIONS: dict[str, str] = {"asc": "ASC", "desc": "DESC"} @@ -1163,6 +1190,167 @@ def diff_changes(before: Any, after: Any, fields: tuple[str, ...]) -> list[dict] return changes +#: FK-valued fields a ``field_change`` may record, and where each one's human +#: label lives: field → (table, label column). WS-27w item 2 (P-5): a change +#: entry that stores only the UUID renders as a UUID the moment the row it +#: points at is renamed or deleted, so labels are resolved AT WRITE TIME and +#: stored beside the ids — history survives a lane rename without a join. +#: +#: ``status_id`` is here even though a status move is a TRANSITION with its own +#: activity type: a future call site that diffs it anyway must still resolve +#: labels rather than store bare ids. +FK_LABEL_FIELDS: dict[str, tuple[str, str]] = { + "status_id": ("pm_task_statuses", "name"), + "type_id": ("pm_task_types", "name"), + "parent_task_id": ("pm_tasks", "title"), + "project_id": ("pm_projects", "name"), + "parent_project_id": ("pm_projects", "name"), +} + +#: Fields whose consecutive same-actor edits COALESCE into the prior activity +#: row instead of appending (WS-27w item 3, P-5): an autosaving editor +#: otherwise writes dozens of rows for one editing session, and a timeline +#: that is 40 lines of "edited description" buries the one change that +#: mattered. +COALESCED_FIELDS: frozenset[str] = frozenset({"description"}) + + +async def _label_of(db: Any, table: str, column: str, row_id: Any) -> Any: + """One FK target's display label — ``None`` for a cleared or deleted end.""" + if row_id is None: + return None + row = await load_row(db, table, str(row_id)) + return None if row is None else wire(getattr(row, column, None)) + + +async def resolve_fk_labels(db: Any, changes: list[dict]) -> list[dict]: + """Rewrite FK-valued diff entries to the five-key shape the timeline owes. + + ``{field, old, new}`` stays for plain values; an entry whose field is in + :data:`FK_LABEL_FIELDS` becomes ``{field, old_id, new_id, old_label, + new_label}`` with the labels read NOW, while the referenced rows still + exist. Every ``field_change`` write goes through + :func:`record_field_change` and therefore through here — the structural + test in ``test_projects_hardening`` is what keeps that sentence true. + """ + out: list[dict] = [] + for change in changes: + source = FK_LABEL_FIELDS.get(str(change.get("field") or "")) + if source is None: + out.append(change) + continue + table, column = source + old_id, new_id = change.get("old"), change.get("new") + out.append({ + "field": change.get("field"), + "old_id": old_id, + "new_id": new_id, + "old_label": await _label_of(db, table, column, old_id), + "new_label": await _label_of(db, table, column, new_id), + }) + return out + + +async def _coalescible_prior( + db: Any, *, created_by: str, changes: list[dict], + task_id: str | None, project_id: str | None, +) -> Any | None: + """The activity row this edit folds into, or ``None`` to append normally. + + Consecutive means exactly what WS-27w says: the IMMEDIATELY previous + activity row for this task (or project) is the same actor editing the same + lone field. Anything in between — a comment, an assignment, another + field's change, somebody else's edit — breaks the run, because the + timeline must still show that those happened in that order. + + A prior row whose meta carries anything beside ``changes`` (a revert's + ``reverted_activity_id``) is a statement of its own and is never coalesced + into or over. + """ + if len(changes) != 1 or str(changes[0].get("field") or "") not in COALESCED_FIELDS: + return None + if task_id is not None: + clause, target = "task_id = CAST(:target AS uuid)", task_id + elif project_id is not None: + clause, target = "project_id = CAST(:target AS uuid)", project_id + else: + return None + row = (await db.execute( + text( + f"SELECT * FROM pm_activities WHERE {clause} " + "AND deleted_at IS NULL " + "ORDER BY created_at DESC, id DESC LIMIT 1" + ), + {"target": str(target)}, + )).fetchone() + if row is None or getattr(row, "type", None) != "field_change": + return None + prior_actor = str(getattr(row, "created_by", "") or "").strip().lower() + if prior_actor != (created_by or "").strip().lower(): + return None + meta = from_jsonb(getattr(row, "meta", None)) + if not isinstance(meta, dict) or set(meta) != {"changes"}: + return None + prior = [c for c in (meta.get("changes") or []) if isinstance(c, dict)] + if len(prior) != 1 or str(prior[0].get("field") or "") != str(changes[0]["field"]): + return None + return row + + +async def record_field_change( + db: Any, + *, + created_by: str, + changes: list[dict], + task_id: str | None = None, + project_id: str | None = None, + extra_meta: dict[str, Any] | None = None, +) -> Any: + """Write one ``field_change`` activity — THE one door (WS-27w items 2+3). + + Every caller that records a field change comes through here, and the + structural test walks the package's ``record_activity`` call sites to + refuse any that do not. That single-door shape is what the two rules hang + off: + + 1. **labels at write time** — :func:`resolve_fk_labels` runs on every + write, so an FK-valued change can never reach the table as a bare pair + of UUIDs; + 2. **description coalescing** — a same-actor consecutive edit of a + :data:`COALESCED_FIELDS` field UPDATES the prior row (its span of + ``old`` → latest ``new``, and its timestamp) instead of appending. + + ``extra_meta`` rides beside ``changes`` in the meta object; a write that + carries any (a revert naming ``reverted_activity_id``) is always appended, + never coalesced — it is an assertion about history, not an edit in a run. + """ + resolved = await resolve_fk_labels(db, changes) + if extra_meta is None: + prior = await _coalescible_prior( + db, created_by=created_by, changes=resolved, + task_id=task_id, project_id=project_id, + ) + if prior is not None: + prior_meta = from_jsonb(getattr(prior, "meta", None)) or {} + first = (prior_meta.get("changes") or [{}])[0] + merged = {**resolved[0], "old": first.get("old")} + # `created_at` is bumped too, not only `updated_at`: the coalesced + # row now records the LATEST edit, the timeline orders on + # `created_at`, and the row is already the task's newest — so the + # bump keeps it truthful without reordering anything. + return await update_row(db, "pm_activities", str(prior.id), { + "meta": {"changes": [merged]}, + "created_at": now(), + }) + meta: dict[str, Any] = {"changes": resolved} + if extra_meta: + meta.update(extra_meta) + return await record_activity( + db, activity_type="field_change", created_by=created_by, + task_id=task_id, project_id=project_id, meta=meta, + ) + + # ── Events (§6.3) ─────────────────────────────────────────────────────────── async def emit(event_type: str, payload: dict[str, Any]) -> None: diff --git a/apps/services/gateway/gateway/routes/projects/search.py b/apps/services/gateway/gateway/routes/projects/search.py index 031528492..ed4430459 100644 --- a/apps/services/gateway/gateway/routes/projects/search.py +++ b/apps/services/gateway/gateway/routes/projects/search.py @@ -33,6 +33,13 @@ **LIKE metacharacters are escaped** — see `like_escape`. That was a live defect on the list endpoint too, not a new-code precaution. +**Pickers pass `?exclude_relatives_of=`** (WS-27w item 5). A parent/ +relation/duplicate picker anchored on a task must not offer the task itself, +its ancestors, its descendants, or anything already related in either +direction — every one of those is a choice the write path will 422, and a +picker that offers refusals teaches people the picker is broken. The write-time +guards stay; this is their read-side twin. + **Comments are deliberately not searched.** They are the largest text in the system and the least likely to be what somebody is looking for by name; a comment hit would also have to be rendered as its task, which makes ranking @@ -41,10 +48,14 @@ from __future__ import annotations +from typing import Any + from acb_auth import UserContext, get_current_user from fastapi import Depends from gateway.routes.projects.core import ( + MAX_DEPTH, _get_db, + load_visible_task, resolve_visibility, router, task_visibility_clause, @@ -111,7 +122,7 @@ def task_number(raw: str) -> int | None: JOIN pm_projects p ON p.id = t.project_id JOIN pm_task_statuses s ON s.id = t.status_id WHERE {visible} - AND t.archived_at IS NULL + AND t.archived_at IS NULL{exclude} AND (t.title ILIKE :term OR t.description ILIKE :term OR (CAST(:number AS bigint) IS NOT NULL @@ -121,10 +132,83 @@ def task_number(raw: str) -> int | None: """ +#: The clause `exclude_relatives_of` adds (WS-27w item 5, P-7). A fragment +#: rather than a second statement, so everything else about search — ranking, +#: visibility, the archived rule, the cap — is exactly the same query. +_EXCLUDE_SQL = "\n AND NOT (t.id = ANY(CAST(:excluded AS uuid[])))" + + +async def relative_task_ids(db: Any, task_id: str) -> set[str]: + """Every task a picker anchored on ``task_id`` must not offer. + + Four classes, matching the write-time guards one for one: the task itself, + its ancestors, its descendants (all three are what `assert_no_task_cycle` + would 422), and anything already related in EITHER direction (what the + link upsert would merely re-assert). The write-time guards stay untouched + — this is their read-side twin, so a relation/duplicate/parent picker + cannot offer a choice the write path will refuse. + + Walks are bounded by :data:`MAX_DEPTH` for the cycle checks' reason: an + unbounded walk over data somebody can create is a denial-of-service + surface, and a real hierarchy never approaches the bound. + """ + anchor = str(task_id) + out: set[str] = {anchor} + + # Ancestors — one hop at a time, the same walk `assert_no_task_cycle` does. + current = anchor + for _ in range(MAX_DEPTH): + row = (await db.execute( + text("SELECT parent_task_id FROM pm_tasks WHERE id = CAST(:id AS uuid)"), + {"id": current}, + )).fetchone() + parent = getattr(row, "parent_task_id", None) if row is not None else None + if parent is None or str(parent) in out: + break + out.add(str(parent)) + current = str(parent) + + # Descendants — frontier by frontier, the `assert_no_block_cycle` shape. + frontier = {anchor} + for _ in range(MAX_DEPTH): + if not frontier: + break + rows = (await db.execute( + text( + "SELECT id FROM pm_tasks " + "WHERE parent_task_id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": sorted(frontier)}, + )).fetchall() + frontier = {str(r.id) for r in rows} - out + out |= frontier + + # Already related, both directions — two single-column reads rather than + # one OR, because a link is readable from either side and the picker must + # exclude it from either side too. + for column, other in ( + ("source_task_id", "target_task_id"), + ("target_task_id", "source_task_id"), + ): + rows = (await db.execute( + text( + f"SELECT * FROM pm_task_links " + f"WHERE {column} = CAST(:tid AS uuid)" + ), + {"tid": anchor}, + )).fetchall() + out |= { + str(getattr(r, other)) for r in rows + if getattr(r, other, None) is not None + } + return out + + @router.get("/search") async def search_tasks( q: str = "", limit: int = MAX_HITS, + exclude_relatives_of: str | None = None, user: UserContext = Depends(get_current_user), ) -> dict: """Ranked task hits across every project the caller can see. @@ -143,10 +227,24 @@ async def search_tasks( db = await _get_db() try: vis = await resolve_visibility(db, user) + exclude_sql = "" + exclude_params: dict[str, Any] = {} + if exclude_relatives_of: + # The anchor must be visible to the caller — an unreadable id is a + # 404 (R5), exactly as it is on the list endpoint's project filter, + # so the parameter cannot be used to probe for existence. + await load_visible_task(db, vis, exclude_relatives_of) + exclude_sql = _EXCLUDE_SQL + exclude_params["excluded"] = sorted( + await relative_task_ids(db, exclude_relatives_of) + ) rows = (await db.execute( - text(_SEARCH_SQL.format(visible=task_visibility_clause(vis))), + text(_SEARCH_SQL.format( + visible=task_visibility_clause(vis), exclude=exclude_sql, + )), { **vis.params, + **exclude_params, "term": f"%{escaped}%", "prefix": f"{escaped}%", "number": task_number(term), @@ -183,4 +281,4 @@ async def search_tasks( await db.close() -__all__ = ["MAX_HITS", "MIN_QUERY", "task_number"] +__all__ = ["MAX_HITS", "MIN_QUERY", "relative_task_ids", "task_number"] diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index 00497d72d..2108a1caa 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -24,6 +24,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.projects.core import ( + CLOSING_CATEGORIES, DIRECTIONS, TASK_SORTS, TASK_SOURCES, @@ -46,7 +47,10 @@ load_visible_project, load_visible_task, next_task_number, + now, record_activity, + record_field_change, + require_row, require_status_in_project, resolve_visibility, root_project_id, @@ -193,10 +197,14 @@ async def list_tasks( total = (await db.execute( text(f"SELECT count(*) FROM pm_tasks t{where}"), params, )).scalar() or 0 + # `column` is an allowlisted template with `{dir}` slots; the direction + # is one of OUR two words, never caller text. Every entry ends with the + # `(created_at, id)` tiebreaker (core.SORT_TIEBREAK), so the order is + # total and a tie cannot straddle a page boundary. rows = (await db.execute( text( f"SELECT t.* FROM pm_tasks t{where} " - f"ORDER BY {column} {order} NULLS LAST, t.id {order} " + f"ORDER BY {column.format(dir=order)} " f"LIMIT :limit OFFSET :offset" ), {**params, "limit": page.limit, "offset": page.offset}, @@ -365,9 +373,11 @@ async def patch_task( for key, moved in sorted(custom_changes.items()) ) if changes: - await record_activity( - db, activity_type="field_change", created_by=actor(user), - task_id=task_id, meta={"changes": changes}, + # Through the ONE field_change door (WS-27w): FK ids gain their + # labels at write time, and a same-actor consecutive + # description edit coalesces into the prior row. + await record_field_change( + db, created_by=actor(user), task_id=task_id, changes=changes, ) moved = None if new_status is not None and str(new_status) != str(before.status_id): @@ -505,6 +515,91 @@ async def delete_task( ) +# ── Archive (WS-27w item 1) ───────────────────────────────────────────────── + +@router.post("/tasks/{task_id}/archive") +async def archive_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Archive one task — allowed only once it is CLOSED. + + An archived task exits every default list, board, calendar and search + surface at once, so archiving an open task is a trap, not a feature (P-3): + the work disappears while still owed, and nobody gardening a board can see + where it went. The guard is written on the status CATEGORY, and as "not in + (done, cancelled)" rather than as a list of open categories — a category + added later (WS-27u's `triage`) is refused by default instead of becoming + silently archivable. The refusal names the actual category, because "cannot + archive" without the why sends people hunting through lanes. + + WS-27z's sweeper depends on this guard shipping first. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + status = await require_row( + db, "pm_task_statuses", str(task.status_id), "Status", + ) + category = str(getattr(status, "category", "") or "") + if category not in CLOSING_CATEGORIES: + raise HTTPException( + status_code=422, + detail=( + f"Cannot archive an open task: its status category is " + f"'{category}'. Move it to a done or cancelled status " + f"first." + ), + ) + if getattr(task, "archived_at", None) is not None: + # Already archived — idempotent, the double-click answer. + return row_to_dict(task, TaskModel) + row = await update_row(db, "pm_tasks", task_id, {"archived_at": now()}) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Task archived", + ) + await db.commit() + result = row_to_dict(row, TaskModel) + finally: + await db.close() + + await emit("pm.task.archived", {"task_id": task_id}) + return result + + +@router.post("/tasks/{task_id}/unarchive") +async def unarchive_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Bring an archived task back onto its board. + + No category guard in this direction — restoring puts work back where + people can see it, which is never the trap the archive guard exists to + prevent. Without this endpoint an archive would be one-way: nothing else + writes ``archived_at``, and the PATCH surface deliberately does not accept + it. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + if getattr(task, "archived_at", None) is None: + return row_to_dict(task, TaskModel) + row = await update_row(db, "pm_tasks", task_id, {"archived_at": None}) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Task restored from the archive", + ) + await db.commit() + result = row_to_dict(row, TaskModel) + finally: + await db.close() + + await emit("pm.task.unarchived", {"task_id": task_id}) + return result + + # ── Assignees ─────────────────────────────────────────────────────────────── @router.put("/tasks/{task_id}/assignees") diff --git a/apps/services/gateway/gateway/routes/projects/tree.py b/apps/services/gateway/gateway/routes/projects/tree.py index b6aa3d362..bc404c155 100644 --- a/apps/services/gateway/gateway/routes/projects/tree.py +++ b/apps/services/gateway/gateway/routes/projects/tree.py @@ -42,6 +42,7 @@ insert_row, load_visible_project, record_activity, + record_field_change, require_organization, resolve_visibility, root_project_id, @@ -284,9 +285,13 @@ async def patch_node( after = await update_row(db, "pm_projects", project_id, values) changes = diff_changes(before, after, _TRACKED_PROJECT_FIELDS) if changes: - await record_activity( - db, activity_type="field_change", created_by=actor(user), - project_id=project_id, meta={"changes": changes}, + # The ONE field_change door (WS-27w) — none of the tracked project + # fields is FK-valued today, but the door is what keeps that claim + # checked rather than remembered, and a project-description edit + # session coalesces like a task's. + await record_field_change( + db, created_by=actor(user), project_id=project_id, + changes=changes, ) await db.commit() result = row_to_dict(after, ProjectModel) diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 3545a9a1a..08d8832e9 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -74,6 +74,11 @@ _LOWER_EQ = re.compile(r"lower\((?:\w+\.)?(\w+)\)\s*=\s*:(\w+)", re.I) #: `` = :param`` — never inside a lower() or a CAST. _PLAIN_EQ = re.compile(r"(? = ANY(CAST(:param AS uuid[]))`` — a bounded id-set membership test +#: (WS-27w's relatives walk and picker exclusion). +_ANY_UUID = re.compile( + r"(?:\w+\.)?(\w+)\s*=\s*ANY\(CAST\(:(\w+)\s+AS\s+uuid\[\]\)\)", re.I +) #: `` = 'literal'`` _LITERAL_EQ = re.compile(r"\b(?:\w+\.)?(\w+)\s*=\s*'([^']*)'") #: `` IS [NOT] NULL`` @@ -657,9 +662,18 @@ def _search_hits(self, statement: str, args: dict) -> list[Any]: tenanted = bool(_ROW_TENANT.search(_CLOSURE_BODY.sub("", statement))) org = str(args.get("vis_org")) tenant_only = bool(_TENANT_PROJECTS.search(statement)) + # WS-27w's picker exclusion — applied ONLY when the statement carries + # the clause, so a search that drops it stops excluding here too and + # the four-class test goes red. + excluded = ( + {str(i) for i in (args.get("excluded") or [])} + if "NOT (t.id = ANY" in statement else set() + ) found: list[Any] = [] for task in self.rows("pm_tasks"): + if str(task.get("id")) in excluded: + continue if tenanted and str(task.get("organization_id")) != org: continue if tenant_only and str(task.get("project_id")) not in self.tenant_project_ids(org): @@ -1184,6 +1198,10 @@ def _apply_columns( seen = True want = str(args.get(param) or "").lower() rows = [r for r in rows if str(r.get(column) or "").lower() == want] + for column, param in _ANY_UUID.findall(top): + seen = True + wanted_ids = {str(v) for v in (args.get(param) or [])} + rows = [r for r in rows if str(r.get(column)) in wanted_ids] for column, param in _PLAIN_EQ.findall(top): seen = True rows = [r for r in rows if r.get(column) == args.get(param)] @@ -1232,6 +1250,32 @@ def _apply_columns( return rows, seen def _ordered(self, statement: str, rows: list[dict]) -> list[dict]: + # WS-27w's semantic status sort: category rank, then lane position, + # then the `(created_at, id)` tiebreaker. The RANK ORDER is read off + # the statement's own ARRAY literal rather than assumed, so a route + # that reordered — or alphabetised — the vocabulary changes this + # mirror's answer instead of being invisible to it. + if "array_position" in statement and "s.category" in statement: + literal = re.search(r"ARRAY\[([^\]]*)\]", statement) + rank = [ + v.strip().strip("'") + for v in (literal.group(1).split(",") if literal else []) + ] + reverse = bool(re.search(r"t\.id\s+DESC", statement, re.I)) + uses_position = "s.position" in statement + statuses = {str(s["id"]): s for s in self.rows("pm_task_statuses")} + + def status_key(row: dict) -> tuple: + status = statuses.get(str(row.get("status_id")), {}) + category = str(status.get("category") or "") + return ( + rank.index(category) if category in rank else len(rank), + _sortable(status.get("position")) if uses_position else 0, + _sortable(row.get("created_at")), + str(row.get("id")), + ) + + return sorted(rows, key=status_key, reverse=reverse) order = _ORDER_RE.search(statement) if order is None: return rows diff --git a/tests/unit/test_projects_hardening.py b/tests/unit/test_projects_hardening.py new file mode 100644 index 000000000..f2a65c28f --- /dev/null +++ b/tests/unit/test_projects_hardening.py @@ -0,0 +1,523 @@ +"""WS-27w — read-path and history hardening. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1 (WS-27w). +Rationale: ``specs/plane_pm_research_2026-08.md`` §3 (P-3, P-5, P-6, P-7, P-21). + +Six small corrections, and the two claims here that outlive this ticket are +STRUCTURAL, deliberately DB-free: + +* **every ``field_change`` goes through the one labelled door.** The walker + below reads the package's ``record_activity`` call sites out of the AST, so + a future call site written without label resolution fails this suite before + it ships a UUID into somebody's history. +* **every ``TASK_SORTS`` entry ends with the ``(created_at, id)`` tiebreaker.** + A sort without a total order lets a tie straddle a page boundary, and the + bug reads as "pagination sometimes loses a task" — months of mystery for one + missing clause. + +Everything behavioural runs hermetically against ``_projects_fakes`` — no +Postgres, no TestClient, ``_get_db`` monkeypatched per SUT module. +""" + +from __future__ import annotations + +import ast +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import automation as pm_automation +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import search as pm_search +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import tree as pm_tree + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + page, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_tree, pm_tasks, pm_activities, pm_search) +USER = projects_user() +COLLEAGUE = member_user("colleague@fracktal.in") + +PACKAGE = Path("apps/services/gateway/gateway/routes/projects") + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + return fake + + +@pytest.fixture +def events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict]]: + return silence_events(monkeypatch, MODULES) + + +def _workspace(db: FakeProjectsDB) -> tuple: + """One project with an open and a closed lane.""" + project = db.seed_project(name="Ops") + todo = db.seed_status(project.id, name="To do", category="todo", + is_default=True, position=10) + done = db.seed_status(project.id, name="Done", category="done", + is_default=False, position=40) + return project, todo, done + + +# ── 1 · the archive guard ─────────────────────────────────────────────────── + +async def test_archiving_an_open_task_is_422_naming_the_category( + db: FakeProjectsDB, events: list, +) -> None: + """⚠️ The trap this guard closes: an archived open task silently exits + every default list while the work is still owed (P-3). The refusal names + the ACTUAL category so the person knows which lane to leave first.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Still open") + + with pytest.raises(HTTPException) as exc: + await pm_tasks.archive_task(str(task.id), user=USER) + + assert exc.value.status_code == 422 + assert "'todo'" in exc.value.detail + # And nothing was written: the task is untouched. + assert next( + t for t in db.rows("pm_tasks") if str(t["id"]) == str(task.id) + )["archived_at"] is None + + +@pytest.mark.parametrize("category,name", [("done", "Done"), ("cancelled", "Won't do")]) +async def test_a_closed_task_archives_and_earns_a_timeline_row( + db: FakeProjectsDB, events: list, category: str, name: str, +) -> None: + """Both closing categories archive — `cancelled` counts as closed, the + same rule `completed_at` follows.""" + project = db.seed_project(name="Ops") + closed = db.seed_status(project.id, name=name, category=category) + task = db.seed_task(project.id, closed.id, title="Finished") + + result = await pm_tasks.archive_task(str(task.id), user=USER) + + assert result["archived_at"] is not None + assert any( + a["body"] == "Task archived" for a in db.activities("system") + ) + assert ("pm.task.archived", {"task_id": str(task.id)}) in events + + +async def test_the_guard_is_category_not_in_closing_never_a_list_of_open_ones() -> None: + """WS-27u is concurrently adding a `triage` category. Written as `not in + (done, cancelled)`, a new category is refused by default; written as a + list of open categories, it would be silently archivable. Pinned on the + source so the mutant that flips the predicate's direction goes red.""" + source = (PACKAGE / "tasks.py").read_text(encoding="utf-8") + assert "if category not in CLOSING_CATEGORIES:" in source + assert frozenset({"done", "cancelled"}) == pm_core.CLOSING_CATEGORIES + + +async def test_unarchive_restores_and_needs_no_category( + db: FakeProjectsDB, events: list, +) -> None: + """The way back has no guard: restoring puts work where people can see it, + which is never the trap the archive guard exists to prevent.""" + project, todo, _ = _workspace(db) + task = db.seed_task( + project.id, todo.id, title="Buried", archived_at=pm_core.now(), + ) + + result = await pm_tasks.unarchive_task(str(task.id), user=USER) + + assert result["archived_at"] is None + + +async def test_archiving_twice_is_idempotent_not_a_second_timeline_row( + db: FakeProjectsDB, events: list, +) -> None: + project = db.seed_project(name="Ops") + done = db.seed_status(project.id, name="Done", category="done") + task = db.seed_task(project.id, done.id, title="Finished") + + await pm_tasks.archive_task(str(task.id), user=USER) + await pm_tasks.archive_task(str(task.id), user=USER) + + assert len([a for a in db.activities("system") + if a["body"] == "Task archived"]) == 1 + + +# ── 2 · activity meta — the labelled door, structurally ───────────────────── + +def _field_change_call_sites() -> list[tuple[str, str, int]]: + """Every ``record_activity(activity_type="field_change", …)`` call in the + routes package, as ``(file, enclosing function, line)``. + + AST, not regex, because the thing being guarded is a CALL — a docstring or + a comment mentioning the words must not satisfy (or trip) the rule. + """ + sites: list[tuple[str, str, int]] = [] + for path in sorted(PACKAGE.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + # Parent links, so a call can name its innermost enclosing function. + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + child._parent = parent # type: ignore[attr-defined] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = getattr(func, "id", None) or getattr(func, "attr", None) + if name != "record_activity": + continue + kind = next( + (k.value.value for k in node.keywords + if k.arg == "activity_type" and isinstance(k.value, ast.Constant)), + None, + ) + if kind != "field_change": + continue + enclosing = node + while enclosing is not None and not isinstance( + enclosing, ast.FunctionDef | ast.AsyncFunctionDef, + ): + enclosing = getattr(enclosing, "_parent", None) + sites.append(( + path.name, + enclosing.name if enclosing is not None else "", + node.lineno, + )) + return sites + + +def test_every_field_change_call_site_goes_through_the_label_resolver() -> None: + """⚠️ THE structural rule (P-5). ``record_field_change`` is the one + function allowed to hand ``record_activity`` a ``field_change`` — it is + where FK ids gain their labels and where description edits coalesce. A + future call site that writes the type directly has, by construction, + skipped both, and this walker fails it by name and line.""" + sites = _field_change_call_sites() + # Self-check first: a walker that finds nothing is a walker that proves + # nothing — the one legitimate site must be visible to it. + assert sites, "the walker found no field_change call sites at all" + offenders = [ + f"{file}:{line} (in {function})" + for file, function, line in sites + if (file, function) != ("core.py", "record_field_change") + ] + assert not offenders, ( + "field_change activities must be written through " + "core.record_field_change, which resolves FK labels at write time; " + f"found direct call sites: {offenders}" + ) + + +def test_the_one_door_resolves_labels_before_it_writes() -> None: + """And the door itself cannot quietly lose the resolver: the call to + ``resolve_fk_labels`` precedes the write in its source.""" + import inspect + + source = inspect.getsource(pm_core.record_field_change) + assert source.index("resolve_fk_labels(") < source.index("record_activity(") + + +def test_every_tracked_fk_field_has_a_label_source() -> None: + """A tracked field named ``*_id`` is an FK by this package's own naming + convention; one without a row in ``FK_LABEL_FIELDS`` would pass through + the resolver untouched and store bare UUIDs — the exact defect item 2 + exists to close.""" + tracked = { + *pm_tasks._TRACKED_TASK_FIELDS, + *pm_automation.PATCHABLE_FIELDS, + *pm_tree._TRACKED_PROJECT_FIELDS, + } + missing = sorted( + name for name in tracked + if name.endswith("_id") and name not in pm_core.FK_LABEL_FIELDS + ) + assert not missing, f"FK-valued tracked fields without a label source: {missing}" + + +async def test_a_type_change_carries_ids_and_labels_resolved_at_write_time( + db: FakeProjectsDB, events: list, +) -> None: + """The behavioural half: patching ``type_id`` stores the five-key shape, + with the label read NOW — history renders without a join against a row + that may later be renamed or deleted.""" + project, todo, _ = _workspace(db) + bug = db.seed("pm_task_types", project_id=project.id, name="Bug") + task = db.seed_task(project.id, todo.id, title="Typed") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(type_id=str(bug.id)), user=USER, + ) + + [entry] = db.activities("field_change") + [change] = entry["meta"]["changes"] + assert change == { + "field": "type_id", + "old_id": None, + "new_id": str(bug.id), + "old_label": None, + "new_label": "Bug", + } + + +# ── 3 · description-edit coalescing ───────────────────────────────────────── + +async def test_same_actor_consecutive_description_edits_are_one_row( + db: FakeProjectsDB, events: list, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The autosave case (P-5): two edits in a row, one timeline row, spanning + first ``old`` to latest ``new`` — with the row's timestamp moved to the + latest edit, because the row now records that one.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + later = datetime(2026, 8, 9, 12, 0, tzinfo=UTC) + monkeypatch.setattr(pm_core, "now", lambda: later) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=USER, + ) + + [entry] = db.activities("field_change") + assert entry["meta"]["changes"] == [ + {"field": "description", "old": "v1", "new": "v3"}, + ] + assert entry["created_at"] == later + + +async def test_a_different_actor_breaks_the_run_two_rows( + db: FakeProjectsDB, events: list, +) -> None: + """Same field, different hands: coalescing across actors would attribute + one person's words to another.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=COLLEAGUE, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_an_intervening_activity_breaks_the_run( + db: FakeProjectsDB, events: list, +) -> None: + """Consecutive means IMMEDIATELY previous. A comment in between is part of + the story, and folding the second edit backwards over it would reorder + what actually happened.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_core.record_activity( + db, activity_type="comment", created_by="owner@fracktal.in", + task_id=str(task.id), body="looks good", + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=USER, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_a_multi_field_edit_never_coalesces( + db: FakeProjectsDB, events: list, +) -> None: + """Description + importance in one PATCH is not an autosave run; folding + it into a prior description row would hide the importance change.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3", importance=4), user=USER, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_editing_a_comment_twice_updates_the_row_never_appends( + db: FakeProjectsDB, events: list, +) -> None: + """The comment-body half: the comment IS the activity row, so an edit — + and an edit of the edit — rewrites it in place and bumps its timestamp. + Two edits, one comment row, no side rows.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc") + comment = db.seed( + "pm_activities", task_id=str(task.id), type="comment", + body="first", created_by="owner@fracktal.in", + ) + before = next( + a for a in db.rows("pm_activities") if str(a["id"]) == str(comment.id) + )["updated_at"] + + await pm_activities.edit_comment( + str(comment.id), pm_activities.CommentIn(body="second"), user=USER, + ) + await pm_activities.edit_comment( + str(comment.id), pm_activities.CommentIn(body="third"), user=USER, + ) + + rows = [a for a in db.rows("pm_activities") if a["type"] == "comment"] + assert len(rows) == 1 + assert rows[0]["body"] == "third" + assert rows[0]["updated_at"] > before + assert len(db.rows("pm_activities")) == 1 # nothing appended beside it + + +# ── 4 · semantic sorts, and the tiebreaker rule ───────────────────────────── + +def test_every_task_sort_ends_with_the_created_at_id_tiebreaker() -> None: + """⚠️ Structural (P-6). Without a total order, two tasks tying on the sort + key can straddle a page boundary — appearing on both pages or neither + depending on the plan — and the report is "pagination loses tasks".""" + for key, value in pm_core.TASK_SORTS.items(): + assert value.endswith(pm_core.SORT_TIEBREAK), ( + f"TASK_SORTS[{key!r}] must end with the deterministic " + f"{pm_core.SORT_TIEBREAK!r} tiebreaker; got: {value!r}" + ) + + +def test_the_status_sort_is_semantic_never_alphabetical() -> None: + """Category rank then lane position — never the status NAME, which would + put "Backlog" before "Done" only by accident of language and reshuffle the + whole list on every lane rename.""" + status = pm_core.TASK_SORTS["status"] + assert "array_position" in status and "s.category" in status + assert "s.position" in status + assert "s.name" not in status + # And the rank IS the vocabulary, in lifecycle order — not a second list. + for category in pm_core.STATUS_CATEGORIES: + assert f"'{category}'" in status + + +async def test_sorting_by_status_orders_by_category_rank_then_position( + db: FakeProjectsDB, events: list, +) -> None: + """The crafted fixture: lane NAMES chosen so alphabetical order is exactly + wrong — 'Aaa Done' would sort first by name and must sort last by rank — + and two same-category lanes to prove position breaks the tie.""" + project = db.seed_project(name="Ops") + done = db.seed_status(project.id, name="Aaa Done", category="done", + position=40, is_default=False) + doing_b = db.seed_status(project.id, name="Bbb Doing", category="in_progress", + position=30, is_default=False) + doing_z = db.seed_status(project.id, name="Zzz Doing first", category="in_progress", + position=20, is_default=False) + todo = db.seed_status(project.id, name="Yyy To do", category="todo", + position=10, is_default=True) + + db.seed_task(project.id, done.id, title="finished") + db.seed_task(project.id, doing_b.id, title="doing, later lane") + db.seed_task(project.id, doing_z.id, title="doing, earlier lane") + db.seed_task(project.id, todo.id, title="queued") + + result = await pm_tasks.list_tasks( + user=USER, sort="status", direction="asc", page=page(), + ) + + assert [row["title"] for row in result.rows] == [ + "queued", # todo ranks before in_progress + "doing, earlier lane", # same category: position 20 before 30 + "doing, later lane", + "finished", # done ranks last, despite the 'Aaa' name + ] + + +# ── 5 · picker exclusions on search ───────────────────────────────────────── + +def _family(db: FakeProjectsDB) -> dict: + """Anchor with an ancestor, descendants, relations both ways, a bystander.""" + project, todo, _ = _workspace(db) + parent = db.seed_task(project.id, todo.id, title="pick parent") + anchor = db.seed_task(project.id, todo.id, title="pick anchor", + parent_task_id=parent.id) + child = db.seed_task(project.id, todo.id, title="pick child", + parent_task_id=anchor.id) + db.seed_task(project.id, todo.id, title="pick grandchild", + parent_task_id=child.id) + outgoing = db.seed_task(project.id, todo.id, title="pick outgoing relation") + incoming = db.seed_task(project.id, todo.id, title="pick incoming relation") + db.seed("pm_task_links", source_task_id=anchor.id, + target_task_id=outgoing.id, link_type="relates_to") + db.seed("pm_task_links", source_task_id=incoming.id, + target_task_id=anchor.id, link_type="blocks") + bystander = db.seed_task(project.id, todo.id, title="pick bystander") + return {"anchor": anchor, "bystander": bystander} + + +async def test_exclude_relatives_of_hides_all_four_classes( + db: FakeProjectsDB, events: list, +) -> None: + """Self, ancestors, descendants, related-in-either-direction: everything + the write path would 422 (or merely re-assert), so the picker cannot offer + it. The bystander is the control — exclusion must not become an empty + answer.""" + family = _family(db) + + result = await pm_search.search_tasks( + q="pick", exclude_relatives_of=str(family["anchor"].id), user=USER, + ) + + assert [r["title"] for r in result["rows"]] == ["pick bystander"] + + +async def test_without_the_parameter_search_is_unchanged( + db: FakeProjectsDB, events: list, +) -> None: + family = _family(db) + result = await pm_search.search_tasks(q="pick", user=USER) + titles = {r["title"] for r in result["rows"]} + assert family["anchor"].title in titles + assert len(titles) == 7 + + +async def test_an_unreadable_anchor_is_404_not_an_oracle( + db: FakeProjectsDB, events: list, +) -> None: + """R5: filtering by a task you cannot see must answer exactly what "no + such task" answers, or the parameter probes for existence.""" + secret = db.seed_project(name="Secret", subject=None) + status = db.seed_status(secret.id, name="To do", category="todo") + hidden = db.seed_task(secret.id, status.id, title="hidden anchor") + db.seed_project(name="Mine", subject="colleague@fracktal.in") + + with pytest.raises(HTTPException) as exc: + await pm_search.search_tasks( + q="anything", exclude_relatives_of=str(hidden.id), user=COLLEAGUE, + ) + assert exc.value.status_code == 404 + + +async def test_the_write_time_guards_stay(db: FakeProjectsDB) -> None: + """Item 5's last clause: the read-side exclusion is a twin, not a + replacement — self-linking still 422s at the write.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Self") + + with pytest.raises(HTTPException) as exc: + await pm_tasks.create_link( + str(task.id), + pm_tasks.LinkIn(target_task_id=str(task.id)), + user=USER, + ) + assert exc.value.status_code == 422 diff --git a/tests/unit/test_projects_routes.py b/tests/unit/test_projects_routes.py index 7fa932280..9168b9cf1 100644 --- a/tests/unit/test_projects_routes.py +++ b/tests/unit/test_projects_routes.py @@ -81,6 +81,8 @@ def test_every_feature_module_is_actually_mounted() -> None: "/projects/nodes/{project_id}/grants", "/projects/tasks", "/projects/tasks/{task_id}", + "/projects/tasks/{task_id}/archive", + "/projects/tasks/{task_id}/unarchive", "/projects/tasks/{task_id}/assignees", "/projects/tasks/{task_id}/timeline", "/projects/tasks/{task_id}/comments", @@ -389,13 +391,15 @@ async def test_every_allowlisted_sort_key_reaches_the_order_by( db: FakeProjectsDB, key: str, ) -> None: """The allowlist's values are the only identifiers interpolated into the - ORDER BY, so this also pins that none of them is caller text.""" + ORDER BY, so this also pins that none of them is caller text. The values + are `{dir}` templates since WS-27w; the default direction is `desc`.""" _project_with_statuses(db) await pm_tasks.list_tasks(user=USER, sort=key, page=page()) ordered = [s for s in db.statements if "ORDER BY" in s] - assert any(pm_core.TASK_SORTS[key] in s for s in ordered) + expected = " ".join(pm_core.TASK_SORTS[key].format(dir="DESC").split()) + assert any(expected in s for s in ordered) async def test_archived_tasks_are_hidden_by_default(db: FakeProjectsDB) -> None: diff --git a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx index d35ca7acd..001eff1f0 100644 --- a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx @@ -23,7 +23,7 @@ import { useMemo, useState } from "react"; import type { TaskRow } from "../lib/api"; import { buildColumnDropUpdate, planDrop, sortForView } from "../lib/board"; -import { cardChips } from "../lib/card"; +import { cardChips, taskRef } from "../lib/card"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; interface Props { @@ -139,7 +139,7 @@ export function TaskBoard({ so a task looks like the same kind of thing in both. */} - {task.task_number ? `#${task.task_number}` : ""} + {taskRef(task)} diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index d479e5fce..625648170 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -17,7 +17,7 @@ import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; import type { StatusRow, TaskRow } from "../lib/api"; import { sortForView } from "../lib/board"; -import { cardChips } from "../lib/card"; +import { cardChips, taskRef } from "../lib/card"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; interface Props { @@ -123,7 +123,7 @@ export function TaskList({ ) : null} - {task.task_number ?? "—"} + {taskRef(task) ?? "—"} (null); + // WS-27w item 6 — the copy-deep-link affordance's "it worked" flash. + const [copied, setCopied] = useState(false); const [files, setFiles] = useState([]); const assignees = task.assignees ?? []; // Agents are excluded: an agent cannot receive a notification (migration @@ -277,6 +281,26 @@ export function TaskPanel({ } } + /** + * Copy a URL that reopens this panel — `/projects?task=`, the deep-link + * shape the page already reads (WS-28b). The icon flips to a check briefly, + * because a copy with no acknowledgement gets clicked three times. + */ + async function copyDeepLink() { + try { + await navigator.clipboard.writeText( + taskDeepLink(task, window.location.origin), + ); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can be unavailable (permissions, insecure context). The + // link is not lost — opening the task by deep link shows it in the + // address bar — so this fails quietly rather than turning a copy + // button into an error banner. + } + } + /** Insert `@address` at the caret, so nobody has to type the token by hand. */ function mention(who: string) { const box = commentBox.current; @@ -295,9 +319,17 @@ export function TaskPanel({
); } diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index d479e5fce..f2849b435 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -12,23 +12,40 @@ * the board would have drawn as columns, as headed sections. Switching between * board and list must not change which tasks are on screen or how they are * gathered, which is why both take the output of one `groupTasks` call. + * + * WS-27y: every group section ends in a quick-add pre-filled with the group's + * value (`lib/quickAdd.ts` owns that mapping), and an arrow-key cursor walks + * the rows — Shift extends the WS-27n selection, Enter opens the panel. */ import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; +import { useMemo, useState } from "react"; import type { StatusRow, TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; import { sortForView } from "../lib/board"; import { cardChips } from "../lib/card"; +import { clampCursor, stepCursor } from "../lib/cursor"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; +import { quickAddPrefill } from "../lib/quickAdd"; +import { QuickAdd } from "./QuickAdd"; +import { useFlash } from "./useFlash"; + +const NOBODY: ReadonlySet = new Set(); interface Props { groups: TaskGroup[]; groupBy: GroupBy; statuses: StatusRow[]; + /** WS-27y — where a quick-added task is created (the selected node). */ + projectId: string; + onCreated: (task: TaskRow) => void; /** WS-27n — ids currently multi-selected. */ selected?: ReadonlySet; onToggle?: (id: string, shift: boolean) => void; onToggleAll?: () => void; allChecked?: boolean; + /** WS-27y — Shift+Arrow grew the selection to exactly these ids. */ + onExtendSelection?: (ids: string[]) => void; onSelect: (task: TaskRow) => void; } @@ -36,21 +53,112 @@ export function TaskList({ groups, groupBy, statuses, + projectId, + onCreated, selected, onToggle, onToggleAll, allChecked = false, + onExtendSelection, onSelect, }: Props) { + const [cursor, setCursor] = useState(-1); + const [anchor, setAnchor] = useState(null); + const { flash, attach, scrollTo } = useFlash(); + const statusById = new Map(statuses.map((s) => [s.id, s])); const total = groups.reduce((sum, group) => sum + group.tasks.length, 0); + // The cursor's world: rendered order, each id once (a two-owner task is + // drawn in two sections but is one row to the keyboard, as to WS-27n). + const sections = useMemo( + () => + groups + .filter((group) => group.tasks.length > 0) + .map((group) => ({ ...group, tasks: sortForView(group.tasks) })), + [groups] + ); + const rows = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const group of sections) + for (const task of group.tasks) + if (!seen.has(task.id)) { + seen.add(task.id); + out.push(task.id); + } + return out; + }, [sections]); + const taskById = useMemo(() => { + const map = new Map(); + for (const group of sections) for (const task of group.tasks) map.set(task.id, task); + return map; + }, [sections]); + + // Clamped at READ time rather than synced by an effect: the rows shrink + // under the cursor on every reload, and a state write per reload is exactly + // the cascading-render pattern the lint forbids. + const cursorAt = clampCursor(rows.length, cursor); + + function onKeyDown(event: React.KeyboardEvent) { + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]" + ) + ) + return; + const picked = selected ?? NOBODY; + const next = stepCursor( + rows, + { cursor: cursorAt, anchor, selection: picked }, + event.key, + event.shiftKey + ); + if (!next) return; + event.preventDefault(); + setCursor(next.cursor); + setAnchor(next.anchor); + if (next.selection !== picked) onExtendSelection?.([...next.selection]); + if (next.open) { + const task = taskById.get(next.open); + if (task) onSelect(task); + } + if (next.cursor >= 0) scrollTo(rows[next.cursor]); + } + + async function quickAdd(title: string, groupKey: string) { + const plan = quickAddPrefill(groupBy, groupKey); + const created = await projectsApi.createTask({ + project_id: projectId, + title, + ...plan.create, + }); + if (plan.assignees?.length) { + // Best-effort: the task exists; a failed PUT leaves it honestly in + // Unassigned rather than inviting a duplicate-creating retry. + try { + await projectsApi.setAssignees(created.id, plan.assignees); + } catch { + /* the list will show where it actually landed */ + } + } + flash(created.id); + onCreated(created); + } + if (total === 0) { return

No tasks here yet.

; } + const columnCount = onToggle ? 6 : 5; + return ( -
+
@@ -76,81 +184,93 @@ export function TaskList({ - {groups.map((group) => { - // An empty status lane is kept on the board so a missing column reads - // as a missing state; a list has no columns, so an empty section is - // just a heading with nothing under it. Drop it. - if (group.tasks.length === 0) return null; - return ( - - {groupBy === "none" ? null : ( - - + {groupBy === "none" ? null : ( + + + + )} + {group.tasks.map((task) => { + const status = statusById.get(task.status_id); + const atCursor = cursorAt >= 0 && rows[cursorAt] === task.id; + return ( + onSelect(task)} + className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted ${ + selected?.has(task.id) ? "bg-accent/40" : "" + } ${atCursor ? "bg-muted/60 ring-2 ring-inset ring-ring" : ""}`} + > + {onToggle ? ( + + ) : null} + + + + + - )} - {sortForView(group.tasks).map((task) => { - const status = statusById.get(task.status_id); - return ( - onSelect(task)} - className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted ${ - selected?.has(task.id) ? "bg-accent/40" : "" - }`} - > - {onToggle ? ( - - ) : null} - - - - - - - ); - })} - - ); - })} + ); + })} + {/* WS-27y — the group's own capture box: a task added here lands + in THIS group, pre-filled by `quickAddPrefill`. */} + + + + + ))}
Details
- {group.label} - - {group.tasks.length} + {/* An empty status lane is kept on the board so a missing column reads + as a missing state; a list has no columns, so an empty section is + just a heading with nothing under it — `sections` dropped it. */} + {sections.map((group) => ( +
+ {group.label} + + {group.tasks.length} + +
+ e.stopPropagation()} + onChange={(e) => + onToggle( + task.id, + (e.nativeEvent as MouseEvent).shiftKey, + ) + } + /> + + {task.task_number ?? "—"} + + + {task.title} - + + {status?.name ?? "—"} + + {task.assignees?.length ? ( + + ) : ( + "—" + )} + + +
- e.stopPropagation()} - onChange={(e) => - onToggle( - task.id, - (e.nativeEvent as MouseEvent).shiftKey, - ) - } - /> - - {task.task_number ?? "—"} - - - {task.title} - - - {status?.name ?? "—"} - - {task.assignees?.length ? ( - - ) : ( - "—" - )} - - -
+ quickAdd(title, group.key)} + className="max-w-md" + /> +
); diff --git a/workbench/control_plane/src/app/projects/components/flash.module.css b/workbench/control_plane/src/app/projects/components/flash.module.css new file mode 100644 index 000000000..5fed4f59d --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/flash.module.css @@ -0,0 +1,34 @@ +/* + * Projects · the landing flash (WS-27y). + * + * After a drop or a quick-add, the card that moved (or was born) announces + * where it landed: scrolled into view by `useFlash`, then this brief fade. + * Theme tokens only — the tint is the theme's own primary at low opacity, so + * every theme flashes in its own voice (DESIGN_SYSTEM.md §1: a token at an + * opacity is still a token). + */ + +.flash { + animation: flashFade 1.2s var(--motion-easing, ease-out) both; +} + +@keyframes flashFade { + 0% { + background-color: color-mix(in srgb, var(--primary) 28%, transparent); + box-shadow: 0 0 0 2px var(--ring); + } + 60% { + background-color: color-mix(in srgb, var(--primary) 14%, transparent); + box-shadow: 0 0 0 2px transparent; + } + 100% { + background-color: transparent; + box-shadow: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .flash { + animation: none; + } +} diff --git a/workbench/control_plane/src/app/projects/components/useFlash.ts b/workbench/control_plane/src/app/projects/components/useFlash.ts new file mode 100644 index 000000000..aa5ebf3c4 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/useFlash.ts @@ -0,0 +1,88 @@ +"use client"; + +/** + * Projects · scroll-into-view + flash for a card that just landed (WS-27y). + * + * After any drop or quick-add, the moved/created card must be SEEN to land — + * scrolled into view and briefly tinted — or the gesture ends in silence and + * people re-do it. The awkward part is timing: at the moment of the gesture + * the card's element may not exist yet (a quick-add's card appears only after + * the reload; a cross-column drop remounts the card in its new column). So a + * flash is *pending* until the element shows up: `flash(id)` fires + * immediately when the node is on the page, and otherwise the ref callback + * fires it the moment React mounts it. The pending marker expires after a few + * seconds so a card re-mounted much later (a filter change) does not flash + * out of nowhere. + * + * Refs and classList rather than state, deliberately: a flash must not + * re-render three hundred cards, and the animation itself is CSS + * (`flash.module.css`, theme tokens only). + */ + +import { useCallback, useRef } from "react"; + +import styles from "./flash.module.css"; + +const PENDING_MS = 3000; +const CLEAR_MS = 1600; + +export interface Flash { + /** Flash the element registered under `id`, now or when it next mounts. */ + flash: (id: string) => void; + /** Ref callback registering an element under `id`. */ + attach: (id: string) => (el: HTMLElement | null) => void; + /** Scroll to a registered element without flashing — the cursor's need. */ + scrollTo: (id: string) => void; +} + +export function useFlash(): Flash { + const els = useRef(new Map()); + const pending = useRef(null); + const expiry = useRef | null>(null); + + const run = useCallback((el: HTMLElement) => { + el.scrollIntoView({ block: "nearest", inline: "nearest" }); + // Remove-reflow-add restarts the animation when the same card flashes + // twice in a row (two quick-adds into one column). + el.classList.remove(styles.flash); + void el.offsetWidth; + el.classList.add(styles.flash); + setTimeout(() => el.classList.remove(styles.flash), CLEAR_MS); + }, []); + + const flash = useCallback( + (id: string) => { + // Pending survives an immediate run: a drop flashes the card where it + // stands AND re-flashes it if the reload remounts it in its new column. + pending.current = id; + if (expiry.current) clearTimeout(expiry.current); + expiry.current = setTimeout(() => { + pending.current = null; + }, PENDING_MS); + const el = els.current.get(id); + if (el) run(el); + }, + [run] + ); + + const attach = useCallback( + (id: string) => (el: HTMLElement | null) => { + if (el) { + els.current.set(id, el); + if (pending.current === id) { + pending.current = null; + run(el); + } + } else { + els.current.delete(id); + } + }, + [run] + ); + + const scrollTo = useCallback((id: string) => { + els.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, []); + + return { flash, attach, scrollTo }; +} diff --git a/workbench/control_plane/src/app/projects/lib/board.test.ts b/workbench/control_plane/src/app/projects/lib/board.test.ts index c4595b913..f19c775cd 100644 --- a/workbench/control_plane/src/app/projects/lib/board.test.ts +++ b/workbench/control_plane/src/app/projects/lib/board.test.ts @@ -9,12 +9,16 @@ import { describe, expect, it } from "vitest"; import { POSITION_MAX, SAVED_VIEW_POSITION, + buildCellDropPatch, buildColumnDropUpdate, + currentAxisKey, + dropRefusal, orderBearingView, planDrop, positionBetween, sortForView, } from "./board"; +import { UNSET } from "./grouping"; describe("positionBetween", () => { it("halves the gap between two neighbours", () => { @@ -118,6 +122,109 @@ describe("buildColumnDropUpdate", () => { expect(buildColumnDropUpdate("assignee", "someone")).toBeNull(); expect(buildColumnDropUpdate(undefined, null)).toBeNull(); }); + + it("patches importance as an integer, and the UNSET lane as null (WS-27y)", () => { + // The gateway's TaskIn declares `importance: int`; "2" would 422, and + // Number(UNSET) would be NaN. + expect(buildColumnDropUpdate("importance", "2")).toEqual({ importance: 2 }); + expect(buildColumnDropUpdate("importance", "0")).toEqual({ importance: 0 }); + expect(buildColumnDropUpdate("importance", UNSET)).toEqual({ importance: null }); + }); +}); + +describe("dropRefusal (WS-27y)", () => { + it("allows the single-valued plain-PATCH axes", () => { + expect(dropRefusal("status")).toBeNull(); + expect(dropRefusal("importance")).toBeNull(); + expect(dropRefusal("none")).toBeNull(); + expect(dropRefusal(undefined)).toBeNull(); + }); + + it("refuses many-valued axes, and says why", () => { + expect(dropRefusal("assignee")).toMatch(/many-valued/i); + expect(dropRefusal("tag")).toMatch(/many-valued/i); + }); + + it("refuses a project move with the grant boundary named", () => { + expect(dropRefusal("project")).toMatch(/grant boundary/i); + }); + + it("refuses a lane cell when EITHER axis is unwritable", () => { + // Writing half of what the cell means would file the card somewhere it + // is not. + expect(dropRefusal("status", "assignee")).toMatch(/many-valued/i); + expect(dropRefusal("assignee", "status")).toMatch(/many-valued/i); + expect(dropRefusal("status", "importance")).toBeNull(); + }); + + it("refuses an axis it has never heard of, naming it", () => { + expect(dropRefusal("phase")).toMatch(/phase/); + }); + + it("refuses to move an archived task at all", () => { + expect(dropRefusal("status", null, { id: "t", archived_at: "2026-08-01" })).toMatch( + /archived/i + ); + }); +}); + +describe("currentAxisKey", () => { + const task = { + status_id: "s-1", + project_id: "p-1", + importance: 0, + }; + + it("reads the bucket a task sits in per axis", () => { + expect(currentAxisKey(task, "status")).toBe("s-1"); + expect(currentAxisKey(task, "project")).toBe("p-1"); + }); + + it("keeps importance 0 a real bucket and missing importance UNSET", () => { + expect(currentAxisKey(task, "importance")).toBe("0"); + expect(currentAxisKey({ importance: null }, "importance")).toBe(UNSET); + expect(currentAxisKey({}, "importance")).toBe(UNSET); + }); + + it("has no single answer for many-valued or unknown axes", () => { + expect(currentAxisKey(task, "assignee")).toBeNull(); + expect(currentAxisKey(task, undefined)).toBeNull(); + }); +}); + +describe("buildCellDropPatch (WS-27y)", () => { + const task = { status_id: "s-todo", importance: null }; + + it("sets BOTH axes when a card lands in a foreign cell", () => { + expect( + buildCellDropPatch(task, "status", "s-doing", "importance", "2") + ).toEqual({ status_id: "s-doing", importance: 2 }); + }); + + it("patches only the axis that actually moved", () => { + // Dropped elsewhere in its own column: the lane changed, status did not — + // patching status anyway would write an activity row about a non-move. + expect(buildCellDropPatch(task, "status", "s-todo", "importance", "1")).toEqual({ + importance: 1, + }); + }); + + it("patches nothing for a drop into the cell it came from", () => { + expect(buildCellDropPatch(task, "status", "s-todo", "importance", UNSET)).toBeNull(); + }); + + it("works without a lane axis — the flat board's column drop", () => { + expect(buildCellDropPatch(task, "status", "s-doing")).toEqual({ + status_id: "s-doing", + }); + expect(buildCellDropPatch(task, "status", "s-todo")).toBeNull(); + }); + + it("clears priority when dropped into the no-priority lane", () => { + expect( + buildCellDropPatch({ status_id: "s-todo", importance: 2 }, "status", "s-todo", "importance", UNSET) + ).toEqual({ importance: null }); + }); }); describe("orderBearingView", () => { diff --git a/workbench/control_plane/src/app/projects/lib/board.ts b/workbench/control_plane/src/app/projects/lib/board.ts index e3dfcdaf8..0c7be99c8 100644 --- a/workbench/control_plane/src/app/projects/lib/board.ts +++ b/workbench/control_plane/src/app/projects/lib/board.ts @@ -9,6 +9,8 @@ * there is no rank column on the task, and one drop writes exactly one row. */ +import { UNSET } from "./grouping"; + export interface PositionedTask { id: string; /** The task's position in THIS view, or null when it has never been dragged. */ @@ -150,7 +152,7 @@ export function orderBearingView( export function buildColumnDropUpdate( columnBy: string | null | undefined, groupKey: string | null -): Record | null { +): Record | null { switch (columnBy) { case "status": return { status_id: groupKey }; @@ -158,9 +160,117 @@ export function buildColumnDropUpdate( return { type_id: groupKey }; case "project": return { project_id: groupKey }; + case "importance": + // The UNSET lane means "no priority", and priority travels as an + // integer — `Number(UNSET)` would be NaN, which JSON turns into null + // only by luck of the serialiser. + return { + importance: groupKey === null || groupKey === UNSET ? null : Number(groupKey), + }; default: // An unknown grouping is not an error — the drop still reorders within // the column; it simply patches no field. return null; } } + +/** + * WS-27y — why a drop into this target is refused, or `null` when it is not. + * + * The axes a drop can WRITE are the single-valued plain-PATCH fields: status + * and priority (and type, which `buildColumnDropUpdate` already speaks). The + * refusals are the reasons the old board silently disabled dragging for — + * said out loud, on the target, while the card hovers over it: + * + * - assignee/tag are many-valued. A task with two owners is drawn in both + * their columns; dropping it into a third cannot know which of the two it + * should replace, so the drop cannot honestly mean anything. + * - project crosses a grant boundary (R5) — the move is real but deliberate, + * and belongs in the task panel, not at the end of a flick. + * - "none" refuses nothing: one column, so a drop is a pure reorder. + * + * A dual-axis target (a lane cell) is refused if EITHER axis is — writing + * half of what the cell means would file the card somewhere it is not. + */ +export function dropRefusal( + columnBy: string | null | undefined, + laneBy?: string | null, + task?: Pick & { archived_at?: string | null } +): string | null { + // A task the server has archived is read-only history; the board should + // never be dragging one, but a stale row is not impossible. + if (task?.archived_at) return "This task is archived — unarchive it first."; + + const REASONS: Record = { + assignee: + "Assignees are many-valued — a drop can't know which one to replace. Edit them on the task.", + tag: "Tags are many-valued — edit them on the task instead.", + project: + "Moving between projects crosses a grant boundary — use the task panel.", + }; + const WRITABLE = new Set(["status", "importance", "type", "none"]); + + for (const axis of [columnBy, laneBy]) { + if (!axis || axis === "none" || WRITABLE.has(axis)) continue; + return REASONS[axis] ?? `Grouping by ${axis} isn't a field a drop can set.`; + } + return null; +} + +/** The bucket key a task currently occupies on an axis — `UNSET` sentinel and + * all, so it compares directly against a column or lane key. `null` means the + * axis has no single current value (many-valued, or unknown). */ +export function currentAxisKey( + task: { + status_id?: string | null; + type_id?: string | null; + project_id?: string | null; + importance?: number | null; + }, + axis: string | null | undefined +): string | null { + switch (axis) { + case "status": + return task.status_id ?? null; + case "type": + return task.type_id ?? null; + case "project": + return task.project_id ?? null; + case "importance": + return task.importance === null || task.importance === undefined + ? UNSET + : String(task.importance); + default: + return null; + } +} + +/** + * The one PATCH a drop into a (column, lane) cell implies — BOTH axes at once. + * + * Axes the task already satisfies are left out, so dropping a card elsewhere + * in its own lane changes only the axis that actually moved, and a drop into + * the very cell it came from patches nothing (`null`) — no activity row + * saying a task moved to where it already was. + * + * Callers must have consulted `dropRefusal` first; an unwritable axis here + * simply contributes nothing, which is the safe wrong answer. + */ +export function buildCellDropPatch( + task: Parameters[0], + columnBy: string | null | undefined, + columnKey: string | null, + laneBy?: string | null, + laneKey?: string | null +): Record | null { + const patch: Record = {}; + for (const [axis, key] of [ + [columnBy, columnKey], + [laneBy, laneKey], + ] as const) { + if (!axis || key === undefined || key === null) continue; + if (currentAxisKey(task, axis) === key) continue; + Object.assign(patch, buildColumnDropUpdate(axis, key) ?? {}); + } + return Object.keys(patch).length ? patch : null; +} diff --git a/workbench/control_plane/src/app/projects/lib/cursor.test.ts b/workbench/control_plane/src/app/projects/lib/cursor.test.ts new file mode 100644 index 000000000..f3a477fed --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/cursor.test.ts @@ -0,0 +1,124 @@ +/** + * Projects · the keyboard cursor (WS-27y). + * + * The transitions that decide whether arrow keys feel solid: entry from + * nowhere, both boundaries, a shift-sweep in each direction, a sweep started + * with the very first shifted keystroke, and a cursor left standing on rows + * that a reload just shortened. + */ + +import { describe, expect, it } from "vitest"; + +import { NO_CURSOR, clampCursor, stepCursor } from "./cursor"; + +const rows = ["a", "b", "c", "d"]; +const at = (cursor: number, selection: string[] = [], anchor: number | null = null) => ({ + cursor, + anchor, + selection: new Set(selection), +}); + +describe("plain arrows", () => { + it("walks down and up", () => { + expect(stepCursor(rows, at(1), "ArrowDown", false)?.cursor).toBe(2); + expect(stepCursor(rows, at(1), "ArrowUp", false)?.cursor).toBe(0); + }); + + it("enters at the top going down and at the bottom going up", () => { + expect(stepCursor(rows, at(-1), "ArrowDown", false)?.cursor).toBe(0); + expect(stepCursor(rows, at(-1), "ArrowUp", false)?.cursor).toBe(3); + }); + + it("stops at both boundaries instead of wrapping", () => { + expect(stepCursor(rows, at(3), "ArrowDown", false)?.cursor).toBe(3); + expect(stepCursor(rows, at(0), "ArrowUp", false)?.cursor).toBe(0); + }); + + it("leaves the selection untouched — and returns the SAME set", () => { + const state = at(0, ["a"]); + const next = stepCursor(rows, state, "ArrowDown", false); + // Identity, not just equality: callers use it to skip a state write. + expect(next?.selection).toBe(state.selection); + }); + + it("ends any running sweep", () => { + expect(stepCursor(rows, at(2, [], 0), "ArrowDown", false)?.anchor).toBeNull(); + }); +}); + +describe("shift-arrows extend the selection", () => { + it("adds the swept range from the cursor", () => { + const next = stepCursor(rows, at(1), "ArrowDown", true); + expect(next?.cursor).toBe(2); + expect(next?.anchor).toBe(1); + expect([...(next?.selection ?? [])].sort()).toEqual(["b", "c"]); + }); + + it("sweeps upward too", () => { + const next = stepCursor(rows, at(2), "ArrowUp", true); + expect([...(next?.selection ?? [])].sort()).toEqual(["b", "c"]); + }); + + it("keeps the anchor across a continued sweep", () => { + const first = stepCursor(rows, at(1), "ArrowDown", true)!; + const second = stepCursor(rows, first, "ArrowDown", true)!; + expect(second.anchor).toBe(1); + expect([...second.selection].sort()).toEqual(["b", "c", "d"]); + }); + + it("adds to an existing selection rather than replacing it", () => { + // The additive grammar shift-click already has (WS-27n): shift extends, + // a plain click un-selects. The keyboard must not invent a second one. + const next = stepCursor(rows, at(2, ["a"]), "ArrowDown", true); + expect([...(next?.selection ?? [])].sort()).toEqual(["a", "c", "d"]); + }); + + it("selects the entry row when the sweep starts from nowhere", () => { + const next = stepCursor(rows, at(-1), "ArrowDown", true); + expect(next?.cursor).toBe(0); + expect([...(next?.selection ?? [])]).toEqual(["a"]); + }); +}); + +describe("Enter", () => { + it("opens the cursor row", () => { + expect(stepCursor(rows, at(2), "Enter", false)?.open).toBe("c"); + }); + + it("opens nothing when no row is active", () => { + expect(stepCursor(rows, at(-1), "Enter", false)).toBeNull(); + }); +}); + +describe("keys the cursor does not own", () => { + it("returns null so the caller never preventDefaults them", () => { + expect(stepCursor(rows, at(0), "ArrowLeft", false)).toBeNull(); + expect(stepCursor(rows, at(0), "a", false)).toBeNull(); + }); + + it("handles nothing on an empty surface", () => { + expect(stepCursor([], at(0), "ArrowDown", false)).toBeNull(); + }); +}); + +describe("clampCursor — the rows changed underneath", () => { + it("clamps a cursor past the end onto the last row", () => { + expect(clampCursor(2, 5)).toBe(1); + }); + + it("keeps a still-valid cursor where it was", () => { + expect(clampCursor(4, 2)).toBe(2); + }); + + it("clears the cursor when nothing is left, and keeps none none", () => { + expect(clampCursor(0, 2)).toBe(-1); + expect(clampCursor(4, NO_CURSOR.cursor)).toBe(-1); + }); + + it("steps sensibly right after a clamp", () => { + // The reload race: cursor on row 5 of 6, filter drops it to 3 rows, + // then the user presses ArrowDown. Clamp then step must stay in bounds. + const clamped = clampCursor(3, 5); + expect(stepCursor(["a", "b", "c"], at(clamped), "ArrowDown", false)?.cursor).toBe(2); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/cursor.ts b/workbench/control_plane/src/app/projects/lib/cursor.ts new file mode 100644 index 000000000..5c1ac78a1 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/cursor.ts @@ -0,0 +1,91 @@ +/** + * Projects · the keyboard cursor (WS-27y). + * + * ArrowUp/ArrowDown walk an active row through the list or board in render + * order; Shift+Arrow extends the EXISTING selection model (WS-27n) from the + * cursor; Enter opens the row. All of it is one pure transition — + * (rows, state, key) → next state — so the awkward cases are assertions + * rather than manual testing: a cursor on a row a filter just removed, a + * shift-sweep started from nowhere, an arrow at the boundary. + * + * Selection semantics deliberately reuse `selection.range`, and are therefore + * ADDITIVE like the shift-click it extends: sweeping over rows adds them, and + * un-selecting is a click, exactly as it already was. A second removal + * grammar here would make the keyboard and the mouse disagree about what + * shift means. + */ + +import { range } from "./selection"; + +export interface CursorState { + /** Index into the visible rows. `-1` = no active row. */ + cursor: number; + /** Where the current shift-sweep started, or null outside a sweep. */ + anchor: number | null; + selection: ReadonlySet; +} + +export const NO_CURSOR: Pick = { + cursor: -1, + anchor: null, +}; + +export interface CursorNext extends CursorState { + /** The row id Enter asked to open, else null. */ + open: string | null; +} + +/** + * One keystroke. Returns `null` for keys the cursor does not own, so callers + * can `preventDefault` exactly when the key was consumed and never eat a + * keystroke that belonged to something else. + */ +export function stepCursor( + rows: readonly string[], + state: CursorState, + key: string, + shift: boolean +): CursorNext | null { + if (rows.length === 0) return null; + const cursor = clampCursor(rows.length, state.cursor); + + if (key === "Enter") { + if (cursor < 0) return null; + return { ...state, cursor, open: rows[cursor] }; + } + if (key !== "ArrowDown" && key !== "ArrowUp") return null; + + // From nowhere, ArrowDown enters at the top and ArrowUp at the bottom — + // the row nearest where the keystroke's attention already was. + const next = + key === "ArrowDown" + ? cursor < 0 + ? 0 + : Math.min(cursor + 1, rows.length - 1) + : cursor < 0 + ? rows.length - 1 + : Math.max(cursor - 1, 0); + + if (!shift) { + // A plain arrow ends any sweep; the selection itself is untouched, and is + // returned as the SAME set so callers can cheaply see nothing changed. + return { cursor: next, anchor: null, selection: state.selection, open: null }; + } + + // Shift: the sweep runs from where it started (or from the row the cursor + // was on; or, entering from nowhere, from the entry row itself) to the new + // cursor, and everything in between joins the selection. + const anchor = state.anchor ?? (cursor >= 0 ? cursor : next); + const selection = new Set(state.selection); + for (const id of range(rows, rows[anchor], rows[next])) selection.add(id); + return { cursor: next, anchor, selection, open: null }; +} + +/** + * Where the cursor lands after the rows changed under it: clamped into the + * new bounds, or gone when there is nothing left to stand on. + */ +export function clampCursor(rowCount: number, cursor: number): number { + if (rowCount === 0 || cursor < 0) return -1; + return Math.min(cursor, rowCount - 1); +} diff --git a/workbench/control_plane/src/app/projects/lib/grouping.test.ts b/workbench/control_plane/src/app/projects/lib/grouping.test.ts index d4b1479d8..806c70761 100644 --- a/workbench/control_plane/src/app/projects/lib/grouping.test.ts +++ b/workbench/control_plane/src/app/projects/lib/grouping.test.ts @@ -10,8 +10,10 @@ import { describe, expect, it } from "vitest"; import type { StatusRow, TaskRow } from "./api"; import { + type BoardLanes, EMPTY_FILTERS, GROUP_OPTIONS, + NO_LANES, UNSET, fromConfig, groupTasks, @@ -169,6 +171,7 @@ describe("saved view config", () => { expect(fromConfig(toConfig(filters, "assignee"))).toEqual({ filters, groupBy: "assignee", + lanes: NO_LANES, }); }); @@ -221,6 +224,77 @@ describe("saved view config", () => { }); }); +describe("swimlane state in a saved view (WS-27y)", () => { + const lanes: BoardLanes = { + subGroupBy: "assignee", + collapsedLanes: ["zoe@x.co", UNSET], + showEmptyLanes: true, + }; + + it("round-trips the sub-axis, the folded lanes and the empty-lane toggle", () => { + expect(fromConfig(toConfig(EMPTY_FILTERS, "status", lanes))).toEqual({ + filters: EMPTY_FILTERS, + groupBy: "status", + lanes, + }); + }); + + it("stores nothing for a flat board, so lane-less views stay byte-identical", () => { + expect(toConfig(EMPTY_FILTERS, "status", NO_LANES)).toEqual( + toConfig(EMPTY_FILTERS, "status") + ); + expect(toConfig(EMPTY_FILTERS, "status")).toEqual({ + filters: {}, + group_by: "status", + }); + }); + + it("keeps collapsed lanes a JSON array, not a CSV", () => { + // Lane keys are addresses and sentinels; an address containing a comma is + // unlikely, but the config is JSON and a list should stay a list. + const config = toConfig(EMPTY_FILTERS, "status", lanes); + expect(config.collapsed_lanes).toEqual(["zoe@x.co", UNSET]); + }); + + it("normalises a sub-axis equal to the main axis to none", () => { + // A board laned by its own columns is nonsense a hand-edited config could + // still say; every consumer sees the normalised truth. + const got = fromConfig({ group_by: "status", sub_group_by: "status" }); + expect(got.lanes.subGroupBy).toBe("none"); + // ...and toConfig refuses to write it in the first place. + expect( + toConfig(EMPTY_FILTERS, "status", { ...lanes, subGroupBy: "status" }) + ).not.toHaveProperty("sub_group_by"); + }); + + it("drops junk lane state from a hand-edited config", () => { + const got = fromConfig({ + sub_group_by: "phase", + collapsed_lanes: [7, null, "real"], + show_empty_lanes: "true", + }); + expect(got.lanes.subGroupBy).toBe("none"); + expect(got.lanes.collapsedLanes).toEqual(["real"]); + // A string is not a decision somebody made in the UI (same rule as + // overdue). + expect(got.lanes.showEmptyLanes).toBe(false); + }); + + it("reads an old config with no lane keys as a flat board", () => { + expect(fromConfig({ filters: {}, group_by: "status" }).lanes).toEqual(NO_LANES); + }); + + it("does not persist collapse state without its axis", () => { + const config = toConfig(EMPTY_FILTERS, "status", { + ...NO_LANES, + collapsedLanes: ["ghost"], + }); + // A collapsed-lane list without the axis it belonged to is keys from a + // board that no longer exists. + expect(config).not.toHaveProperty("collapsed_lanes"); + }); +}); + describe("groupTasks by tag (WS-27m)", () => { it("puts a task with three tags in all three columns", () => { // Same reason as two assignees: it genuinely belongs to each, and picking @@ -270,7 +344,11 @@ describe("tag filters in the query", () => { it("round-trips through a saved view", () => { const filters = { ...EMPTY_FILTERS, tags: ["bug", "ops"] }; - expect(fromConfig(toConfig(filters, "tag"))).toEqual({ filters, groupBy: "tag" }); + expect(fromConfig(toConfig(filters, "tag"))).toEqual({ + filters, + groupBy: "tag", + lanes: NO_LANES, + }); }); it("survives a config that stored tags as an array instead of CSV", () => { diff --git a/workbench/control_plane/src/app/projects/lib/grouping.ts b/workbench/control_plane/src/app/projects/lib/grouping.ts index 0f8b522b7..9213f04fe 100644 --- a/workbench/control_plane/src/app/projects/lib/grouping.ts +++ b/workbench/control_plane/src/app/projects/lib/grouping.ts @@ -51,6 +51,28 @@ export interface Filters { tags: string[]; } +/** + * WS-27y — the board's second axis, and the lane state that travels with a view. + * + * One object rather than three loose values because they only mean anything + * together: a collapsed-lane list without its axis is a list of keys from a + * board that no longer exists. + */ +export interface BoardLanes { + /** Sub-grouping axis drawn as swimlane rows. `"none"` = a flat board. */ + subGroupBy: GroupBy; + /** Lane keys the viewer folded shut. Persisted with the view, like filters. */ + collapsedLanes: string[]; + /** Draw lanes with nothing in them. Off by default — an empty lane is noise. */ + showEmptyLanes: boolean; +} + +export const NO_LANES: BoardLanes = { + subGroupBy: "none", + collapsedLanes: [], + showEmptyLanes: false, +}; + export const EMPTY_FILTERS: Filters = { q: "", statusCategory: "", @@ -83,10 +105,24 @@ export function toQuery(filters: Filters): Record { } /** A stored view's `config.filters` → the form state, defaults filled in. */ -export function fromConfig(config: unknown): { filters: Filters; groupBy: GroupBy } { +export function fromConfig(config: unknown): { + filters: Filters; + groupBy: GroupBy; + lanes: BoardLanes; +} { const raw = (config ?? {}) as Record; const stored = (raw.filters ?? {}) as Record; - const groupBy = raw.group_by; + const groupBy = GROUP_OPTIONS.includes(raw.group_by as GroupBy) + ? (raw.group_by as GroupBy) + : "status"; + // A sub-axis equal to the main axis is a board that lanes by its own + // columns — nonsense a hand-edited config could still say. Normalised to + // "none" HERE so every consumer sees one truth rather than each re-deciding. + const storedSub = raw.sub_group_by; + const subGroupBy = + GROUP_OPTIONS.includes(storedSub as GroupBy) && storedSub !== groupBy + ? (storedSub as GroupBy) + : "none"; return { filters: { ...EMPTY_FILTERS, @@ -101,9 +137,17 @@ export function fromConfig(config: unknown): { filters: Filters; groupBy: GroupB ? stored.tags.split(",").map((s) => s.trim()).filter(Boolean) : [], }, - groupBy: GROUP_OPTIONS.includes(groupBy as GroupBy) - ? (groupBy as GroupBy) - : "status", + groupBy, + lanes: { + subGroupBy, + // An array in the config (JSON keeps a list a list; lane keys never + // travel as a query string, so there is no CSV shape to mirror). A lane + // key that is not a string is a hand-edit, and is dropped. + collapsedLanes: Array.isArray(raw.collapsed_lanes) + ? raw.collapsed_lanes.filter((k): k is string => typeof k === "string") + : [], + showEmptyLanes: raw.show_empty_lanes === true, + }, }; } @@ -116,7 +160,11 @@ export function fromConfig(config: unknown): { filters: Filters; groupBy: GroupB * `"false"` must not read as on — so a view built from query shape would come * back with its toggles silently cleared. */ -export function toConfig(filters: Filters, groupBy: GroupBy): Record { +export function toConfig( + filters: Filters, + groupBy: GroupBy, + lanes: BoardLanes = NO_LANES +): Record { const stored: Record = {}; if (filters.q.trim()) stored.q = filters.q.trim(); if (filters.statusCategory) stored.status_category = filters.statusCategory; @@ -127,7 +175,15 @@ export function toConfig(filters: Filters, groupBy: GroupBy): Record = { filters: stored, group_by: groupBy }; + // WS-27y — lane state, only when it says something. A flat board stores no + // lane keys at all, so older views and lane-less views are byte-identical. + if (lanes.subGroupBy !== "none" && lanes.subGroupBy !== groupBy) { + config.sub_group_by = lanes.subGroupBy; + if (lanes.collapsedLanes.length) config.collapsed_lanes = lanes.collapsedLanes; + if (lanes.showEmptyLanes) config.show_empty_lanes = true; + } + return config; } /** Whether anything is actually filtering, for the "clear" affordance. */ diff --git a/workbench/control_plane/src/app/projects/lib/quickAdd.test.ts b/workbench/control_plane/src/app/projects/lib/quickAdd.test.ts new file mode 100644 index 000000000..4b88f78e5 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/quickAdd.test.ts @@ -0,0 +1,114 @@ +/** + * Projects · group-context quick-add (WS-27y). + * + * The contract per axis: a task quick-added inside a group must come back IN + * that group. Each case asserts the payload fragment says so — and that the + * assignee axis routes through the follow-up PUT rather than pretending the + * create body can carry people. + */ + +import { describe, expect, it } from "vitest"; + +import { dayKey } from "./calendar"; +import { UNSET } from "./grouping"; +import { + EMPTY_PLAN, + dueInstantForDay, + mergePlans, + quickAddPrefill, +} from "./quickAdd"; + +describe("quickAddPrefill per axis", () => { + it("status → the column's status_id", () => { + expect(quickAddPrefill("status", "s-doing")).toEqual({ + create: { status_id: "s-doing" }, + }); + }); + + it("project → creates IN the column's project", () => { + expect(quickAddPrefill("project", "p-firmware")).toEqual({ + create: { project_id: "p-firmware" }, + }); + }); + + it("importance → an integer, not the string the key carries", () => { + // The gateway's TaskIn declares `importance: int`; a "3" would 422. + expect(quickAddPrefill("importance", "3")).toEqual({ + create: { importance: 3 }, + }); + expect(quickAddPrefill("importance", "0")).toEqual({ + create: { importance: 0 }, + }); + }); + + it("tag → a one-element tags list", () => { + expect(quickAddPrefill("tag", "bug")).toEqual({ create: { tags: ["bug"] } }); + }); + + it("assignee → the follow-up PUT, never the create body", () => { + // TaskIn has no assignees field; smuggling it into the POST would be + // silently dropped and the add would land outside its column. + const plan = quickAddPrefill("assignee", "priya@x.co"); + expect(plan.create).toEqual({}); + expect(plan.assignees).toEqual(["priya@x.co"]); + }); + + it("day → a due instant that renders on the clicked day", () => { + const plan = quickAddPrefill("day", "2026-08-12"); + const due = plan.create.due_at as string; + // Behavioural, not byte-for-byte: whatever instant is chosen, the + // calendar (which reads due_at in the viewer's zone) must file it on the + // day that was clicked. + expect(dayKey(new Date(due))).toBe("2026-08-12"); + }); + + it("keeps the instant mid-day, so nearby timezones agree on the date", () => { + const due = new Date(dueInstantForDay("2026-08-12")); + expect(due.getHours()).toBe(12); + }); + + it("asks for nothing in an UNSET bucket, on every axis", () => { + // A task is born unassigned, untagged and priority-less — "create it in + // the Unassigned column" is what a bare create already does. + for (const axis of ["assignee", "tag", "importance"]) { + expect(quickAddPrefill(axis, UNSET)).toEqual(EMPTY_PLAN); + } + }); + + it("treats 'none' and unknown future axes as a plain add, never an error", () => { + expect(quickAddPrefill("none", "all")).toEqual(EMPTY_PLAN); + expect(quickAddPrefill("phase", "beta")).toEqual(EMPTY_PLAN); + }); +}); + +describe("mergePlans — a lane cell is two contexts at once", () => { + it("merges a column's field with a lane's field", () => { + const merged = mergePlans( + quickAddPrefill("status", "s-doing"), + quickAddPrefill("importance", "2") + ); + expect(merged.create).toEqual({ status_id: "s-doing", importance: 2 }); + expect(merged.assignees).toBeUndefined(); + }); + + it("carries the lane's assignee through the merge", () => { + const merged = mergePlans( + quickAddPrefill("status", "s-todo"), + quickAddPrefill("assignee", "ravi@x.co") + ); + expect(merged.create).toEqual({ status_id: "s-todo" }); + expect(merged.assignees).toEqual(["ravi@x.co"]); + }); + + it("unions assignees rather than letting the later plan overwrite", () => { + const merged = mergePlans( + { create: {}, assignees: ["a@x.co"] }, + { create: {}, assignees: ["a@x.co", "b@x.co"] } + ); + expect(merged.assignees).toEqual(["a@x.co", "b@x.co"]); + }); + + it("merging nothing is still a valid plan", () => { + expect(mergePlans()).toEqual({ create: {} }); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/quickAdd.ts b/workbench/control_plane/src/app/projects/lib/quickAdd.ts new file mode 100644 index 000000000..fb82d6a67 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/quickAdd.ts @@ -0,0 +1,96 @@ +/** + * Projects · group-context quick-add (WS-27y). + * + * A quick-add lives inside a group — a board column, a lane cell, a list + * section, a calendar day — and the task it creates must LAND in that group, + * or the add reads as a failure while the task sits in some default bucket + * off-screen. The mapping from "where the input is" to "what the create must + * say" is this module, pure and surface-agnostic: the board, the list, the + * calendar, and the coming spreadsheet layout (WS-27x's bottom quick-add row) + * all feed it an axis and a key and spread the result into their own POST. + * + * The plan has two parts because the API does: `create` merges into the + * `POST /tasks` body (`TaskIn` speaks status_id, project_id, importance, tags, + * due_at), while `assignees` is a follow-up `PUT` — assignment is not a + * create-body field, and pretending it is would silently drop the one value + * an assignee column's quick-add exists to set. + */ + +import { UNSET, type GroupBy } from "./grouping"; + +/** The axes a quick-add can sit inside: any grouping, plus a calendar day. */ +export type QuickAddAxis = GroupBy | "day"; + +export interface QuickAddPlan { + /** Fields to merge into the `POST /tasks` body. */ + create: Record; + /** People to `PUT` onto the task once it exists. Absent when none. */ + assignees?: string[]; +} + +/** A plan that says nothing — the quick-add of a group with no value. */ +export const EMPTY_PLAN: QuickAddPlan = { create: {} }; + +/** + * A calendar day (`YYYY-MM-DD`) as a due instant: noon LOCAL time. + * + * Noon, not midnight — `due_at` is an instant read back in each viewer's + * timezone (see `calendar.taskDays`), and a local-midnight instant lands on + * yesterday for anyone west of the creator. Noon keeps the task on the + * intended day for every viewer within ±11 hours, which is the best a single + * instant can do for a day somebody clicked on. + */ +export function dueInstantForDay(day: string): string { + const [y, m, d] = day.split("-").map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0).toISOString(); +} + +/** + * (axis, group key) → what the created task must carry to belong there. + * + * The `UNSET` bucket asks for nothing on any axis: a task is born unassigned, + * untagged and priority-less, so "create it in the Unassigned column" is + * already what a bare create does. Status has no unset bucket — the server + * assigns the project's default status when none is sent. + */ +export function quickAddPrefill( + axis: QuickAddAxis | string, + key: string +): QuickAddPlan { + if (key === UNSET) return EMPTY_PLAN; + switch (axis) { + case "status": + return { create: { status_id: key } }; + case "project": + // The one axis where the fragment overrides the surface's own + // project_id — a quick-add in the "Firmware" column creates IN Firmware. + return { create: { project_id: key } }; + case "importance": + return { create: { importance: Number(key) } }; + case "tag": + return { create: { tags: [key] } }; + case "assignee": + return { create: {}, assignees: [key] }; + case "day": + return { create: { due_at: dueInstantForDay(key) } }; + default: + // "none", the flat list's "all" bucket, and any axis a later view + // invents: an add with no context is a plain add, never an error. + return EMPTY_PLAN; + } +} + +/** + * A lane cell is TWO contexts at once — merge their plans. Later plans win on + * conflicting create keys (there are none between distinct axes today); + * assignees union rather than overwrite, since both axes may name people. + */ +export function mergePlans(...plans: QuickAddPlan[]): QuickAddPlan { + const create: Record = {}; + const assignees = new Set(); + for (const plan of plans) { + Object.assign(create, plan.create); + for (const who of plan.assignees ?? []) assignees.add(who); + } + return assignees.size ? { create, assignees: [...assignees] } : { create }; +} diff --git a/workbench/control_plane/src/app/projects/lib/swimlanes.test.ts b/workbench/control_plane/src/app/projects/lib/swimlanes.test.ts new file mode 100644 index 000000000..1220acf41 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/swimlanes.test.ts @@ -0,0 +1,169 @@ +/** + * Projects · swimlanes (WS-27y). + * + * The silent failures a lane grid invites: a multi-assignee task missing from + * one of its people's lanes, a lane count that disagrees with its cards, a + * status sub-axis losing its configured order, cells drifting out of column + * alignment. + */ + +import { describe, expect, it } from "vitest"; + +import type { StatusRow, TaskRow } from "./api"; +import { UNSET, groupTasks } from "./grouping"; +import { + buildSwimlanes, + hiddenLaneCount, + toggleLane, + visibleLanes, +} from "./swimlanes"; + +const status = (id: string, name: string, position: number): StatusRow => ({ + id, + project_id: "p1", + name, + color: "#888888", + position, + category: "todo", + is_default: false, +}); + +const task = (over: Partial = {}): TaskRow => ({ + id: "t1", + project_id: "p1", + root_project_id: "p1", + status_id: "s-todo", + title: "Fix the extruder", + ...over, +}); + +const STATUSES = [status("s-doing", "In progress", 2), status("s-todo", "To do", 1)]; +const ctx = { statuses: STATUSES }; + +/** Status columns × assignee lanes — the flagship combination. */ +const board = (tasks: TaskRow[]) => { + const columns = groupTasks(tasks, "status", ctx); + return { columns, lanes: buildSwimlanes(columns, "assignee", ctx) }; +}; + +describe("buildSwimlanes", () => { + it("puts each task in the cell where its column and lane cross", () => { + const { columns, lanes } = board([ + task({ id: "a", status_id: "s-todo", assignees: ["priya@x.co"] }), + task({ id: "b", status_id: "s-doing", assignees: ["priya@x.co"] }), + task({ id: "c", status_id: "s-todo", assignees: ["ravi@x.co"] }), + ]); + expect(columns.map((c) => c.key)).toEqual(["s-todo", "s-doing"]); + expect(lanes.map((l) => l.label)).toEqual(["priya", "ravi"]); + // priya's lane: [todo cell, doing cell] + expect(lanes[0].cells.map((cell) => cell.map((t) => t.id))).toEqual([ + ["a"], + ["b"], + ]); + expect(lanes[1].cells.map((cell) => cell.map((t) => t.id))).toEqual([ + ["c"], + [], + ]); + }); + + it("keeps cells aligned with the columns, index for index", () => { + const { columns, lanes } = board([task({ assignees: ["zoe@x.co"] })]); + for (const lane of lanes) expect(lane.cells).toHaveLength(columns.length); + }); + + it("draws a two-owner task in BOTH owners' lanes", () => { + // Same honesty rule as assignee columns: it IS both people's work. + const { lanes } = board([ + task({ assignees: ["priya@x.co", "ravi@x.co"] }), + ]); + expect(lanes.map((l) => l.label)).toEqual(["priya", "ravi"]); + expect(lanes.every((l) => l.cells[0].length === 1)).toBe(true); + }); + + it("counts a task once per lane, not once per column it appears in", () => { + // Columns from a multi-valued FIRST axis can repeat a task; the lane + // total must still speak in distinct tasks or the header lies. + const columns = groupTasks( + [task({ id: "a", tags: ["bug", "ops"], assignees: ["zoe@x.co"] })], + "tag", + ctx + ); + const lanes = buildSwimlanes(columns, "assignee", ctx); + expect(lanes).toHaveLength(1); + expect(lanes[0].total).toBe(1); + // ...while still drawing it in both columns' cells. + expect(lanes[0].cells.map((cell) => cell.length)).toEqual([1, 1]); + }); + + it("collects ownerless tasks in an Unassigned lane, last", () => { + const { lanes } = board([ + task({ id: "a" }), + task({ id: "b", assignees: ["zoe@x.co"] }), + ]); + expect(lanes.map((l) => l.key)).toEqual(["zoe@x.co", UNSET]); + }); + + it("keeps a status sub-axis in configured order, empty lanes included", () => { + const columns = groupTasks( + [task({ assignees: ["zoe@x.co"], status_id: "s-doing" })], + "assignee", + ctx + ); + const lanes = buildSwimlanes(columns, "status", ctx); + // "To do" is empty but present — position order, not activity order. + expect(lanes.map((l) => l.label)).toEqual(["To do", "In progress"]); + expect(lanes.map((l) => l.total)).toEqual([0, 1]); + }); + + it("preserves each column's own task order inside a cell", () => { + const columns = [ + { + key: "s-todo", + label: "To do", + tasks: [ + task({ id: "second", assignees: ["zoe@x.co"] }), + task({ id: "first", assignees: ["zoe@x.co"] }), + ], + }, + ]; + const lanes = buildSwimlanes(columns, "assignee", ctx); + // Whatever order the column held (per-view positions), the cell keeps. + expect(lanes[0].cells[0].map((t) => t.id)).toEqual(["second", "first"]); + }); + + it("is empty for an empty board", () => { + expect(buildSwimlanes([], "assignee", ctx)).toEqual([]); + }); +}); + +describe("visibleLanes and the show-empty toggle", () => { + const lanes = [ + { key: "a", label: "A", total: 2, cells: [[]] }, + { key: "b", label: "B", total: 0, cells: [[]] }, + ]; + + it("hides empty lanes by default", () => { + expect(visibleLanes(lanes, false).map((l) => l.key)).toEqual(["a"]); + }); + + it("shows them all when asked", () => { + expect(visibleLanes(lanes, true).map((l) => l.key)).toEqual(["a", "b"]); + }); + + it("says how many are hidden, so the toggle is honest about what it hides", () => { + expect(hiddenLaneCount(lanes)).toBe(1); + }); +}); + +describe("toggleLane", () => { + it("folds a lane shut and back open", () => { + expect(toggleLane([], "a")).toEqual(["a"]); + expect(toggleLane(["a", "b"], "a")).toEqual(["b"]); + }); + + it("does not mutate the list it was given", () => { + const collapsed = ["a"]; + toggleLane(collapsed, "b"); + expect(collapsed).toEqual(["a"]); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/swimlanes.ts b/workbench/control_plane/src/app/projects/lib/swimlanes.ts new file mode 100644 index 000000000..e7f3abf72 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/swimlanes.ts @@ -0,0 +1,97 @@ +/** + * Projects · swimlanes — the board's second axis (WS-27y). + * + * A board grouped by status and sub-grouped by assignee is a grid: columns are + * the first axis, lanes are the second, and a cell is the intersection. All of + * that intersection arithmetic lives here as pure functions, because the bugs + * a swimlane board invites are silent ones — a task in the wrong cell, a lane + * whose count disagrees with its cards, a multi-assignee task that vanishes + * from one of its people's lanes. + * + * Lane identity and ordering are `groupTasks`'s, deliberately: a lane is just + * a group drawn sideways, and re-deriving membership here would be a second + * implementation of the rules `grouping.ts` already tests (two assignees → + * both lanes, importance 0 is Low not unset, empty status lanes kept). + */ + +import type { StatusRow, TaskRow } from "./api"; +import { type GroupBy, type TaskGroup, groupTasks } from "./grouping"; + +export interface Swimlane { + /** Stable lane identity — same vocabulary as a group key. */ + key: string; + label: string; + /** Distinct tasks in the lane, across every column. */ + total: number; + /** Cells aligned index-for-index with the caller's columns. */ + cells: TaskRow[][]; +} + +/** + * Columns × sub-axis → lanes. + * + * Lane order comes from grouping the DISTINCT union of every column's tasks — + * so a status sub-axis keeps its configured lane order (and its empty lanes), + * and an assignee sub-axis sorts people alphabetically with Unassigned last, + * exactly as the flat board would have drawn them as columns. + * + * Each cell preserves its column's task order, so per-view positions survive + * the split into lanes. + */ +export function buildSwimlanes( + columns: readonly TaskGroup[], + subBy: GroupBy, + ctx: { statuses: StatusRow[]; projectName?: (id: string) => string } +): Swimlane[] { + // Distinct union: a task drawn in two columns (two assignees, two tags) must + // count once per lane, not once per column it appears in. + const seen = new Set(); + const union: TaskRow[] = []; + for (const column of columns) { + for (const task of column.tasks) { + if (!seen.has(task.id)) { + seen.add(task.id); + union.push(task); + } + } + } + + const order = groupTasks(union, subBy, ctx); + const perColumn = columns.map( + (column) => + new Map(groupTasks(column.tasks, subBy, ctx).map((g) => [g.key, g.tasks])) + ); + + return order.map((lane) => ({ + key: lane.key, + label: lane.label, + total: lane.tasks.length, + cells: perColumn.map((cells) => cells.get(lane.key) ?? []), + })); +} + +/** + * The lanes to draw. Empty lanes are hidden unless asked for — a board where + * most people have nothing in most statuses is otherwise mostly whitespace. + */ +export function visibleLanes( + lanes: readonly Swimlane[], + showEmpty: boolean +): Swimlane[] { + return showEmpty ? [...lanes] : lanes.filter((lane) => lane.total > 0); +} + +/** How many lanes `visibleLanes` is hiding, for the toggle's honesty. */ +export function hiddenLaneCount(lanes: readonly Swimlane[]): number { + return lanes.filter((lane) => lane.total === 0).length; +} + +/** Fold a lane shut, or open it back up. Pure, so the list can live in a view. */ +export function toggleLane( + collapsed: readonly string[], + key: string +): string[] { + return collapsed.includes(key) + ? collapsed.filter((k) => k !== key) + : [...collapsed, key]; +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index 04ac7413f..b6b7a4f71 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -45,14 +45,17 @@ import { calendarWindow, dayKey, monthGrid, shiftMonth } from "./lib/calendar"; import { isOpenShortcut } from "./lib/search"; import type { Edge } from "./lib/timeline"; import { + type BoardLanes, EMPTY_FILTERS, type Filters, type GroupBy, + NO_LANES, fromConfig, groupTasks, toConfig, toQuery, } from "./lib/grouping"; +import { toggleLane } from "./lib/swimlanes"; import { allSelected as everySelected, buildRequest, @@ -114,6 +117,8 @@ function ProjectsWorkspace() { // the chip clears the moment the state stops matching what was saved. const [filters, setFilters] = useState(EMPTY_FILTERS); const [groupBy, setGroupBy] = useState("status"); + // WS-27y — the board's second axis plus its lane state; saved with a view. + const [lanes, setLanes] = useState(NO_LANES); const [views, setViews] = useState([]); const [activeViewId, setActiveViewId] = useState(null); const [me, setMe] = useState(""); @@ -379,6 +384,13 @@ function ProjectsWorkspace() { if (!shift) setAnchor(id); } + // WS-27y — the keyboard's Shift+Arrow grew the selection; `stepCursor` only + // ever adds, so replacing with its superset is the union. + function extendSelection(ids: string[]) { + setBulkNotice(null); + setPicked(new Set(ids)); + } + async function applyBulk(request: ReturnType) { if (!request) return; setBulkBusy(true); @@ -401,9 +413,12 @@ function ProjectsWorkspace() { } function applyView(view: ViewRow) { - const { filters: next, groupBy: nextGroup } = fromConfig(view.config); + const { filters: next, groupBy: nextGroup, lanes: nextLanes } = fromConfig( + view.config + ); setFilters(next); setGroupBy(nextGroup); + setLanes(nextLanes); setActiveViewId(view.id); } @@ -413,7 +428,7 @@ function ProjectsWorkspace() { const created = await projectsApi.createView(selected.id, { name, view_type: mode, - config: toConfig(filters, groupBy), + config: toConfig(filters, groupBy, lanes), // Above the seeded pair, so the drag handler keeps writing its order // into the project's original board rather than into a saved filter. position: SAVED_VIEW_POSITION + views.length, @@ -595,14 +610,15 @@ function ProjectsWorkspace() { async function handleDrop( task: TaskRow, writes: ReturnType, - patch: Record | null + patch: Record | null ) { // Optimistic: the card moves now and the truth arrives on reload. A drag - // that waits for a round trip feels broken even when it is correct. - if (patch?.status_id) { + // that waits for a round trip feels broken even when it is correct. The + // WHOLE patch applies — a lane-cell drop moves two axes at once (WS-27y). + if (patch) { setTasks((current) => current.map((t) => - t.id === task.id ? { ...t, status_id: patch.status_id as string } : t + t.id === task.id ? { ...t, ...(patch as Partial) } : t ) ); } @@ -781,6 +797,23 @@ function ProjectsWorkspace() { groupBy={groupBy} onGroupBy={(next) => { setGroupBy(next); + // The new main axis may be the current sub-axis; lanes of the + // board's own columns mean nothing, so they reset. + setLanes((current) => + current.subGroupBy === next + ? { ...current, subGroupBy: "none", collapsedLanes: [] } + : current + ); + setActiveViewId(null); + }} + subGroupBy={lanes.subGroupBy} + onSubGroupBy={(next) => { + // Collapsed-lane keys belong to the axis that made them. + setLanes((current) => ({ + ...current, + subGroupBy: next, + collapsedLanes: [], + })); setActiveViewId(null); }} me={me} @@ -853,6 +886,8 @@ function ProjectsWorkspace() { undated={month.undated} truncated={month.truncated} today={dayKey(new Date())} + projectId={selected.id} + onCreated={() => void loadMonth()} onSelect={(task) => void openWithStatuses(task)} onMove={(task, patch) => void moveTask(task, patch)} onStep={(months) => setMonthAnchor(shiftMonth(grid, months))} @@ -862,8 +897,23 @@ function ProjectsWorkspace() { + setLanes((current) => ({ + ...current, + collapsedLanes: toggleLane(current.collapsedLanes, key), + })) + } + onShowEmptyLanes={(show) => + setLanes((current) => ({ ...current, showEmptyLanes: show })) + } + statuses={statuses} + projectName={projectName} + projectId={selected.id} + onCreated={() => void loadProject(selected)} selected={picked} onToggle={toggleSelection} + onExtendSelection={extendSelection} onSelect={(task) => void openWithStatuses(task)} onDrop={handleDrop} /> @@ -872,6 +922,8 @@ function ProjectsWorkspace() { groups={groups} groupBy={groupBy} statuses={statuses} + projectId={selected.id} + onCreated={() => void loadProject(selected)} selected={picked} onToggle={toggleSelection} allChecked={everySelected(picked, onScreen)} @@ -880,6 +932,7 @@ function ProjectsWorkspace() { everySelected(picked, onScreen) ? new Set() : new Set(onScreen) ) } + onExtendSelection={extendSelection} onSelect={(task) => void openWithStatuses(task)} /> )} From ac58779deced5d410653e66e71814eb2169497fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:28:38 +0000 Subject: [PATCH 5/8] feat(projects): WS-27x spreadsheet layout + shown-fields contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces, one ticket, because the column set IS the contract: - shown_fields joins the saved-view config. lib/shownFields.ts is the ONE vocabulary (core keys + custom. by shape) that both the table's columns and the chip gate read; toConfig/fromConfig round-trip it (set semantics, default omitted, explicit [] kept), and the gateway's normalise_view_config mirrors the discipline exactly — unknown/non-string dropped, absent stays absent, tests beside WS-27y's lane-state ones. - Chip gating: card.visibleChips is the VISIBILITY layer over taskCard.ts's fact layer — CHIP_FIELD maps chip kind → field key, a hidden field earns no chip, and board, list, calendar and timeline all draw through it. - TableView: one row per task, columns = shown fields (+ Title), inline editors driving the EXISTING write paths (PATCH for status/dates/priority/ custom fields, PUT for assignees — no table-only write path). Header clicks map onto the gateway's TASK_SORTS keys (asc → desc → the view's own order); status stays the WS-27w semantic sort because the server does the sorting. Sub-tasks nest under an on-page parent via table.treeRows (orphans surface flat, collapse local, cycle-safe). - Quick-add rows per group bottom, reusing WS-27y's QuickAdd + quickAddPrefill + useFlash unchanged. - tableCursor.ts: the 2-D cell cursor — arrows move a visible ring, Enter edits (or opens the panel where a cell has no editor), Esc cancels; entry/clamping semantics deliberately match lib/cursor.ts, not a fork. - Wired as a third-ish layout ("table" beside board/list/calendar/ timeline), persisted with views like the others; FilterBar grows the shown-fields picker and counts a non-default set as saveable. DESIGN_SYSTEM: tokens only, Icon/Button/Input primitives, theme suite green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/routes/projects/filters.py | 35 + tests/unit/test_projects_filters.py | 48 ++ .../app/projects/components/CalendarView.tsx | 7 +- .../src/app/projects/components/FilterBar.tsx | 91 ++- .../src/app/projects/components/TableView.tsx | 676 ++++++++++++++++++ .../src/app/projects/components/TaskBoard.tsx | 7 +- .../src/app/projects/components/TaskList.tsx | 7 +- .../app/projects/components/TimelineView.tsx | 7 +- .../src/app/projects/lib/card.test.ts | 71 +- .../src/app/projects/lib/card.ts | 40 ++ .../src/app/projects/lib/grouping.test.ts | 62 ++ .../src/app/projects/lib/grouping.ts | 24 +- .../src/app/projects/lib/shownFields.test.ts | 93 +++ .../src/app/projects/lib/shownFields.ts | 128 ++++ .../src/app/projects/lib/table.test.ts | 233 ++++++ .../src/app/projects/lib/table.ts | 214 ++++++ .../src/app/projects/lib/tableCursor.test.ts | 115 +++ .../src/app/projects/lib/tableCursor.ts | 96 +++ .../control_plane/src/app/projects/page.tsx | 60 +- 19 files changed, 1993 insertions(+), 21 deletions(-) create mode 100644 workbench/control_plane/src/app/projects/components/TableView.tsx create mode 100644 workbench/control_plane/src/app/projects/lib/shownFields.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/shownFields.ts create mode 100644 workbench/control_plane/src/app/projects/lib/table.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/table.ts create mode 100644 workbench/control_plane/src/app/projects/lib/tableCursor.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/tableCursor.ts diff --git a/apps/services/gateway/gateway/routes/projects/filters.py b/apps/services/gateway/gateway/routes/projects/filters.py index 8f4c7356c..bbaff0801 100644 --- a/apps/services/gateway/gateway/routes/projects/filters.py +++ b/apps/services/gateway/gateway/routes/projects/filters.py @@ -241,6 +241,21 @@ def build_task_filters( "status", "assignee", "project", "importance", "tag", "none", ) +#: WS-27x — the field keys a view's `shown_fields` may name. Mirrors the +#: client vocabulary (`lib/shownFields.ts`), which is the single source the +#: table's columns and the chip gate read; this tuple is the server's copy so +#: a stored view cannot accumulate junk keys. +SHOWN_FIELDS: tuple[str, ...] = ( + "status", "assignees", "start_date", "due_at", "importance", + "subtasks", "blocked", "tags", "attachments", "estimate", "created_at", +) + +#: A project's custom fields ride the same list as ``custom.`` — +#: the spelling ``patch_task`` already files a custom edit under. Checked by +#: SHAPE rather than against the registry: this function is pure, and a view +#: must survive its field being deleted after the save. +_CUSTOM_FIELD_PREFIX = "custom." + def normalise_view_config(config: Any) -> dict[str, Any]: """A stored view's config, reduced to what the board can actually apply. @@ -282,6 +297,26 @@ def normalise_view_config(config: Any) -> dict[str, Any]: out["collapsed_lanes"] = kept if config.get("show_empty_lanes") is True: out["show_empty_lanes"] = True + # WS-27x — the shown-fields set. The client's rules mirrored exactly + # (`shownFields.sanitizeShownFields`): a list of known field keys, with + # unknown and non-string entries dropped and duplicates collapsed to the + # first appearance. ABSENT (or not a list) stays absent — the default set + # is the client's to apply, and writing it here would freeze today's + # default into every stored view. An explicitly stored empty list is KEPT: + # "every column hidden" is a choice, not the default. + shown = config.get("shown_fields") + if isinstance(shown, list): + kept_fields: list[str] = [] + for key in shown: + if not isinstance(key, str) or key in kept_fields: + continue + known = key in SHOWN_FIELDS or ( + key.startswith(_CUSTOM_FIELD_PREFIX) + and len(key) > len(_CUSTOM_FIELD_PREFIX) + ) + if known: + kept_fields.append(key) + out["shown_fields"] = kept_fields return out diff --git a/tests/unit/test_projects_filters.py b/tests/unit/test_projects_filters.py index 46fbb8392..db32a0dc8 100644 --- a/tests/unit/test_projects_filters.py +++ b/tests/unit/test_projects_filters.py @@ -351,6 +351,54 @@ def test_a_lane_less_view_stores_no_lane_keys_at_all(): } +def test_shown_fields_survive_normalisation_with_junk_dropped(): + """WS-27x — the shown-fields set rides the view config. Unknown keys and + non-strings are hand-edits (or a newer client's vocabulary) and are + dropped; the rest must come back intact or every save round-trip would + strip somebody's column choices.""" + got = normalise_view_config({ + "group_by": "status", + "shown_fields": ["status", "phase", 7, None, "due_at", "assignees"], + }) + assert got["shown_fields"] == ["status", "due_at", "assignees"] + + +def test_every_advertised_shown_field_survives_normalisation(): + from gateway.routes.projects.filters import SHOWN_FIELDS + + got = normalise_view_config({"shown_fields": list(SHOWN_FIELDS)}) + assert got["shown_fields"] == list(SHOWN_FIELDS) + + +def test_custom_field_keys_pass_by_shape_and_a_bare_prefix_does_not(): + """`custom.` names a project field the pure normaliser cannot look + up, so it is checked by shape — and `custom.` alone names nothing.""" + got = normalise_view_config({ + "shown_fields": ["custom.budget", "custom.", "customer"], + }) + assert got["shown_fields"] == ["custom.budget"] + + +def test_a_duplicate_shown_field_is_kept_once(): + got = normalise_view_config({"shown_fields": ["status", "status", "tags"]}) + assert got["shown_fields"] == ["status", "tags"] + + +def test_an_absent_or_junk_shown_fields_stays_absent(): + """Absent means "the client's default set". Writing a default HERE would + freeze today's default into every stored view, so the key is simply not + emitted — mirroring how a lane-less view stores no lane keys.""" + assert "shown_fields" not in normalise_view_config({"group_by": "status"}) + for junk in ("status,tags", {"status": True}, 7, None, True): + assert "shown_fields" not in normalise_view_config({"shown_fields": junk}) + + +def test_an_explicitly_empty_shown_fields_list_is_kept(): + """Hiding every column is a choice, not the default — collapsing `[]` + into "absent" would un-hide a deliberate choice on the next apply.""" + assert normalise_view_config({"shown_fields": []})["shown_fields"] == [] + + def test_a_saved_view_and_the_same_filters_typed_by_hand_are_one_query(): """The whole reason the builder is shared. If these ever diverge, a saved view shows a different set of tasks than the filters it claims to hold.""" diff --git a/workbench/control_plane/src/app/projects/components/CalendarView.tsx b/workbench/control_plane/src/app/projects/components/CalendarView.tsx index 818c61ec9..63e8e1e15 100644 --- a/workbench/control_plane/src/app/projects/components/CalendarView.tsx +++ b/workbench/control_plane/src/app/projects/components/CalendarView.tsx @@ -28,7 +28,7 @@ import { placeTasks, rescheduleTo, } from "../lib/calendar"; -import { cardChips } from "../lib/card"; +import { visibleChips } from "../lib/card"; import { quickAddPrefill } from "../lib/quickAdd"; import { QuickAdd } from "./QuickAdd"; import { useFlash } from "./useFlash"; @@ -44,6 +44,8 @@ interface Props { truncated: boolean; today?: string; /** WS-27y — where a day's quick-added task is created (the selected node). */ + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; projectId: string; onCreated: (task: TaskRow) => void; onSelect: (task: TaskRow) => void; @@ -59,6 +61,7 @@ export function CalendarView({ truncated, today, projectId, + shownFields, onCreated, onSelect, onMove, @@ -177,7 +180,7 @@ export function CalendarView({ > {task.title} - + ))} diff --git a/workbench/control_plane/src/app/projects/components/FilterBar.tsx b/workbench/control_plane/src/app/projects/components/FilterBar.tsx index 995ebca7e..a6c6df862 100644 --- a/workbench/control_plane/src/app/projects/components/FilterBar.tsx +++ b/workbench/control_plane/src/app/projects/components/FilterBar.tsx @@ -22,7 +22,7 @@ import Button from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { useEffect, useState } from "react"; -import type { TagRow, ViewRow } from "../lib/api"; +import type { FieldRow, TagRow, ViewRow } from "../lib/api"; import { EMPTY_FILTERS, type Filters, @@ -30,6 +30,14 @@ import { type GroupBy, isFiltered, } from "../lib/grouping"; +import { + DEFAULT_SHOWN, + FIELD_KEYS, + FIELD_LABELS, + customFieldKey, + sameFieldSet, + toggleField, +} from "../lib/shownFields"; import { byUsage, chipClass } from "../lib/tags"; /** The status categories, labelled. Mirrors the gateway's `STATUS_CATEGORIES`. */ @@ -67,6 +75,11 @@ interface Props { me: string; /** WS-27m — the project's registered tags, for the tag row. */ tags: TagRow[]; + /** WS-27x — the view's shown fields: the table's columns AND the chip gate. */ + shownFields: readonly string[]; + onShownFields: (next: string[]) => void; + /** WS-27l — the project's custom field definitions, offered in the picker. */ + fields: FieldRow[]; views: ViewRow[]; activeViewId: string | null; onApplyView: (view: ViewRow) => void; @@ -85,6 +98,9 @@ export function FilterBar({ onSubGroupBy, me, tags, + shownFields, + onShownFields, + fields, views, activeViewId, onApplyView, @@ -98,6 +114,8 @@ export function FilterBar({ const [draft, setDraft] = useState(filters.q); const [naming, setNaming] = useState(false); const [viewName, setViewName] = useState(""); + // WS-27x — the shown-fields picker's popover. + const [pickingFields, setPickingFields] = useState(false); useEffect(() => setDraft(filters.q), [filters.q]); @@ -207,6 +225,65 @@ export function FilterBar({ + {/* WS-27x — which fields this view shows. ONE set feeding two + consumers: the table's columns and every card's chip row, so + hiding a field here silences it everywhere at once. */} +
+ + {pickingFields ? ( +
{ + if (e.key === "Escape") setPickingFields(false); + }} + > +
+ + Shown fields + + +
+
+ {[ + ...FIELD_KEYS.map((key) => ({ key, label: FIELD_LABELS[key] })), + ...fields.map((def) => ({ + key: customFieldKey(def), + label: def.name, + })), + ].map(({ key, label }) => ( + + ))} +
+
+ ) : null} +
+ {isFiltered(filters) ? ( + ); + } + + function readOnlyCell(task: TaskRow, column: TableColumn): React.ReactNode { + switch (column.key) { + case "status": + return statusById.get(task.status_id)?.name ?? "—"; + case "assignees": + return task.assignees?.length ? ( + + ) : ( + "—" + ); + case "due_at": + return task.due_at ? new Date(task.due_at).toLocaleDateString() : "—"; + case "start_date": + // A floating DATE — shown as stored, never routed through `new + // Date()`, which would move it a day west of Greenwich (see api.ts). + return task.start_date ? task.start_date.slice(0, 10) : "—"; + case "importance": + return importanceLabel(task.importance) || "—"; + case "subtasks": { + const counts = task.subtasks; + return counts && counts.total > 0 ? `${counts.done}/${counts.total}` : "—"; + } + case "blocked": + return task.blocked_by_count ? String(task.blocked_by_count) : "—"; + case "tags": + return task.tags?.length ? task.tags.join(", ") : "—"; + case "attachments": + // Counted only on the single-task read (WS-27i); the list rows this + // table draws honestly do not know. + return "—"; + case "estimate": + return durationLabel(task.estimate_mins) || "—"; + case "created_at": + return task.created_at ? new Date(task.created_at).toLocaleDateString() : "—"; + default: { + const key = customKeyOf(column.key); + if (!key || !column.def) return "—"; + return displayValue(column.def, (task.custom_fields ?? {})[key]); + } + } + } + + function editorCell(task: TaskRow, column: TableColumn): React.ReactNode { + if (column.key === "status") { + return ( + + ); + } + if (column.key === "importance") { + return ( + + ); + } + if (column.key === "assignees") { + return ( + void saveAssignees(task, value)} + onCancel={closeEditor} + /> + ); + } + if (column.key === "due_at") { + return ( + + // Noon local, the calendar quick-add's rule (`dueInstantForDay`), + // so the chosen day survives every viewer's timezone. + void saveCell(task, { due_at: value ? dueInstantForDay(value) : null }) + } + onCancel={closeEditor} + /> + ); + } + if (column.key === "start_date") { + return ( + void saveCell(task, { start_date: value || null })} + onCancel={closeEditor} + /> + ); + } + const key = customKeyOf(column.key); + const def = column.def; + if (!key || !def) return readOnlyCell(task, column); + const stored = (task.custom_fields ?? {})[key]; + if (def.field_type === "boolean") { + return ( + + void saveCell(task, { custom_fields: { [key]: e.target.checked } }) + } + onBlur={closeEditor} + onKeyDown={(e) => { + if (e.key === "Escape") closeEditor(); + }} + /> + ); + } + if (def.field_type === "select") { + return ( + + ); + } + return ( + + void saveCell(task, { + custom_fields: { [key]: toWire(def.field_type, value) }, + }) + } + onCancel={closeEditor} + /> + ); + } + + if (total === 0) { + return ( +
+

No tasks here yet.

+ {/* No group to land in, so the UNSET sentinel: every axis maps it to + the empty plan, and the server assigns the default status. */} + quickAdd(title, UNSET)} + className="max-w-md" + /> +
+ ); + } + + return ( +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + + {columns.map((column) => ( + + ))} + + + {sections.map((section, sectionIndex) => { + // This section's first row's index in the cursor's flat world. + const base = sections + .slice(0, sectionIndex) + .reduce((sum, s) => sum + s.rows.length, 0); + return ( + + {groupBy === "none" ? null : ( + + + + )} + {section.rows.map((row, rowIndex) => { + const task = row.task; + const flatIndex = base + rowIndex; + const cursorHere = at.row === flatIndex; + return ( + + + {columns.map((column, columnIndex) => { + const col = columnIndex + 1; + const here = cursorHere && at.col === col; + const editing = here && at.editing && isEditable(column); + return ( + + ); + })} + + ); + })} + {/* WS-27y machinery, per the ticket: a task added here lands in + THIS group, pre-filled by `quickAddPrefill`. */} + + + + + ); + })} +
+ {header({ key: "title", label: "Title", sortKey: "title" })} + + {header(column)} +
+ {section.label} + + {section.rows.length} + +
{ + setCell({ row: flatIndex, col: 0, editing: false }); + onSelect(task); + }} + className={`cursor-pointer px-3 py-2 text-foreground ${ + cursorHere && at.col === 0 ? "ring-2 ring-inset ring-ring" : "" + }`} + > + + {row.childCount > 0 ? ( + + ) : row.depth > 0 ? ( + + ) : null} + + {taskRef(task) ?? ""} + + + {task.title} + + + { + if (editing) return; + setCell({ + row: flatIndex, + col, + editing: isEditable(column), + }); + }} + className={`px-3 py-2 text-muted-foreground ${ + isEditable(column) ? "cursor-pointer" : "" + } ${here ? "ring-2 ring-inset ring-ring" : ""}`} + > + {editing ? editorCell(task, column) : readOnlyCell(task, column)} +
+ quickAdd(title, section.key)} + className="max-w-md" + /> +
+
+ ); +} + +/** + * A one-line text-ish cell editor: Enter commits, Esc cancels, blur commits + * (leaving a half-typed edit behind silently is worse than saving it). + * Uncontrolled — the draft dies with the editor, which is what Esc means. + */ +function CellText({ + label, + defaultValue, + type = "text", + placeholder, + onCommit, + onCancel, +}: { + label: string; + defaultValue: string; + type?: string; + placeholder?: string; + onCommit: (value: string) => void; + onCancel: () => void; +}) { + const cancelled = useRef(false); + return ( + { + if (e.key === "Enter") { + e.preventDefault(); + onCommit((e.target as HTMLInputElement).value); + } else if (e.key === "Escape") { + cancelled.current = true; + onCancel(); + } + }} + onBlur={(e) => { + if (cancelled.current) return; + // Only a real change commits — tabbing through an untouched cell + // must not write a PATCH and post a timeline entry for nothing. + if (e.target.value !== defaultValue) onCommit(e.target.value); + else onCancel(); + }} + /> + ); +} diff --git a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx index 6e782d9dc..74a3454ef 100644 --- a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx @@ -36,7 +36,7 @@ import { planDrop, sortForView, } from "../lib/board"; -import { cardChips, taskRef } from "../lib/card"; +import { taskRef, visibleChips } from "../lib/card"; import { clampCursor, stepCursor } from "../lib/cursor"; import { type BoardLanes, @@ -68,6 +68,8 @@ interface Props { projectName?: (id: string) => string; /** Where a quick-added task is created (the selected node). */ projectId: string; + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; onCreated: (task: TaskRow) => void; /** WS-27n — ids currently multi-selected. Empty when nobody is bulk editing. */ selected?: ReadonlySet; @@ -91,6 +93,7 @@ export function TaskBoard({ statuses, projectName, projectId, + shownFields, onCreated, selected, onToggle, @@ -325,7 +328,7 @@ export function TaskBoard({ {/* The chip row and the owner strip are the shared card vocabulary (WS-27s) — the same components /tasks draws, so a task looks like the same kind of thing in both. */} - + {taskRef(task)} diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index 3f36211f2..2edc2b901 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -23,7 +23,7 @@ import { useMemo, useState } from "react"; import type { StatusRow, TaskRow } from "../lib/api"; import { projectsApi } from "../lib/api"; import { sortForView } from "../lib/board"; -import { cardChips, taskRef } from "../lib/card"; +import { taskRef, visibleChips } from "../lib/card"; import { clampCursor, stepCursor } from "../lib/cursor"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; import { quickAddPrefill } from "../lib/quickAdd"; @@ -38,6 +38,8 @@ interface Props { statuses: StatusRow[]; /** WS-27y — where a quick-added task is created (the selected node). */ projectId: string; + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; onCreated: (task: TaskRow) => void; /** WS-27n — ids currently multi-selected. */ selected?: ReadonlySet; @@ -54,6 +56,7 @@ export function TaskList({ groupBy, statuses, projectId, + shownFields, onCreated, selected, onToggle, @@ -251,7 +254,7 @@ export function TaskList({ )} - + ); diff --git a/workbench/control_plane/src/app/projects/components/TimelineView.tsx b/workbench/control_plane/src/app/projects/components/TimelineView.tsx index c19858c59..23eab61e1 100644 --- a/workbench/control_plane/src/app/projects/components/TimelineView.tsx +++ b/workbench/control_plane/src/app/projects/components/TimelineView.tsx @@ -28,7 +28,7 @@ import Button from "@/components/ui/Button"; import { useMemo, useState } from "react"; import type { TaskRow } from "../lib/api"; -import { cardChips } from "../lib/card"; +import { visibleChips } from "../lib/card"; import { dayKey, shiftDay } from "../lib/calendar"; import { type Edge, @@ -52,6 +52,8 @@ interface Props { links: Edge[]; undated: number; truncated: boolean; + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; today?: string; onSelect: (task: TaskRow) => void; onLink: (blockerId: string, blockedId: string) => void; @@ -64,6 +66,7 @@ export function TimelineView({ undated, truncated, today, + shownFields, onSelect, onLink, onRefuse, @@ -340,7 +343,7 @@ export function TimelineView({ /> ) : null} {row.task.title} - + {/* The link handle. Only on a real bar: a derived one has no dates of its own, so a dependency drawn from diff --git a/workbench/control_plane/src/app/projects/lib/card.test.ts b/workbench/control_plane/src/app/projects/lib/card.test.ts index 20dcadf3a..198383d76 100644 --- a/workbench/control_plane/src/app/projects/lib/card.test.ts +++ b/workbench/control_plane/src/app/projects/lib/card.test.ts @@ -9,8 +9,18 @@ import { describe, expect, it } from "vitest"; +import { taskMeta } from "@/lib/taskCard"; + import type { TaskRow } from "./api"; -import { cardChips, taskDeepLink, taskFacts, taskRef } from "./card"; +import { + CHIP_FIELD, + cardChips, + taskDeepLink, + taskFacts, + taskRef, + visibleChips, +} from "./card"; +import { DEFAULT_SHOWN } from "./shownFields"; const NOW = Date.parse("2026-08-07T12:00:00Z"); const hours = (n: number) => new Date(NOW + n * 3_600_000).toISOString(); @@ -99,6 +109,65 @@ describe("cardChips", () => { }); }); +describe("visibleChips — the shown-fields gate (WS-27x)", () => { + // A row that earns every chip the list endpoint can produce. + const loaded = row({ + due_at: hours(-2), + subtasks: { done: 1, total: 3 }, + blocked_by_count: 1, + tags: ["ops"], + }); + + it("draws every earned chip under the default shown set", () => { + // The gate is a VISIBILITY layer: with the defaults it must change + // nothing about what a card drew before shown-fields existed. + expect(visibleChips(loaded, DEFAULT_SHOWN, NOW)).toEqual( + cardChips(loaded, NOW), + ); + }); + + it("silences exactly the chip whose field was hidden", () => { + const shown = DEFAULT_SHOWN.filter((key) => key !== "due_at"); + expect(visibleChips(loaded, shown, NOW).map((c) => c.key)).toEqual([ + "blocked", + "subtasks", + "tags", + ]); + }); + + it("produces no chips at all when every field is hidden", () => { + // "This view surfaces nothing" and "this task earned nothing" must read + // identically — no placeholder, no dimmed chip. + expect(visibleChips(loaded, [], NOW)).toEqual([]); + }); + + it("keeps the fact layer intact — gating filters, never re-derives", () => { + const [chip] = visibleChips(loaded, ["blocked"], NOW); + expect(chip).toEqual(cardChips(loaded, NOW)[0]); + }); + + it("maps every chip kind taskMeta can emit onto a field key", () => { + // An unmapped chip kind would bypass the gate silently. Derived from + // `taskMeta` itself over fully-loaded facts, so a chip added there + // without a mapping here fails loudly. + const everyChip = taskMeta( + { + dueAt: hours(-2), + subtasks: { done: 1, total: 2 }, + blockedByCount: 1, + tagCount: 1, + attachmentCount: 1, + estimateMins: 30, + }, + NOW, + ).map((c) => c.key); + expect(everyChip.length).toBeGreaterThanOrEqual(6); + for (const key of everyChip) { + expect(CHIP_FIELD[key], `chip '${key}' has no shown-field mapping`).toBeDefined(); + } + }); +}); + describe("taskRef", () => { // WS-27w item 6 — one formatter, three surfaces (board, list, panel). it("formats the per-root number as the id people quote", () => { diff --git a/workbench/control_plane/src/app/projects/lib/card.ts b/workbench/control_plane/src/app/projects/lib/card.ts index c993b20dc..5a4f26268 100644 --- a/workbench/control_plane/src/app/projects/lib/card.ts +++ b/workbench/control_plane/src/app/projects/lib/card.ts @@ -32,6 +32,46 @@ export function cardChips(task: TaskRow, nowMs?: number): MetaChip[] { return taskMeta(taskFacts(task), nowMs); } +/** + * WS-27x — which shown-field key each chip kind renders under. + * + * The VISIBILITY layer over `taskMeta`'s fact layer: `taskCard.ts` stays the + * one place that decides which chips a task has *earned*, and this mapping is + * the one place that decides which of them the view's `shown_fields` lets + * through. Keys are `lib/shownFields.ts`'s vocabulary — the same source the + * table's columns read, so hiding a field silences its chip on every surface + * at once. + * + * Exported so a test can assert every chip `taskMeta` can emit is mapped — + * an unmapped chip kind would silently bypass the gate. + */ +export const CHIP_FIELD: Record = { + blocked: "blocked", + due: "due_at", + subtasks: "subtasks", + tags: "tags", + attachments: "attachments", + estimate: "estimate", +}; + +/** + * The chips one row has earned AND the view chose to show. + * + * A chip whose field is not shown produces nothing — not a dimmed chip, not a + * placeholder — because "this view does not surface due dates" and "this task + * has no due date" must read identically, exactly as `taskMeta`'s + * a-zero-earns-no-chip rule already treats absence. + */ +export function visibleChips( + task: TaskRow, + shownFields: readonly string[], + nowMs?: number +): MetaChip[] { + return cardChips(task, nowMs).filter((chip) => + shownFields.includes(CHIP_FIELD[chip.key] ?? chip.key) + ); +} + /** * WS-27w item 6 — the human task id, formatted in ONE place. * diff --git a/workbench/control_plane/src/app/projects/lib/grouping.test.ts b/workbench/control_plane/src/app/projects/lib/grouping.test.ts index 806c70761..b3d8155b8 100644 --- a/workbench/control_plane/src/app/projects/lib/grouping.test.ts +++ b/workbench/control_plane/src/app/projects/lib/grouping.test.ts @@ -22,6 +22,7 @@ import { toConfig, toQuery, } from "./grouping"; +import { DEFAULT_SHOWN } from "./shownFields"; const status = (id: string, name: string, position: number): StatusRow => ({ id, @@ -172,6 +173,7 @@ describe("saved view config", () => { filters, groupBy: "assignee", lanes: NO_LANES, + shownFields: [...DEFAULT_SHOWN], }); }); @@ -236,6 +238,7 @@ describe("swimlane state in a saved view (WS-27y)", () => { filters: EMPTY_FILTERS, groupBy: "status", lanes, + shownFields: [...DEFAULT_SHOWN], }); }); @@ -295,6 +298,64 @@ describe("swimlane state in a saved view (WS-27y)", () => { }); }); +describe("shown fields in a saved view (WS-27x)", () => { + it("round-trips a non-default set", () => { + const shown = ["status", "tags", "custom.budget"]; + expect(fromConfig(toConfig(EMPTY_FILTERS, "status", NO_LANES, shown))).toEqual({ + filters: EMPTY_FILTERS, + groupBy: "status", + lanes: NO_LANES, + shownFields: shown, + }); + }); + + it("stores nothing for the default set, so untouched views stay byte-identical", () => { + // Same rule as lane state: a view saved before shown-fields existed and + // one saved after with untouched columns must be the same bytes. + expect(toConfig(EMPTY_FILTERS, "status", NO_LANES, [...DEFAULT_SHOWN])).toEqual( + toConfig(EMPTY_FILTERS, "status") + ); + }); + + it("compares against the default as a SET, not a sequence", () => { + // Toggling a field off and back on reorders the list; that is not a + // change somebody made to the view. + const reordered = [...DEFAULT_SHOWN].reverse(); + expect(toConfig(EMPTY_FILTERS, "status", NO_LANES, reordered)).not.toHaveProperty( + "shown_fields" + ); + }); + + it("stores an explicitly emptied set — hiding everything is a choice", () => { + const config = toConfig(EMPTY_FILTERS, "status", NO_LANES, []); + expect(config.shown_fields).toEqual([]); + expect(fromConfig(config).shownFields).toEqual([]); + }); + + it("reads an old config with no shown_fields as the default set", () => { + expect(fromConfig({ filters: {}, group_by: "status" }).shownFields).toEqual([ + ...DEFAULT_SHOWN, + ]); + }); + + it("drops junk keys from a hand-edited config", () => { + // Same discipline the server applies (`normalise_view_config`): unknown + // and non-string keys dropped, duplicates collapsed, `custom.` alone + // names nothing. + expect( + fromConfig({ + shown_fields: ["status", "phase", 7, "custom.", "custom.budget", "status"], + }).shownFields + ).toEqual(["status", "custom.budget"]); + }); + + it("reads a non-list shown_fields as absent, never as hidden-everything", () => { + expect(fromConfig({ shown_fields: "status,tags" }).shownFields).toEqual([ + ...DEFAULT_SHOWN, + ]); + }); +}); + describe("groupTasks by tag (WS-27m)", () => { it("puts a task with three tags in all three columns", () => { // Same reason as two assignees: it genuinely belongs to each, and picking @@ -348,6 +409,7 @@ describe("tag filters in the query", () => { filters, groupBy: "tag", lanes: NO_LANES, + shownFields: [...DEFAULT_SHOWN], }); }); diff --git a/workbench/control_plane/src/app/projects/lib/grouping.ts b/workbench/control_plane/src/app/projects/lib/grouping.ts index 9213f04fe..0ab0d6e5f 100644 --- a/workbench/control_plane/src/app/projects/lib/grouping.ts +++ b/workbench/control_plane/src/app/projects/lib/grouping.ts @@ -12,6 +12,11 @@ */ import type { StatusRow, TaskRow } from "./api"; +import { + DEFAULT_SHOWN, + sameFieldSet, + sanitizeShownFields, +} from "./shownFields"; export type GroupBy = | "status" @@ -109,6 +114,8 @@ export function fromConfig(config: unknown): { filters: Filters; groupBy: GroupBy; lanes: BoardLanes; + /** WS-27x — the fields this view shows. Defaulted when nothing was stored. */ + shownFields: string[]; } { const raw = (config ?? {}) as Record; const stored = (raw.filters ?? {}) as Record; @@ -148,6 +155,11 @@ export function fromConfig(config: unknown): { : [], showEmptyLanes: raw.show_empty_lanes === true, }, + // WS-27x — a set of known field keys (`shownFields.sanitizeShownFields` + // owns the discipline: unknown/non-string dropped, duplicates collapsed). + // ABSENT means the default set; an explicitly stored `[]` means every + // column hidden — collapsing the two would un-hide a deliberate choice. + shownFields: sanitizeShownFields(raw.shown_fields) ?? [...DEFAULT_SHOWN], }; } @@ -163,7 +175,8 @@ export function fromConfig(config: unknown): { export function toConfig( filters: Filters, groupBy: GroupBy, - lanes: BoardLanes = NO_LANES + lanes: BoardLanes = NO_LANES, + shownFields: readonly string[] = DEFAULT_SHOWN ): Record { const stored: Record = {}; if (filters.q.trim()) stored.q = filters.q.trim(); @@ -183,6 +196,15 @@ export function toConfig( if (lanes.collapsedLanes.length) config.collapsed_lanes = lanes.collapsedLanes; if (lanes.showEmptyLanes) config.show_empty_lanes = true; } + // WS-27x — stored only when it differs from the default SET, so a view that + // never touched its columns stays byte-identical to one saved before + // shown-fields existed. Set comparison, not sequence: order is presentation + // the vocabulary owns (`table.tableColumns`), and a toggle-off-toggle-on + // must not dirty a config it did not change. An empty list IS stored — + // "every column hidden" is a choice, not the default. + if (!sameFieldSet(shownFields, DEFAULT_SHOWN)) { + config.shown_fields = sanitizeShownFields([...shownFields]) ?? []; + } return config; } diff --git a/workbench/control_plane/src/app/projects/lib/shownFields.test.ts b/workbench/control_plane/src/app/projects/lib/shownFields.test.ts new file mode 100644 index 000000000..9bbba1c00 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/shownFields.test.ts @@ -0,0 +1,93 @@ +/** + * WS-27x — the shown-fields vocabulary and its value discipline. + * + * The claims worth pinning are the seams: the vocabulary this file exports is + * the ONE source both the table's columns and the chip gate read, its keys + * are mirrored by the gateway's `filters.SHOWN_FIELDS`, and "absent" must + * stay distinguishable from "explicitly empty" or a deliberate hide-all view + * un-hides itself on the next apply. + */ + +import { describe, expect, it } from "vitest"; + +import { + CUSTOM_FIELD_PREFIX, + DEFAULT_SHOWN, + FIELD_KEYS, + FIELD_LABELS, + customFieldKey, + isFieldKey, + sameFieldSet, + sanitizeShownFields, + toggleField, +} from "./shownFields"; + +describe("the vocabulary", () => { + it("labels every core key, so the picker can never render a bare slug", () => { + for (const key of FIELD_KEYS) { + expect(FIELD_LABELS[key], `${key} has no label`).toBeTruthy(); + } + }); + + it("defaults to a subset of the vocabulary", () => { + for (const key of DEFAULT_SHOWN) expect(isFieldKey(key)).toBe(true); + }); + + it("accepts custom fields by shape — and refuses the bare prefix", () => { + expect(isFieldKey("custom.budget")).toBe(true); + expect(isFieldKey(CUSTOM_FIELD_PREFIX)).toBe(false); + expect(isFieldKey("customer")).toBe(false); + expect(isFieldKey(7)).toBe(false); + }); + + it("spells a custom field the way the timeline already does", () => { + // `patch_task` files a custom edit as `custom.` — one spelling + // across the app, not a second one invented here. + expect(customFieldKey({ field_key: "budget" })).toBe("custom.budget"); + }); +}); + +describe("sanitizeShownFields", () => { + it("distinguishes absent from explicitly empty", () => { + // Absent = the default set (the caller's to apply); [] = every column + // hidden, a deliberate choice that must survive the round trip. + expect(sanitizeShownFields(undefined)).toBeNull(); + expect(sanitizeShownFields("status,tags")).toBeNull(); + expect(sanitizeShownFields({})).toBeNull(); + expect(sanitizeShownFields([])).toEqual([]); + }); + + it("drops unknown and non-string keys, keeps the rest in order", () => { + expect( + sanitizeShownFields(["status", "phase", 7, null, "due_at", "custom.budget"]), + ).toEqual(["status", "due_at", "custom.budget"]); + }); + + it("collapses duplicates to the first appearance", () => { + expect(sanitizeShownFields(["tags", "status", "tags"])).toEqual([ + "tags", + "status", + ]); + }); +}); + +describe("sameFieldSet", () => { + it("compares as a SET — order is the vocabulary's, not the list's", () => { + expect(sameFieldSet(["status", "tags"], ["tags", "status"])).toBe(true); + expect(sameFieldSet(["status"], ["status", "tags"])).toBe(false); + expect(sameFieldSet([], [])).toBe(true); + }); +}); + +describe("toggleField", () => { + it("adds an absent field and removes a present one", () => { + expect(toggleField(["status"], "tags")).toEqual(["status", "tags"]); + expect(toggleField(["status", "tags"], "tags")).toEqual(["status"]); + }); + + it("does not mutate the input", () => { + const shown = ["status"]; + toggleField(shown, "tags"); + expect(shown).toEqual(["status"]); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/shownFields.ts b/workbench/control_plane/src/app/projects/lib/shownFields.ts new file mode 100644 index 000000000..db5af52a1 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/shownFields.ts @@ -0,0 +1,128 @@ +/** + * Projects · the shown-fields contract (WS-27x). + * + * ONE vocabulary, defined here and read by both consumers: the table's columns + * AND the chip gate (`card.visibleChips`). The whole reason the spreadsheet + * layout and the chip row shipped as one ticket is that the column set IS the + * contract — a field hidden from the table must also earn no chip, and two + * lists of "the fields a view shows" would disagree within a release. + * + * The gateway mirrors this set in `filters.SHOWN_FIELDS` + * (`normalise_view_config` drops keys it does not know), so a key added here + * without being added there is a preference the server silently strips on the + * next save. The round-trip tests on both sides exist to make that loud. + * + * **`shown_fields` is a SET, not a sequence.** Column order is the + * vocabulary's own (core keys in declaration order, then the project's custom + * fields in their registry order — `table.tableColumns`), never the stored + * list's, so a hand-shuffled config cannot make two viewers' tables disagree + * about where the Status column is. + */ + +export const FIELD_KEYS = [ + "status", + "assignees", + "start_date", + "due_at", + "importance", + "subtasks", + "blocked", + "tags", + "attachments", + "estimate", + "created_at", +] as const; + +export type FieldKey = (typeof FIELD_KEYS)[number]; + +export const FIELD_LABELS: Record = { + status: "Status", + assignees: "Assignees", + start_date: "Start", + due_at: "Due", + importance: "Priority", + subtasks: "Subtasks", + blocked: "Blocked", + tags: "Tags", + attachments: "Files", + estimate: "Estimate", + created_at: "Created", +}; + +/** + * A project's custom fields ride the same list as `custom.` — the + * exact spelling `patch_task` already files a custom edit under on the + * timeline, so there is one way to name a custom field across the app. + * + * Checked by SHAPE rather than against a registry, on both sides of the wire: + * `shown_fields` is normalised by pure functions (here and in `filters.py`) + * that do not hold the project's field definitions, and a view must survive a + * field being deleted after it was saved — the table simply has no column to + * draw for it, which is the honest rendering. + */ +export const CUSTOM_FIELD_PREFIX = "custom."; + +/** The set a view with no stored `shown_fields` means. */ +export const DEFAULT_SHOWN: readonly string[] = [ + "status", + "assignees", + "due_at", + "importance", + "subtasks", + "blocked", + "tags", +]; + +/** Whether `key` is a field a view may show — core key or a `custom.`. */ +export function isFieldKey(key: unknown): key is string { + if (typeof key !== "string") return false; + if ((FIELD_KEYS as readonly string[]).includes(key)) return true; + return ( + key.startsWith(CUSTOM_FIELD_PREFIX) && key.length > CUSTOM_FIELD_PREFIX.length + ); +} + +/** How a custom field definition spells itself in a `shown_fields` list. */ +export function customFieldKey(def: { field_key: string }): string { + return `${CUSTOM_FIELD_PREFIX}${def.field_key}`; +} + +/** + * A stored config value → the shown list, or `null` when nothing was stored. + * + * `null`, not the default: "absent" and "explicitly empty" are different + * facts. A view saved before shown-fields existed must read as the default + * set, while a view whose owner hid every column must come back with every + * column hidden — collapsing the two would un-hide someone's deliberate + * choice on the next apply. Unknown and non-string keys are dropped + * (hand-edits, or a newer client's vocabulary), duplicates collapse to the + * first appearance. + */ +export function sanitizeShownFields(raw: unknown): string[] | null { + if (!Array.isArray(raw)) return null; + const seen = new Set(); + const out: string[] = []; + for (const key of raw) { + if (!isFieldKey(key) || seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out; +} + +/** Set equality — order is presentation the vocabulary owns, not the list. */ +export function sameFieldSet( + a: readonly string[], + b: readonly string[] +): boolean { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((key) => set.has(key)); +} + +/** Toggle one field in or out of the shown set. */ +export function toggleField(shown: readonly string[], key: string): string[] { + return shown.includes(key) + ? shown.filter((k) => k !== key) + : [...shown, key]; +} diff --git a/workbench/control_plane/src/app/projects/lib/table.test.ts b/workbench/control_plane/src/app/projects/lib/table.test.ts new file mode 100644 index 000000000..696a83ae5 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/table.test.ts @@ -0,0 +1,233 @@ +/** + * WS-27x — the spreadsheet layout's row and column model. + * + * The three claims that decide whether the table is trustworthy: the columns + * a shown-field set produces (and their canonical order), the sub-task + * indentation model over `parent_task_id` (a filtered-out parent must not + * swallow its children), and the header-sort mapping onto the sort keys the + * gateway actually accepts — an unknown key there is a 422, not a fallback. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import type { FieldDef } from "./customFields"; +import { DEFAULT_SHOWN, FIELD_KEYS } from "./shownFields"; +import { + IMPORTANCE_OPTIONS, + TASK_SORT_KEYS, + customKeyOf, + importanceLabel, + nextSort, + sortQuery, + tableColumns, + treeRows, +} from "./table"; + +const def = (field_key: string, over: Partial = {}): FieldDef => ({ + id: `f-${field_key}`, + project_id: "p1", + field_key, + name: field_key, + field_type: "text", + options: [], + position: 0, + ...over, +}); + +describe("the sort mapping mirrors the gateway", () => { + it("matches core.py TASK_SORTS key for key", () => { + // Read from the gateway source, not restated: a key added or removed + // there without this mirror moving is a header click that answers 422. + const source = readFileSync( + resolve( + __dirname, + "../../../../../../apps/services/gateway/gateway/routes/projects/core.py" + ), + "utf-8" + ); + const block = source.match(/TASK_SORTS: dict\[str, str\] = \{([\s\S]*?)\n\}/); + expect(block, "core.py no longer declares TASK_SORTS").toBeTruthy(); + const serverKeys = [...block![1].matchAll(/^\s*"([a-z_]+)":/gm)].map( + (m) => m[1] + ); + expect([...serverKeys].sort()).toEqual([...TASK_SORT_KEYS].sort()); + }); + + it("only ever emits sort keys the server accepts", () => { + const shownEverything = [...FIELD_KEYS]; + for (const column of tableColumns(shownEverything, [])) { + if (column.sortKey !== null) { + expect(TASK_SORT_KEYS).toContain(column.sortKey); + } + } + }); + + it("gives a custom column no sort — there is no server ordering for it", () => { + const columns = tableColumns(["custom.budget"], [def("budget")]); + expect(columns).toHaveLength(1); + expect(columns[0].sortKey).toBeNull(); + }); +}); + +describe("nextSort — one header, three states", () => { + it("cycles unsorted → asc → desc → back to the view's own order", () => { + const asc = nextSort(null, "due_at"); + expect(asc).toEqual({ key: "due_at", dir: "asc" }); + const desc = nextSort(asc, "due_at"); + expect(desc).toEqual({ key: "due_at", dir: "desc" }); + // The third click matters: without it the table can never return to the + // board's hand-arranged `sortForView` order. + expect(nextSort(desc, "due_at")).toBeNull(); + }); + + it("starts a different column ascending, whatever the current state", () => { + expect(nextSort({ key: "due_at", dir: "desc" }, "status")).toEqual({ + key: "status", + dir: "asc", + }); + }); + + it("ignores a click on an unsortable header", () => { + const current = { key: "due_at", dir: "asc" } as const; + expect(nextSort(current, null)).toBe(current); + }); +}); + +describe("sortQuery", () => { + it("emits the existing wire parameters, and nothing when unsorted", () => { + expect(sortQuery({ key: "status", dir: "desc" })).toEqual({ + sort: "status", + direction: "desc", + }); + expect(sortQuery(null)).toEqual({}); + }); +}); + +describe("tableColumns", () => { + it("draws the default set in the vocabulary's order", () => { + expect(tableColumns(DEFAULT_SHOWN, []).map((c) => c.key)).toEqual([ + "status", + "assignees", + "due_at", + "importance", + "subtasks", + "blocked", + "tags", + ]); + }); + + it("ignores the stored list's order — shown_fields is a set", () => { + // A hand-shuffled config must not move the Status column. + const shuffled = [...DEFAULT_SHOWN].reverse(); + expect(tableColumns(shuffled, [])).toEqual(tableColumns(DEFAULT_SHOWN, [])); + }); + + it("appends custom columns after the core ones, in registry order", () => { + const defs = [def("b", { position: 2, name: "B" }), def("a", { position: 1, name: "A" })]; + const columns = tableColumns(["status", "custom.b", "custom.a"], defs); + expect(columns.map((c) => c.key)).toEqual(["status", "custom.a", "custom.b"]); + expect(columns[1].label).toBe("A"); + expect(columns[1].def).toBe(defs[1]); + }); + + it("draws no column for a shown custom field whose definition was deleted", () => { + // The view outlived the field. A header with no data and no editor under + // it is a column of nothing. + expect(tableColumns(["status", "custom.gone"], [])).toHaveLength(1); + }); + + it("draws nothing at all for an emptied set", () => { + expect(tableColumns([], [def("a")])).toEqual([]); + }); +}); + +describe("customKeyOf", () => { + it("unwraps the prefix and refuses everything else", () => { + expect(customKeyOf("custom.budget")).toBe("budget"); + expect(customKeyOf("status")).toBeNull(); + }); +}); + +describe("importance vocabulary", () => { + it("treats 0 as Low, never as unset — it is falsy", () => { + expect(importanceLabel(0)).toBe("Low"); + expect(importanceLabel(null)).toBe(""); + expect(importanceLabel(3)).toBe("Urgent"); + }); + + it("offers an explicit unset row so the select can be emptied", () => { + expect(IMPORTANCE_OPTIONS[0]).toEqual({ value: "", label: "No priority" }); + }); +}); + +// ── the indentation model ─────────────────────────────────────────────────── + +const t = (id: string, parent?: string | null) => ({ + id, + parent_task_id: parent ?? null, +}); + +const NONE: ReadonlySet = new Set(); + +describe("treeRows — sub-tasks indent under an on-page parent", () => { + it("nests children directly under their parent, depth-first", () => { + const rows = treeRows([t("a"), t("b"), t("a1", "a"), t("a1x", "a1")], NONE); + expect(rows.map((r) => [r.task.id, r.depth])).toEqual([ + ["a", 0], + ["a1", 1], + ["a1x", 2], + ["b", 0], + ]); + }); + + it("counts direct children so the caret knows when to draw", () => { + const rows = treeRows([t("a"), t("a1", "a"), t("a2", "a")], NONE); + expect(rows[0].childCount).toBe(2); + expect(rows[1].childCount).toBe(0); + }); + + it("keeps the incoming order within each level — the caller sorted it", () => { + const rows = treeRows([t("b"), t("a"), t("b2", "b"), t("b1", "b")], NONE); + expect(rows.map((r) => r.task.id)).toEqual(["b", "b2", "b1", "a"]); + }); + + it("surfaces a sub-task whose parent is not on the page, flat", () => { + // The filter said "show this task"; hiding it because its parent did not + // qualify would make a filtered table lose rows silently. + const rows = treeRows([t("orphan", "elsewhere"), t("a")], NONE); + expect(rows.map((r) => [r.task.id, r.depth])).toEqual([ + ["orphan", 0], + ["a", 0], + ]); + }); + + it("hides a collapsed parent's whole subtree, grandchildren included", () => { + const rows = treeRows( + [t("a"), t("a1", "a"), t("a1x", "a1"), t("b")], + new Set(["a"]) + ); + expect(rows.map((r) => r.task.id)).toEqual(["a", "b"]); + }); + + it("collapsing a mid-level node keeps its siblings", () => { + const rows = treeRows( + [t("a"), t("a1", "a"), t("a2", "a"), t("a1x", "a1")], + new Set(["a1"]) + ); + expect(rows.map((r) => r.task.id)).toEqual(["a", "a1", "a2"]); + }); + + it("loses no rows to a parent cycle, and does not hang", () => { + // Impossible server-side (`assert_no_task_cycle`); a stale page must + // degrade to missing indentation, never to a hung tab or vanished work. + const rows = treeRows([t("x", "y"), t("y", "x")], NONE); + expect(rows.map((r) => r.task.id).sort()).toEqual(["x", "y"]); + }); + + it("treats a self-parented row as a root rather than recursing", () => { + expect(treeRows([t("a", "a")], NONE).map((r) => r.depth)).toEqual([0]); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/table.ts b/workbench/control_plane/src/app/projects/lib/table.ts new file mode 100644 index 000000000..02782722e --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/table.ts @@ -0,0 +1,214 @@ +/** + * Projects · the spreadsheet layout's row and column model (WS-27x). + * + * Pure functions, because the three decisions a table gets subtly wrong are + * each one assertion here rather than a screenshot: which columns a view's + * `shown_fields` produces (and in what order), how sub-tasks nest under a + * parent that is on the same page, and what a header click means for the sort + * the server is asked for. + * + * **Sorting is the SERVER's** — a header click maps to the sort keys + * `GET /projects/tasks` already accepts (`core.py TASK_SORTS`), never a + * client-side re-sort. Pagination happens in SQL, so a client sort would only + * ever reorder the page it can see; and `status` is a semantic sort + * (category rank then lane position, WS-27w) that the browser has no business + * re-deriving. + */ + +import type { TaskRow } from "./api"; +import type { FieldDef } from "./customFields"; +import { + CUSTOM_FIELD_PREFIX, + FIELD_KEYS, + FIELD_LABELS, + type FieldKey, + customFieldKey, +} from "./shownFields"; + +/** Mirrors the gateway's `TASK_SORTS` keys. An unknown key there is a 422. */ +export const TASK_SORT_KEYS = [ + "created_at", + "updated_at", + "due_at", + "importance", + "title", + "task_number", + "completed_at", + "status", +] as const; + +export type SortKey = (typeof TASK_SORT_KEYS)[number]; + +export interface TableSort { + key: SortKey; + dir: "asc" | "desc"; +} + +/** + * Which sort key a column's header click sends — only for columns whose field + * IS a `TASK_SORTS` key. The rest (assignees, tags, sub-task progress, custom + * fields…) have no server ordering and their headers are honest labels, not + * disabled buttons pretending otherwise. + */ +const COLUMN_SORTS: Partial> = { + status: "status", + due_at: "due_at", + importance: "importance", + created_at: "created_at", +}; + +/** + * One header click: unsorted → ascending → descending → back to the view's + * own order. The third state matters — a table that can never return to the + * board's hand-arranged order (`sortForView`) has quietly replaced it. + * Clicking a different column starts that column ascending. + */ +export function nextSort( + current: TableSort | null, + key: SortKey | null +): TableSort | null { + if (!key) return current; + if (!current || current.key !== key) return { key, dir: "asc" }; + if (current.dir === "asc") return { key, dir: "desc" }; + return null; +} + +/** The query parameters a sort adds to the tasks fetch — nothing when none. */ +export function sortQuery(sort: TableSort | null): Record { + return sort ? { sort: sort.key, direction: sort.dir } : {}; +} + +export interface TableColumn { + /** A `shownFields` key — core, or `custom.`. */ + key: string; + label: string; + /** What a header click sorts by, or `null` for an unsortable column. */ + sortKey: SortKey | null; + /** Present only on custom-field columns. */ + def?: FieldDef; +} + +/** + * The columns a shown-field set draws, in canonical order: core fields in the + * vocabulary's declaration order, then custom fields in the registry's own + * (`position`) order. The stored list is a SET (`shownFields.ts` says why), + * so its order contributes nothing here. + * + * A shown `custom.` with no surviving definition produces NO column: the + * field was deleted after the view was saved, and a header with no data and + * no editor under it is a column of nothing. + */ +export function tableColumns( + shown: readonly string[], + defs: readonly FieldDef[] +): TableColumn[] { + const wanted = new Set(shown); + const out: TableColumn[] = []; + for (const key of FIELD_KEYS) { + if (!wanted.has(key)) continue; + out.push({ key, label: FIELD_LABELS[key], sortKey: COLUMN_SORTS[key] ?? null }); + } + const orderedDefs = [...defs].sort( + (a, b) => a.position - b.position || a.name.localeCompare(b.name) + ); + for (const def of orderedDefs) { + const key = customFieldKey(def); + if (!wanted.has(key)) continue; + out.push({ key, label: def.name, sortKey: null, def }); + } + return out; +} + +/** + * The priority vocabulary, as the importance cell offers and reads it. + * `""` is the unset row — the PATCH sends `importance: null`, and a select + * can otherwise never be emptied once somebody has chosen something. + */ +export const IMPORTANCE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: "", label: "No priority" }, + { value: "3", label: "Urgent" }, + { value: "2", label: "High" }, + { value: "1", label: "Normal" }, + { value: "0", label: "Low" }, +]; + +/** A stored importance → its label. `0` is Low, never "unset" (it is falsy). */ +export function importanceLabel(value: number | null | undefined): string { + if (value === null || value === undefined) return ""; + return IMPORTANCE_OPTIONS.find((o) => o.value === String(value))?.label ?? String(value); +} + +/** The `field_key` a `custom.` column patches, for the cell editor. */ +export function customKeyOf(columnKey: string): string | null { + return columnKey.startsWith(CUSTOM_FIELD_PREFIX) + ? columnKey.slice(CUSTOM_FIELD_PREFIX.length) + : null; +} + +export interface TableRow { + task: T; + /** 0 for a top-level row; +1 per nesting level under an on-page parent. */ + depth: number; + /** How many DIRECT sub-task rows sit under this one (0 = no caret). */ + childCount: number; +} + +type TaskLike = { id: string; parent_task_id?: string | null }; + +/** + * One group's tasks → the rows the table draws, sub-tasks indented under + * their parent. + * + * The same self-FK the server's `tree.py` walks for projects: hierarchy is + * `parent_task_id`, nothing else. A task whose parent is NOT in this list — + * filtered out, on another page, or in another group — renders at the top + * level rather than disappearing: the filter said "show this task", and + * hiding it because its parent did not qualify would make a filtered table + * lose rows silently. + * + * Within a nesting level the incoming order is preserved, because the caller + * has already ordered the list (server sort, or `sortForView`). + * + * `collapsed` hides a parent's whole subtree; collapse state is local to the + * component (per the ticket), so this only needs the set. + */ +export function treeRows( + tasks: readonly T[], + collapsed: ReadonlySet +): TableRow[] { + const present = new Set(tasks.map((t) => t.id)); + const children = new Map(); + const roots: T[] = []; + for (const task of tasks) { + const parent = task.parent_task_id; + if (parent && present.has(parent) && parent !== task.id) { + const bucket = children.get(parent); + if (bucket) bucket.push(task); + else children.set(parent, [task]); + } else { + roots.push(task); + } + } + + const out: TableRow[] = []; + // Iterative with an explicit visited set: the server forbids parent cycles + // (`assert_no_task_cycle`), but a stale page must degrade to missing + // indentation, never to a hung tab. + const visited = new Set(); + // `hidden` still traverses (marking visited) so a collapsed subtree's + // members are accounted for without being drawn — the rootless sweep below + // must not resurface them flat. + const walk = (task: T, depth: number, hidden: boolean) => { + if (visited.has(task.id)) return; + visited.add(task.id); + const kids = children.get(task.id) ?? []; + if (!hidden) out.push({ task, depth, childCount: kids.length }); + const hideKids = hidden || collapsed.has(task.id); + for (const kid of kids) walk(kid, depth + 1, hideKids); + }; + for (const root of roots) walk(root, 0, false); + // A parent cycle (impossible server-side, but this must not depend on it) + // leaves its members rootless; surface them flat rather than losing rows. + for (const task of tasks) if (!visited.has(task.id)) walk(task, 0, false); + return out; +} diff --git a/workbench/control_plane/src/app/projects/lib/tableCursor.test.ts b/workbench/control_plane/src/app/projects/lib/tableCursor.test.ts new file mode 100644 index 000000000..c37ad7ffa --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/tableCursor.test.ts @@ -0,0 +1,115 @@ +/** + * WS-27x — the table's cell cursor. + * + * The transitions that make a 2-D cursor feel solid, plus the ones its 1-D + * sibling (`cursor.test.ts`) already pinned — entry from nowhere, boundaries + * that clamp instead of wrap, unowned keys returning null — asserted again + * here because the two cursors must FEEL like the same grammar. + */ + +import { describe, expect, it } from "vitest"; + +import { NO_CELL, type TableCursor, clampCell, stepCell } from "./tableCursor"; + +const at = (row: number, col = 0, editing = false): TableCursor => ({ + row, + col, + editing, +}); + +// A 4-row, 3-column grid throughout. +const step = (state: TableCursor, key: string) => stepCell(4, 3, state, key); + +describe("plain arrows", () => { + it("walks all four directions", () => { + expect(step(at(1, 1), "ArrowDown")).toEqual(at(2, 1)); + expect(step(at(1, 1), "ArrowUp")).toEqual(at(0, 1)); + expect(step(at(1, 1), "ArrowRight")).toEqual(at(1, 2)); + expect(step(at(1, 1), "ArrowLeft")).toEqual(at(1, 0)); + }); + + it("enters at the top going down and at the bottom going up — like the row cursor", () => { + expect(step(NO_CELL, "ArrowDown")).toEqual(at(0, 0)); + expect(step(NO_CELL, "ArrowUp")).toEqual(at(3, 0)); + }); + + it("does not enter sideways — a column with no row is not a cell", () => { + expect(step(NO_CELL, "ArrowLeft")).toBeNull(); + expect(step(NO_CELL, "ArrowRight")).toBeNull(); + }); + + it("clamps at all four boundaries instead of wrapping", () => { + expect(step(at(3, 0), "ArrowDown")).toEqual(at(3, 0)); + expect(step(at(0, 0), "ArrowUp")).toEqual(at(0, 0)); + expect(step(at(0, 2), "ArrowRight")).toEqual(at(0, 2)); + expect(step(at(0, 0), "ArrowLeft")).toEqual(at(0, 0)); + }); +}); + +describe("Enter and Escape — the editing mode", () => { + it("Enter starts editing the cell under the ring", () => { + expect(step(at(1, 2), "Enter")).toEqual(at(1, 2, true)); + }); + + it("Enter from nowhere edits nothing", () => { + expect(step(NO_CELL, "Enter")).toBeNull(); + }); + + it("Escape cancels back to cursor mode on the same cell", () => { + expect(step(at(1, 2, true), "Escape")).toEqual(at(1, 2)); + }); + + it("Escape outside editing is not the cursor's key", () => { + // It belongs to whoever owns dismissal above (the panel, the palette). + expect(step(at(1, 2), "Escape")).toBeNull(); + }); + + it("while editing, every other key belongs to the editor", () => { + for (const key of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter", "a"]) { + expect(step(at(1, 1, true), key)).toBeNull(); + } + }); +}); + +describe("keys the cursor does not own", () => { + it("returns null so the caller never preventDefaults them", () => { + expect(step(at(1, 1), "a")).toBeNull(); + expect(step(at(1, 1), "Tab")).toBeNull(); + }); + + it("handles nothing on an empty grid, in either dimension", () => { + expect(stepCell(0, 3, at(0), "ArrowDown")).toBeNull(); + expect(stepCell(4, 0, at(0), "ArrowDown")).toBeNull(); + }); +}); + +describe("clampCell — the grid changed underneath", () => { + it("clamps a row past the end onto the last row", () => { + expect(clampCell(2, 3, at(5, 1)).row).toBe(1); + }); + + it("clamps a column past the edge — a field was just hidden", () => { + expect(clampCell(4, 2, at(1, 5)).col).toBe(1); + }); + + it("clears the cursor when no rows are left, and keeps none none", () => { + expect(clampCell(0, 3, at(2, 1)).row).toBe(-1); + expect(clampCell(4, 3, NO_CELL)).toEqual(NO_CELL); + }); + + it("cannot stay editing a cell whose row vanished", () => { + expect(clampCell(0, 3, at(2, 1, true)).editing).toBe(false); + }); + + it("returns the SAME state when nothing moved — callers skip a write", () => { + const state = at(1, 1); + expect(clampCell(4, 3, state)).toBe(state); + }); + + it("steps sensibly right after a clamp", () => { + // The reload race, same as the row cursor: cursor on row 5 of 6, the + // rows shrink to 3, then ArrowDown. Clamp then step must stay in bounds. + const clamped = clampCell(3, 3, at(5, 2)); + expect(stepCell(3, 3, clamped, "ArrowDown")?.row).toBe(2); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/tableCursor.ts b/workbench/control_plane/src/app/projects/lib/tableCursor.ts new file mode 100644 index 000000000..da21eb297 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/tableCursor.ts @@ -0,0 +1,96 @@ +/** + * Projects · the table's cell cursor (WS-27x). + * + * The 2-D sibling of `lib/cursor.ts`: arrows move a visible cell ring, Enter + * starts editing the cell, Esc cancels back to cursor mode. One pure + * transition — (rowCount, colCount, state, key) → next state — so the awkward + * cases are assertions: entry from nowhere, all four boundaries, a keystroke + * while an editor owns the keyboard, a cursor left standing on rows a reload + * just shortened. + * + * Where the two cursors overlap they behave identically, on purpose: + * entry from nowhere goes to the top on ArrowDown and the bottom on ArrowUp, + * boundaries clamp rather than wrap, unowned keys return `null` so the caller + * only ever `preventDefault`s a keystroke that was consumed, and clamping + * happens at READ time (`clampCell`) rather than by a state-syncing effect. + * It is deliberately NOT a fork of `stepCursor`: the row cursor carries + * selection sweeps the cell cursor does not have, and the cell cursor carries + * an editing mode the row cursor does not — grafting either onto the other + * would make both grammars mushy. + */ + +export interface TableCursor { + /** Index into the visible rows. `-1` = no active cell. */ + row: number; + /** Index into the visible columns. Meaningless while `row` is `-1`. */ + col: number; + /** An editor owns the cell. Arrows belong to it; only Esc comes back. */ + editing: boolean; +} + +export const NO_CELL: TableCursor = { row: -1, col: 0, editing: false }; + +/** + * Where the cursor stands after the grid changed under it: row clamped into + * the new bounds (or gone when nothing is left), column clamped likewise — + * a hidden column must not leave the ring floating past the table's edge. + * A cursor whose row vanished cannot still be editing anything. + */ +export function clampCell( + rowCount: number, + colCount: number, + cell: TableCursor +): TableCursor { + const row = rowCount === 0 || cell.row < 0 ? -1 : Math.min(cell.row, rowCount - 1); + const col = Math.max(0, Math.min(cell.col, Math.max(colCount - 1, 0))); + const editing = row >= 0 && cell.editing; + if (row === cell.row && col === cell.col && editing === cell.editing) return cell; + return { row, col, editing }; +} + +/** + * One keystroke. Returns `null` for keys the cursor does not own — including + * EVERY key except Escape while editing, because those belong to the editor. + */ +export function stepCell( + rowCount: number, + colCount: number, + state: TableCursor, + key: string +): TableCursor | null { + if (rowCount === 0 || colCount === 0) return null; + const at = clampCell(rowCount, colCount, state); + + if (at.editing) { + // Esc cancels back to cursor mode on the same cell; the editor's draft is + // the component's to throw away. + if (key === "Escape") return { ...at, editing: false }; + return null; + } + + if (key === "Enter") { + if (at.row < 0) return null; + return { ...at, editing: true }; + } + + switch (key) { + case "ArrowDown": + // From nowhere, down enters at the top and up at the bottom — the row + // nearest where the keystroke's attention already was (same rule as + // `stepCursor`). + return { ...at, row: at.row < 0 ? 0 : Math.min(at.row + 1, rowCount - 1) }; + case "ArrowUp": + return { + ...at, + row: at.row < 0 ? rowCount - 1 : Math.max(at.row - 1, 0), + }; + case "ArrowLeft": + if (at.row < 0) return null; + return { ...at, col: Math.max(at.col - 1, 0) }; + case "ArrowRight": + if (at.row < 0) return null; + return { ...at, col: Math.min(at.col + 1, colCount - 1) }; + default: + return null; + } +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index 97e3b63fc..de4c14270 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -37,6 +37,7 @@ import { ProjectTree } from "./components/ProjectTree"; import { CalendarView } from "./components/CalendarView"; import { SearchPalette } from "./components/SearchPalette"; import { TimelineView } from "./components/TimelineView"; +import { TableView } from "./components/TableView"; import { TaskBoard } from "./components/TaskBoard"; import { TaskList } from "./components/TaskList"; import { TaskPanel } from "./components/TaskPanel"; @@ -56,7 +57,9 @@ import { toConfig, toQuery, } from "./lib/grouping"; +import { DEFAULT_SHOWN } from "./lib/shownFields"; import { toggleLane } from "./lib/swimlanes"; +import { type TableSort, sortQuery } from "./lib/table"; import { allSelected as everySelected, buildRequest, @@ -69,7 +72,7 @@ import { import { fetchAccess } from "@/lib/access"; import { filterByCenter, flatten } from "./lib/tree"; -type ViewMode = "board" | "list" | "calendar" | "timeline"; +type ViewMode = "board" | "list" | "table" | "calendar" | "timeline"; /** An empty calendar window — the shape before anything has been fetched, and * the shape after a failure, so the view never renders a stale month. */ @@ -120,6 +123,11 @@ function ProjectsWorkspace() { const [groupBy, setGroupBy] = useState("status"); // WS-27y — the board's second axis plus its lane state; saved with a view. const [lanes, setLanes] = useState(NO_LANES); + // WS-27x — the view's shown fields (table columns AND the chip gate), saved + // with a view; and the table's header sort, which travels to the server as + // the existing `sort`/`direction` parameters (`TASK_SORTS` keys). + const [shownFields, setShownFields] = useState([...DEFAULT_SHOWN]); + const [tableSort, setTableSort] = useState(null); const [views, setViews] = useState([]); const [activeViewId, setActiveViewId] = useState(null); const [me, setMe] = useState(""); @@ -236,6 +244,9 @@ function ProjectsWorkspace() { include_subtree: true, page_size: 100, ...toQuery(filters), + // WS-27x — the table's header sort; {} when none, so every other + // surface keeps the endpoint's default ordering. + ...sortQuery(tableSort), }), ]); setStatuses(statusRes.rows); @@ -246,7 +257,7 @@ function ProjectsWorkspace() { setTasks([]); } }, - [filters] + [filters, tableSort] ); useEffect(() => { @@ -414,12 +425,16 @@ function ProjectsWorkspace() { } function applyView(view: ViewRow) { - const { filters: next, groupBy: nextGroup, lanes: nextLanes } = fromConfig( - view.config - ); + const { + filters: next, + groupBy: nextGroup, + lanes: nextLanes, + shownFields: nextShown, + } = fromConfig(view.config); setFilters(next); setGroupBy(nextGroup); setLanes(nextLanes); + setShownFields(nextShown); setActiveViewId(view.id); } @@ -429,7 +444,7 @@ function ProjectsWorkspace() { const created = await projectsApi.createView(selected.id, { name, view_type: mode, - config: toConfig(filters, groupBy, lanes), + config: toConfig(filters, groupBy, lanes, shownFields), // Above the seeded pair, so the drag handler keeps writing its order // into the project's original board rather than into a saved filter. position: SAVED_VIEW_POSITION + views.length, @@ -457,6 +472,12 @@ function ProjectsWorkspace() { setActiveViewId(null); } + // WS-27x — same rule for the shown-fields set: it is part of a view. + function changeShownFields(next: string[]) { + setShownFields(next); + setActiveViewId(null); + } + // Opening a task always resolves ITS project's statuses. From the board that // is the set already loaded; from My work it may be any project the member // is assigned into, so it is fetched. @@ -768,7 +789,7 @@ function ProjectsWorkspace() {
- {(["board", "list", "calendar", "timeline"] as ViewMode[]).map((m) => ( + {(["board", "list", "table", "calendar", "timeline"] as ViewMode[]).map((m) => ( + + +
+
+ ); +} diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 4bb162cc3..74ef1091e 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -23,6 +23,7 @@ import { watchersApi, } from "../lib/api"; import { taskDeepLink, taskRef } from "../lib/card"; +import { isAutomated } from "../lib/lifecycle"; import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; import { RepeatEditor } from "./RepeatEditor"; @@ -567,11 +568,25 @@ export function TaskPanel({
    {timeline.map((activity) => (
  1. -

    - {activity.created_by ?? "system"} - {activity.created_at - ? ` · ${new Date(activity.created_at).toLocaleString()}` - : ""} +

    + {/* WS-27z — an automated entry says so. The flag is the row's + meta.automation, written only by the workflow engine; a + sweep archiving a task must not read as a person did it. */} + {isAutomated(activity) ? ( + + + auto + + ) : null} + + {activity.created_by ?? "system"} + {activity.created_at + ? ` · ${new Date(activity.created_at).toLocaleString()}` + : ""} +

    {describe(activity, fields)}

  2. diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index 1a743b6d6..2217530d5 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -16,6 +16,13 @@ export interface ProjectRow { lead?: string | null; clickup_id?: string | null; clickup_kind?: string | null; + /** + * WS-27z — the lifecycle policy. ROOT-project settings (the subtree + * inherits); `null` months = that policy is off, which is the default. + */ + archive_after_months?: number | null; + close_after_months?: number | null; + timezone?: string | null; children?: ProjectRow[]; } @@ -244,6 +251,13 @@ export const projectsApi = { createProject: (payload: Record) => call("nodes", { method: "POST", body: JSON.stringify(payload) }), + /** WS-27z — root-project settings (lifecycle policy) ride the plain PATCH. */ + patchProject: (projectId: string, payload: Record) => + call(`nodes/${projectId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + createTask: (payload: Record) => call("tasks", { method: "POST", body: JSON.stringify(payload) }), diff --git a/workbench/control_plane/src/app/projects/lib/lifecycle.test.ts b/workbench/control_plane/src/app/projects/lib/lifecycle.test.ts new file mode 100644 index 000000000..bd473e23e --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/lifecycle.test.ts @@ -0,0 +1,46 @@ +/** + * Projects · lifecycle helpers (WS-27z). + * + * `isAutomated` is what makes an automated timeline entry LOOK automated — + * a sweep that archives a task must not read as a person having done it. The + * flag rides in `meta.automation`, written by the gateway's activity spine + * for every engine write and never for a human one, so the cases here are + * exactly the shapes the timeline endpoint can hand back. + */ + +import { describe, expect, it } from "vitest"; + +import { isAutomated, parseMonths } from "./lifecycle"; + +describe("isAutomated", () => { + it("reads the automation flag out of meta", () => { + expect(isAutomated({ meta: { automation: true } })).toBe(true); + }); + + it("is false for a human write — absent flag, absent meta, null meta", () => { + expect(isAutomated({ meta: { changes: [] } })).toBe(false); + expect(isAutomated({})).toBe(false); + expect(isAutomated({ meta: null })).toBe(false); + }); +}); + +describe("parseMonths", () => { + it("empty means the policy is off (null), not zero", () => { + expect(parseMonths("")).toEqual({ ok: true, value: null }); + expect(parseMonths(" ")).toEqual({ ok: true, value: null }); + }); + + it("a whole positive number passes", () => { + expect(parseMonths("3")).toEqual({ ok: true, value: 3 }); + expect(parseMonths(" 12 ")).toEqual({ ok: true, value: 12 }); + }); + + it("zero, negatives, fractions and words are refused — the gateway's rule", () => { + expect(parseMonths("0").ok).toBe(false); + expect(parseMonths("-1").ok).toBe(false); + expect(parseMonths("1.5").ok).toBe(false); + expect(parseMonths("soon").ok).toBe(false); + // Scientific notation is a number to `Number()` and not to a person. + expect(parseMonths("1e2").ok).toBe(false); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/lifecycle.ts b/workbench/control_plane/src/app/projects/lib/lifecycle.ts new file mode 100644 index 000000000..593e2e783 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/lifecycle.ts @@ -0,0 +1,45 @@ +/** + * Projects · lifecycle policy helpers (WS-27z). + * + * Two small pure functions, kept out of the components so they are testable + * without a DOM: + * + * - `isAutomated` — the one predicate the timeline renders automated entries + * distinctly by. It reads `meta.automation`, the flag every write made by + * the `/workflows` engine (the lifecycle sweep, `pm_task` nodes) carries; + * human writes never do. + * - `parseMonths` — the settings dialog's input contract, mirroring the + * gateway's validation (a whole number of months > 0, or empty = the + * policy is off) so a bad value is refused before the request instead of + * round-tripping for a 422. + */ + +/** Whether one timeline row was written by an automation. */ +export function isAutomated(activity: { + meta?: Record | null; +}): boolean { + return Boolean(activity.meta?.automation); +} + +export type MonthsParse = + | { ok: true; value: number | null } + | { ok: false; reason: string }; + +/** A months input box → the PATCH value (`null` = off), or a refusal. */ +export function parseMonths(input: string): MonthsParse { + const trimmed = input.trim(); + if (trimmed === "") return { ok: true, value: null }; + // `Number` alone accepts "1e2" and ""; the regex pins plain digits so the + // browser refuses exactly what the gateway would. + if (!/^\d+$/.test(trimmed)) { + return { ok: false, reason: "Months must be a whole number." }; + } + const value = Number(trimmed); + if (value <= 0) { + return { + ok: false, + reason: "Months must be greater than zero — leave it empty to turn the policy off.", + }; + } + return { ok: true, value }; +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index 97e3b63fc..be3e7c194 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -27,6 +27,7 @@ import { projectsApi, } from "./lib/api"; import { FieldManager } from "./components/FieldManager"; +import { LifecyclePolicy } from "./components/LifecyclePolicy"; import { TagManager } from "./components/TagManager"; import { BulkBar } from "./components/BulkBar"; import { FilterBar } from "./components/FilterBar"; @@ -136,6 +137,10 @@ function ProjectsWorkspace() { const [tags, setTags] = useState([]); const [managingTags, setManagingTags] = useState(false); + // WS-27z — the lifecycle-policy dialog. Root projects only: the policy is a + // root setting the whole subtree inherits, and the gateway 422s a child. + const [managingLifecycle, setManagingLifecycle] = useState(false); + // WS-27n — multi-select. `anchor` is the last card clicked without shift, // which is what a shift-click measures its range from. // WS-27q — the calendar is a WINDOW, not the paged task list, so it holds @@ -756,6 +761,17 @@ function ProjectsWorkspace() { Tags ) : null} + {selected && !mine && !selected.parent_project_id ? ( + + ) : null} - ); - } - - return ( -
    { - if (e.key === "Escape") { - setOpen(false); - setTitle(""); - setError(null); - } - e.stopPropagation(); - }} - onSubmit={(e) => { - e.preventDefault(); - const trimmed = title.trim(); - if (!trimmed || busy) return; - setBusy(true); - setError(null); - onAdd(trimmed) - .then(() => setTitle("")) - .catch((err) => setError(String((err as Error).message))) - .finally(() => setBusy(false)); - }} - > - setTitle(e.target.value)} - placeholder={label} - aria-label={label} - // Not `disabled` while busy — disabling blurs the input, and the - // next title should be typable the instant this one lands. - readOnly={busy} - /> - {error ?

    {error}

    : null} -
    - ); -} +export { QuickAdd } from "@/components/QuickAdd"; diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index 2edc2b901..fc2e7ee66 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -17,6 +17,7 @@ * value (`lib/quickAdd.ts` owns that mapping), and an arrow-key cursor walks * the rows — Shift extends the WS-27n selection, Enter opens the panel. */ +import Icon from "@/components/Icon"; import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; import { useMemo, useState } from "react"; @@ -68,6 +69,16 @@ export function TaskList({ const [cursor, setCursor] = useState(-1); const [anchor, setAnchor] = useState(null); const { flash, attach, scrollTo } = useFlash(); + // Group sections collapse, /tasks-style (TaskListGrouped's grammar): a + // chevron on the header, local state, the header row itself stays put. + const [folded, setFolded] = useState>(new Set()); + const toggleFold = (key: string) => + setFolded((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); const statusById = new Map(statuses.map((s) => [s.id, s])); const total = groups.reduce((sum, group) => sum + group.tasks.length, 0); @@ -84,14 +95,18 @@ export function TaskList({ const rows = useMemo(() => { const seen = new Set(); const out: string[] = []; - for (const group of sections) + for (const group of sections) { + // A folded section's rows are off-screen, so the cursor skips them — + // same rule the board applies to a collapsed lane. + if (folded.has(group.key)) continue; for (const task of group.tasks) if (!seen.has(task.id)) { seen.add(task.id); out.push(task.id); } + } return out; - }, [sections]); + }, [sections, folded]); const taskById = useMemo(() => { const map = new Map(); for (const group of sections) for (const task of group.tasks) map.set(task.id, task); @@ -190,7 +205,9 @@ export function TaskList({ {/* An empty status lane is kept on the board so a missing column reads as a missing state; a list has no columns, so an empty section is just a heading with nothing under it — `sections` dropped it. */} - {sections.map((group) => ( + {sections.map((group) => { + const isFolded = groupBy !== "none" && folded.has(group.key); + return ( {groupBy === "none" ? null : ( @@ -198,14 +215,30 @@ export function TaskList({ colSpan={columnCount} className="px-3 py-1.5 text-left text-xs font-medium text-foreground" > - {group.label} - - {group.tasks.length} - + {/* The /tasks group-header grammar (TaskListGrouped): + chevron to collapse, label, then the count as a pill — + so the two apps' grouped lists read identically. */} + )} - {group.tasks.map((task) => { + {isFolded ? null : group.tasks.map((task) => { const status = statusById.get(task.status_id); const atCursor = cursorAt >= 0 && rows[cursorAt] === task.id; return ( @@ -260,20 +293,24 @@ export function TaskList({ ); })} {/* WS-27y — the group's own capture box: a task added here lands - in THIS group, pre-filled by `quickAddPrefill`. */} - - - quickAdd(title, group.key)} - className="max-w-md" - /> - - + in THIS group, pre-filled by `quickAddPrefill`. Folded away + with the rows, exactly as /tasks folds its sections. */} + {isFolded ? null : ( + + + quickAdd(title, group.key)} + className="max-w-md" + /> + + + )} - ))} + ); + })} ); diff --git a/workbench/control_plane/src/app/projects/components/useFlash.ts b/workbench/control_plane/src/app/projects/components/useFlash.ts index aa5ebf3c4..0c7d7bace 100644 --- a/workbench/control_plane/src/app/projects/components/useFlash.ts +++ b/workbench/control_plane/src/app/projects/components/useFlash.ts @@ -1,88 +1,10 @@ -"use client"; - /** - * Projects · scroll-into-view + flash for a card that just landed (WS-27y). - * - * After any drop or quick-add, the moved/created card must be SEEN to land — - * scrolled into view and briefly tinted — or the gesture ends in silence and - * people re-do it. The awkward part is timing: at the moment of the gesture - * the card's element may not exist yet (a quick-add's card appears only after - * the reload; a cross-column drop remounts the card in its new column). So a - * flash is *pending* until the element shows up: `flash(id)` fires - * immediately when the node is on the page, and otherwise the ref callback - * fires it the moment React mounts it. The pending marker expires after a few - * seconds so a card re-mounted much later (a filter change) does not flash - * out of nowhere. + * Projects · the landing flash (WS-27y) — now the SHARED hook. * - * Refs and classList rather than state, deliberately: a flash must not - * re-render three hundred cards, and the animation itself is CSS - * (`flash.module.css`, theme tokens only). + * Moved (with `flash.module.css`) to `src/components/` when /tasks adopted + * the same post-drop announcement: one landing animation, two apps. This + * shim keeps every existing Projects import working unchanged. */ -import { useCallback, useRef } from "react"; - -import styles from "./flash.module.css"; - -const PENDING_MS = 3000; -const CLEAR_MS = 1600; - -export interface Flash { - /** Flash the element registered under `id`, now or when it next mounts. */ - flash: (id: string) => void; - /** Ref callback registering an element under `id`. */ - attach: (id: string) => (el: HTMLElement | null) => void; - /** Scroll to a registered element without flashing — the cursor's need. */ - scrollTo: (id: string) => void; -} - -export function useFlash(): Flash { - const els = useRef(new Map()); - const pending = useRef(null); - const expiry = useRef | null>(null); - - const run = useCallback((el: HTMLElement) => { - el.scrollIntoView({ block: "nearest", inline: "nearest" }); - // Remove-reflow-add restarts the animation when the same card flashes - // twice in a row (two quick-adds into one column). - el.classList.remove(styles.flash); - void el.offsetWidth; - el.classList.add(styles.flash); - setTimeout(() => el.classList.remove(styles.flash), CLEAR_MS); - }, []); - - const flash = useCallback( - (id: string) => { - // Pending survives an immediate run: a drop flashes the card where it - // stands AND re-flashes it if the reload remounts it in its new column. - pending.current = id; - if (expiry.current) clearTimeout(expiry.current); - expiry.current = setTimeout(() => { - pending.current = null; - }, PENDING_MS); - const el = els.current.get(id); - if (el) run(el); - }, - [run] - ); - - const attach = useCallback( - (id: string) => (el: HTMLElement | null) => { - if (el) { - els.current.set(id, el); - if (pending.current === id) { - pending.current = null; - run(el); - } - } else { - els.current.delete(id); - } - }, - [run] - ); - - const scrollTo = useCallback((id: string) => { - els.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, []); - - return { flash, attach, scrollTo }; -} +export { useFlash } from "@/components/useFlash"; +export type { Flash } from "@/components/useFlash"; diff --git a/workbench/control_plane/src/app/projects/lib/cursor.ts b/workbench/control_plane/src/app/projects/lib/cursor.ts index 5c1ac78a1..c02f43847 100644 --- a/workbench/control_plane/src/app/projects/lib/cursor.ts +++ b/workbench/control_plane/src/app/projects/lib/cursor.ts @@ -1,91 +1,15 @@ /** - * Projects · the keyboard cursor (WS-27y). + * Projects · the keyboard cursor (WS-27y) — now the SHARED implementation. * - * ArrowUp/ArrowDown walk an active row through the list or board in render - * order; Shift+Arrow extends the EXISTING selection model (WS-27n) from the - * cursor; Enter opens the row. All of it is one pure transition — - * (rows, state, key) → next state — so the awkward cases are assertions - * rather than manual testing: a cursor on a row a filter just removed, a - * shift-sweep started from nowhere, an arrow at the boundary. + * The logic (and its tests) moved to `src/lib/cursor.ts` when /tasks adopted + * the same cursor: one transition function, two apps. This shim keeps every + * existing Projects import working unchanged. * - * Selection semantics deliberately reuse `selection.range`, and are therefore - * ADDITIVE like the shift-click it extends: sweeping over rows adds them, and - * un-selecting is a click, exactly as it already was. A second removal - * grammar here would make the keyboard and the mouse disagree about what - * shift means. + * Note: `stepCursor`'s shift-sweep used to call `selection.range` directly; + * the shared module carries its own copy of that eight-line index walk (same + * contract, pinned by the same tests) because a shared lib must not import + * app code. `selection.range` remains the Projects selection model's own API. */ -import { range } from "./selection"; - -export interface CursorState { - /** Index into the visible rows. `-1` = no active row. */ - cursor: number; - /** Where the current shift-sweep started, or null outside a sweep. */ - anchor: number | null; - selection: ReadonlySet; -} - -export const NO_CURSOR: Pick = { - cursor: -1, - anchor: null, -}; - -export interface CursorNext extends CursorState { - /** The row id Enter asked to open, else null. */ - open: string | null; -} - -/** - * One keystroke. Returns `null` for keys the cursor does not own, so callers - * can `preventDefault` exactly when the key was consumed and never eat a - * keystroke that belonged to something else. - */ -export function stepCursor( - rows: readonly string[], - state: CursorState, - key: string, - shift: boolean -): CursorNext | null { - if (rows.length === 0) return null; - const cursor = clampCursor(rows.length, state.cursor); - - if (key === "Enter") { - if (cursor < 0) return null; - return { ...state, cursor, open: rows[cursor] }; - } - if (key !== "ArrowDown" && key !== "ArrowUp") return null; - - // From nowhere, ArrowDown enters at the top and ArrowUp at the bottom — - // the row nearest where the keystroke's attention already was. - const next = - key === "ArrowDown" - ? cursor < 0 - ? 0 - : Math.min(cursor + 1, rows.length - 1) - : cursor < 0 - ? rows.length - 1 - : Math.max(cursor - 1, 0); - - if (!shift) { - // A plain arrow ends any sweep; the selection itself is untouched, and is - // returned as the SAME set so callers can cheaply see nothing changed. - return { cursor: next, anchor: null, selection: state.selection, open: null }; - } - - // Shift: the sweep runs from where it started (or from the row the cursor - // was on; or, entering from nowhere, from the entry row itself) to the new - // cursor, and everything in between joins the selection. - const anchor = state.anchor ?? (cursor >= 0 ? cursor : next); - const selection = new Set(state.selection); - for (const id of range(rows, rows[anchor], rows[next])) selection.add(id); - return { cursor: next, anchor, selection, open: null }; -} - -/** - * Where the cursor lands after the rows changed under it: clamped into the - * new bounds, or gone when there is nothing left to stand on. - */ -export function clampCursor(rowCount: number, cursor: number): number { - if (rowCount === 0 || cursor < 0) return -1; - return Math.min(cursor, rowCount - 1); -} +export { NO_CURSOR, clampCursor, stepCursor } from "@/lib/cursor"; +export type { CursorNext, CursorState } from "@/lib/cursor"; diff --git a/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx b/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx index 0c1e67915..c8e237c2b 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx @@ -1,10 +1,15 @@ "use client"; +import { QuickAdd } from "@/components/QuickAdd"; +import { useFlash } from "@/components/useFlash"; +import { clampCursor, stepCursor } from "@/lib/cursor"; import { useCallback, useMemo, useState } from "react"; import { GtdItem, ViewKey } from "../lib/types"; import { useTaskStore } from "../lib/taskStore"; import { TaskCard } from "./TaskCard"; +import { dropRefusal } from "../lib/dropRules"; import { applySort, byManualOrder, statusColumnForItem } from "../lib/ordering"; +import { quickAddPrefill } from "../lib/quickAdd"; import { stageAccent } from "../lib/stageColors"; import { formatStatus } from "../lib/utils"; @@ -27,6 +32,9 @@ import { formatStatus } from "../lib/utils"; // The board is only offered for Next Actions (see ItemList `boardable`); other // views render list-only until their own status model is designed. +/** The cursor never carries a selection here — see the note in onKeyDown. */ +const EMPTY_SELECTION: ReadonlySet = new Set(); + export function TaskBoard({ items, stages, @@ -44,6 +52,8 @@ export function TaskBoard({ const sort = useTaskStore((s) => s.sort); const reorderItem = useTaskStore((s) => s.reorderItem); const updateItem = useTaskStore((s) => s.updateItem); + const quickAddNext = useTaskStore((s) => s.quickAddNext); + const openFocus = useTaskStore((s) => s.openFocus); // Multi-select for bulk archive/delete — works right on the board now. While // selecting, cards become selection toggles and drag is suppressed (a checkbox // and a drag handle on the same card would fight each other). @@ -71,6 +81,10 @@ export function TaskBoard({ const [overCol, setOverCol] = useState(null); // Exact gap ":" the card would drop into (manual mode only). const [dropAt, setDropAt] = useState(null); + // WS-27y backport: the keyboard cursor and the landing flash — the same + // shared machinery the Projects board runs (`@/lib/cursor`, `useFlash`). + const [cursor, setCursor] = useState(-1); + const { flash, attach, scrollTo } = useFlash(); // An unstaged task sits in the FIRST column of the axis. const firstStage = stageKeys[0]; @@ -97,6 +111,59 @@ export function TaskBoard({ return m; }, [items, columns, stageOf, sort]); + // The keyboard cursor's world: every card in render order (column by + // column), same as the Projects board walks its lanes. + const rows = useMemo(() => { + const out: string[] = []; + for (const c of columns) for (const i of byColumn.get(c.key) ?? []) out.push(i.id); + return out; + }, [columns, byColumn]); + + // Clamped at READ time rather than synced by an effect: the rows shrink + // under the cursor on every reload, and a state write per reload is exactly + // the cascading-render pattern the lint forbids. + const cursorAt = clampCursor(rows.length, cursor); + + function onKeyDown(event: React.KeyboardEvent) { + // A keystroke a control already consumed (a quick-add's Enter, a card's + // own Enter-to-open) is not the cursor's; nor is typing in an input. + if (event.defaultPrevented) return; + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]", + ) + ) + return; + // Plain cursor + Enter only. /tasks has no shift-range selection model + // (`selectedIds` is a bare toggle set with no anchor — see taskStore), so + // the shared cursor's shift-sweep stays dormant here rather than + // half-growing a second selection grammar on one surface. + const next = stepCursor( + rows, + { cursor: cursorAt, anchor: null, selection: EMPTY_SELECTION }, + event.key, + false, + ); + if (!next) return; + event.preventDefault(); + setCursor(next.cursor); + if (next.open) openFocus(next.open); + if (next.cursor >= 0) scrollTo(rows[next.cursor]); + } + + // WS-27y backport: dragging is always offered; a refused target explains + // itself while the card hovers (`lib/dropRules`), instead of the old + // silent snap-back. + const dragged = dragId ? items.find((i) => i.id === dragId) : undefined; + const refusalFor = (colKey: string): string | null => + dragged + ? dropRefusal({ + selectMode, + sortField: sort.field, + sameColumn: stageOf(dragged) === colKey, + }) + : null; + // Refile depends on the axis: // • Global board (columns = local STAGES): set `workflowStage` for both // LOCAL and SYNCED. For a synced task the backend translates the stage into @@ -118,6 +185,8 @@ export function TaskBoard({ }; // Drop onto a specific gap (index) within a column — reorder + re-file. + // The landed card scrolls into view and flashes (shared useFlash), so the + // gesture visibly ends where the card now lives. const dropAtIndex = (colKey: string, index: number) => { setDropAt(null); setOverCol(null); @@ -125,11 +194,14 @@ export function TaskBoard({ setDragId(null); if (!id) return; const dest = byManualOrder(byColumn.get(colKey) ?? []); + flash(id); reorderItem(id, dest, index, refileFor(colKey, id)); }; // Drop anywhere in a column (not on a card gap): keep the old semantics — - // in a field sort we can't rank, so just re-file the stage/status. + // in a field sort we can't rank, so just re-file the stage/status. The + // refused case (same column, field sort) already explained itself via the + // hover overlay; here it simply does nothing. const dropColumn = (colKey: string) => { setOverCol(null); setDropAt(null); @@ -141,31 +213,64 @@ export function TaskBoard({ if (manual) { // append to the end of the column const dest = byManualOrder(byColumn.get(colKey) ?? []); + flash(id); reorderItem(id, dest, dest.length, refileFor(colKey, id)); return; } - if (stageOf(item) === colKey) return; // no move + if (stageOf(item) === colKey) return; // refused — the overlay said why const refile = refileFor(colKey, id); - if (refile) updateItem(id, refile); + if (refile) { + flash(id); + updateItem(id, refile); + } + }; + + // Group-context quick-add (shared QuickAdd + this app's prefill): a task + // added at a column's foot is born a NEXT action IN that stage, then + // announces its landing with the same flash a drop gets. + const quickAdd = async (title: string, colKey: string) => { + const id = quickAddNext(title, quickAddPrefill("", colKey) ?? {}); + if (id) flash(id); }; return ( -
    +
    {columns.map((col, ci) => { const colItems = byColumn.get(col.key) ?? []; const isOver = overCol === col.key; + const refusal = isOver && dragged ? refusalFor(col.key) : null; const accent = stageAccent(col.label || col.key, ci, columns.length); return (
    { e.preventDefault(); setOverCol(col.key); }} + onDragOver={(e) => { + // preventDefault even when refusing — the browser must keep + // sending events or the overlay could never show; the refusal + // is enforced in the drop handlers, and the cursor says "no" + // via dropEffect. + e.preventDefault(); + e.dataTransfer.dropEffect = refusalFor(col.key) ? "none" : "move"; + setOverCol(col.key); + }} onDragLeave={() => setOverCol((c) => (c === col.key ? null : c))} onDrop={() => dropColumn(col.key)} className={[ - "flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-xl border bg-secondary/30", + "relative flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-xl border bg-secondary/30", isOver ? "border-primary bg-primary/5" : "border-border", ].join(" ")} > + {/* WS-27y backport: the refusal, said on the target while the + card hovers — same overlay grammar as the Projects board. */} + {refusal ? ( +
    + {refusal} +
    + ) : null} {/* accent cap so each stage column is identifiable at a glance */}
    dropAtIndex(col.key, idx)} /> )} -
    +
    = 0 && rows[cursorAt] === i.id + ? "ring-2 ring-ring" + : "", + ].join(" ")} + > )}
    + {/* WS-27y backport: the column's own capture box — a task added + here is born a NEXT action in THIS stage (shared QuickAdd + + lib/quickAdd prefill), and flashes where it lands. */} +
    + quickAdd(title, col.key)} + /> +
    ); })} diff --git a/workbench/control_plane/src/app/tasks/components/TaskCard.tsx b/workbench/control_plane/src/app/tasks/components/TaskCard.tsx index 1dd13a63e..7ccba933a 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskCard.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskCard.tsx @@ -1,11 +1,13 @@ "use client"; import Icon, { themedIcon } from "@/components/Icon"; +import { TaskMeta } from "@/components/TaskMeta"; import { useState } from "react"; import { GtdItem } from "../lib/types"; +import { gtdMetaChips } from "../lib/cardMeta"; import { useTaskStore } from "../lib/taskStore"; import { useCardActions } from "../lib/useCardActions"; -import { durationLabel, initials, isOverdue, relativeTime } from "../lib/utils"; +import { initials } from "../lib/utils"; import { contextAccent } from "../lib/contextColors"; import { SourceBadge } from "./SourceBadge"; import { PriorityBadge, SuggestionBadge } from "./PriorityControls"; @@ -64,8 +66,6 @@ export function TaskCard({ const project = item.projectId ? projects.find((p) => p.id === item.projectId) : undefined; - const overdue = isOverdue(item); - const atts = item.attachments?.length ?? 0; // Owners beyond the primary (shown as a "+N" on the avatar). const extraAssignees = Math.max(0, (item.assignees?.length ?? 0) - 1); @@ -148,38 +148,13 @@ export function TaskCard({ {item.energy} )} - {item.timeEstimateMins ? ( - - - {durationLabel(item.timeEstimateMins)} - - ) : null} - {item.dueAt && ( - - {overdue ? : } - {relativeTime(item.dueAt)} - - )} - {atts > 0 && ( - - - {atts} - - )} - {item.subtaskCount ? ( - - - {item.subtaskCount} - - ) : null} + {/* The shared facts — due/overdue, subtasks, attachments, estimate — in + the shared chip vocabulary (WS-27s): `gtdMetaChips` adapts GtdItem to + `taskMeta`'s descriptors and the one `TaskMeta` renderer draws them, + so a task reads identically here and on /projects. The GTD-only + badges around it (context, deep, energy, source, priority) stay — + one grammar for the shared facts, not an erased identity. */} + {item.origin?.kind === "email" && ( = new Set(); + // A status-segmented list (Jira backlog style): rows grouped under collapsible // stage headers with counts. In Manual sort the rows are drag-reorderable — // within a group (reposition) and across groups (re-file to that stage). A @@ -70,6 +77,8 @@ export function TaskListGrouped({ const urgentWindowHours = useTaskStore((s) => s.settings.urgentWindowHours); const sort = useTaskStore((s) => s.sort); const reorderItem = useTaskStore((s) => s.reorderItem); + const quickAddNext = useTaskStore((s) => s.quickAddNext); + const openFocus = useTaskStore((s) => s.openFocus); // Multi-select: when active, rows show a checkbox and drag is suppressed // (selecting and dragging the same card would conflict). const selectMode = useTaskStore((s) => s.selectMode); @@ -169,6 +178,64 @@ export function TaskListGrouped({ return next; }); + // WS-27y backport: the keyboard cursor and the landing flash — the same + // shared machinery the Projects list runs (`@/lib/cursor`, `useFlash`). + const [cursor, setCursor] = useState(-1); + const { flash, attach, scrollTo } = useFlash(); + + // The cursor's world: the rows in render order, skipping collapsed groups. + const rows = useMemo(() => { + const out: string[] = []; + for (const g of groups) { + if (collapsed.has(g.key)) continue; + for (const i of byGroup.get(g.key) ?? []) out.push(i.id); + } + return out; + }, [groups, byGroup, collapsed]); + + // Clamped at READ time rather than synced by an effect: the rows shrink + // under the cursor on every reload, and a state write per reload is exactly + // the cascading-render pattern the lint forbids. + const cursorAt = clampCursor(rows.length, cursor); + + function onKeyDown(event: React.KeyboardEvent) { + // A keystroke a control already consumed (a quick-add's Enter, a row's + // own Enter-to-open) is not the cursor's; nor is typing in an input. + if (event.defaultPrevented) return; + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]", + ) + ) + return; + // Plain cursor + Enter only. /tasks has no shift-range selection model + // (`selectedIds` is a bare toggle set with no anchor — see taskStore), so + // the shared cursor's shift-sweep stays dormant here rather than + // half-growing a second selection grammar on one surface. + const next = stepCursor( + rows, + { cursor: cursorAt, anchor: null, selection: EMPTY_SELECTION }, + event.key, + false, + ); + if (!next) return; + event.preventDefault(); + setCursor(next.cursor); + if (next.open) openFocus(next.open); + if (next.cursor >= 0) scrollTo(rows[next.cursor]); + } + + // Group-context quick-add (shared QuickAdd + this app's prefill): a task + // added under a group header is born a NEXT action IN that group, then + // announces its landing with the same flash a drop gets. The computed + // lenses (priority / mode) return null and never offer the box. + const quickAdd = async (title: string, groupKey: string) => { + const prefill = quickAddPrefill(statusGrouped ? "" : (groupBy as GroupBy), groupKey); + if (!prefill) return; + const id = quickAddNext(title, prefill); + if (id) flash(id); + }; + const onDrop = (groupKey: string, index: number) => { setDropAt(null); const id = dragId; @@ -188,13 +255,21 @@ export function TaskListGrouped({ ? { workflowStage: groupKey } : { providerStatus: groupKey } : { workflowStage: groupKey }; + // The landed row scrolls into view and flashes (shared useFlash), so the + // gesture visibly ends where the row now lives. + flash(id); reorderItem(id, dest, index, refile); }; const total = groups.length; return ( -
    +
    {/* Desktop column header row (Context list only). Hidden on mobile, where rows stay stacked. The left spacer matches a row's grip+expand gutters so "Name" and the cells sit above their columns. */} @@ -214,7 +289,10 @@ export function TaskListGrouped({
    )} {groups.map((g, gi) => { - const rows = byGroup.get(g.key) ?? []; + // `groupRows`, not `rows` — the outer `rows` is the keyboard cursor's + // whole-list world, and shadowing it here is how the cursor ring ends + // up comparing against the wrong array. + const groupRows = byGroup.get(g.key) ?? []; const isCollapsed = collapsed.has(g.key); const showHeader = grouped; // Status swimlanes get the per-stage accent; a lens grouping uses a plain @@ -272,7 +350,7 @@ export function TaskListGrouped({ "bg-background/60 text-muted-foreground", ].join(" ")} > - {rows.length} + {groupRows.length} {isDone && ( @@ -284,7 +362,7 @@ export function TaskListGrouped({ )} {!isCollapsed && (
    - {rows.map((item, idx) => ( + {groupRows.map((item, idx) => ( = 0 && rows[cursorAt] === item.id} isDropTarget={dropAt === `${g.key}:${idx}`} onDragStart={() => setDragId(item.id)} onDragEnd={() => { @@ -315,19 +395,19 @@ export function TaskListGrouped({ onDragOver={(e) => { if (!dragId) return; e.preventDefault(); - setDropAt(`${g.key}:${rows.length}`); + setDropAt(`${g.key}:${groupRows.length}`); }} - onDrop={() => onDrop(g.key, rows.length)} + onDrop={() => onDrop(g.key, groupRows.length)} className={[ "transition-all", dragId ? "h-6" : "h-2", - dropAt === `${g.key}:${rows.length}` + dropAt === `${g.key}:${groupRows.length}` ? "border-t-2 border-primary bg-primary/10" : "border-t-2 border-transparent", ].join(" ")} /> )} - {rows.length === 0 && showHeader && ( + {groupRows.length === 0 && showHeader && (

    {dragId ? "Drop here to move to this stage" @@ -336,6 +416,21 @@ export function TaskListGrouped({ : "No tasks in this group"}

    )} + {/* WS-27y backport: the group's own capture box — a task + added here is born a NEXT action IN this group (shared + QuickAdd + lib/quickAdd prefill), and flashes where it + lands. The computed lenses (priority / mode) offer no box: + no create payload can promise the landing. */} + {showHeader && + quickAddPrefill(statusGrouped ? "" : (groupBy as GroupBy), g.key) !== null ? ( +
    + quickAdd(title, g.key)} + className="max-w-md" + /> +
    + ) : null}
    )} @@ -354,6 +449,8 @@ function DraggableRow({ columns, grid, showStage, + attachRef, + atCursor, isDropTarget, onDragStart, onDragEnd, @@ -371,6 +468,10 @@ function DraggableRow({ grid: string; /** Show the card's status pill (off when the list is grouped by status). */ showStage: boolean; + /** useFlash registration — the landing flash / cursor scroll finds the row. */ + attachRef: (el: HTMLElement | null) => void; + /** The keyboard cursor stands on this row (WS-27y backport). */ + atCursor: boolean; isDropTarget: boolean; onDragStart: () => void; onDragEnd: () => void; @@ -385,6 +486,7 @@ function DraggableRow({ return (
    {/* a precise drop line that reads even over a dense row */} diff --git a/workbench/control_plane/src/app/tasks/components/TaskToolbar.tsx b/workbench/control_plane/src/app/tasks/components/TaskToolbar.tsx index 9e1d52ecf..6c3efc2ef 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskToolbar.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskToolbar.tsx @@ -11,6 +11,7 @@ import { activeFilterCount, NO_CONTEXT_FACET, NO_ENERGY_FACET, + SORT_LABEL, type GroupBy, type SortField, type TaskFilters, @@ -35,14 +36,8 @@ import { contextAccent } from "../lib/contextColors"; // Per-view exceptions: Assignee is hidden on My Next Actions (all mine), and // Sort is hidden on Waiting For (that view derives its own order). -const SORT_LABEL: Record = { - manual: "Manual", - priority: "Priority", - due: "Due date", - created: "Created", - title: "Title", - energy: "Energy", -}; +// SORT_LABEL moved to lib/ordering.ts — the board's drop-refusal overlay names +// the active sort from the same map this menu draws it from. const SORT_FIELDS: SortField[] = [ "manual", "priority", "due", "created", "title", "energy", diff --git a/workbench/control_plane/src/app/tasks/lib/cardMeta.test.ts b/workbench/control_plane/src/app/tasks/lib/cardMeta.test.ts new file mode 100644 index 000000000..dd00d411c --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/cardMeta.test.ts @@ -0,0 +1,91 @@ +/** + * /tasks · the shared-vocabulary chip adapter. + * + * What is pinned: the adapter feeds `taskMeta` (never re-implements it), the + * subtask-count chip rides in the shared reading order under the shared key + * and icon, and a zero earns no chip — the same silences `taskMeta` keeps. + */ + +import { describe, expect, it } from "vitest"; + +import { taskMeta } from "@/lib/taskCard"; + +import { gtdMetaChips } from "./cardMeta"; +import type { GtdItem } from "./types"; + +const NOW = Date.parse("2026-08-09T12:00:00Z"); + +function item(patch: Partial): GtdItem { + return { + id: "t1", + source: "LOCAL", + title: "A task", + disposition: "NEXT", + isMine: true, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + ...patch, + }; +} + +describe("gtdMetaChips", () => { + it("earns nothing for a bare task — zeros are silent", () => { + expect(gtdMetaChips(item({}), NOW)).toEqual([]); + }); + + it("speaks the shared vocabulary for due, attachments and estimate", () => { + const chips = gtdMetaChips( + item({ + dueAt: "2026-08-10T12:00:00Z", + attachments: [{ kind: "file", name: "a", url: "u" }], + timeEstimateMins: 90, + }), + NOW, + ); + // Byte-identical to what taskMeta itself would say — the adapter maps + // fields, it does not re-phrase. + expect(chips).toEqual( + taskMeta( + { dueAt: "2026-08-10T12:00:00Z", attachmentCount: 1, estimateMins: 90 }, + NOW, + ), + ); + expect(chips.map((c) => c.key)).toEqual(["due", "attachments", "estimate"]); + }); + + it("escalates overdue exactly as the shared rule does — done is never overdue", () => { + const late = gtdMetaChips(item({ dueAt: "2026-08-01T00:00:00Z" }), NOW); + expect(late[0]).toMatchObject({ key: "due", tone: "danger", icon: "AlertTriangle" }); + + const done = gtdMetaChips( + item({ dueAt: "2026-08-01T00:00:00Z", completedAt: "2026-08-02T00:00:00Z" }), + NOW, + ); + expect(done[0]).toMatchObject({ key: "due", tone: "muted", icon: "Clock" }); + }); + + it("counts subtasks in the shared slot, key and icon", () => { + const chips = gtdMetaChips( + item({ + dueAt: "2026-08-10T12:00:00Z", + subtaskCount: 3, + attachments: [{ kind: "file", name: "a", url: "u" }], + }), + NOW, + ); + // Shared reading order: due → subtasks → attachments. + expect(chips.map((c) => c.key)).toEqual(["due", "subtasks", "attachments"]); + expect(chips[1]).toMatchObject({ + icon: "ListTree", + label: "3", + tone: "muted", + title: "3 subtasks", + }); + }); + + it("leads with the subtask count when there is no due date", () => { + const chips = gtdMetaChips(item({ subtaskCount: 1 }), NOW); + expect(chips.map((c) => c.key)).toEqual(["subtasks"]); + expect(chips[0].title).toBe("1 subtask"); + }); +}); diff --git a/workbench/control_plane/src/app/tasks/lib/cardMeta.ts b/workbench/control_plane/src/app/tasks/lib/cardMeta.ts new file mode 100644 index 000000000..98dde4339 --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/cardMeta.ts @@ -0,0 +1,53 @@ +// The shared-vocabulary chips a GTD task card earns — the /tasks adapter over +// `@/lib/taskCard` (WS-27s), so both task apps describe the same facts with +// the same chips: one icon for "due", one tone for "overdue", one duration +// format, drawn by the one `TaskMeta` renderer. +// +// This is an ADAPTER, not a fork: `taskMeta` stays the single rulebook for +// which chips a task earns and in what order. The only thing added here is +// the one fact `GtdItem` states differently — it knows HOW MANY subtasks a +// task has but not how many are done (`subtaskCount`, no `done`), so the +// shared `subtasks: {done, total}` descriptor would have to lie ("0/5"). A +// count-only chip in the same grammar (same key, icon, tone, slot in the +// reading order) is the honest version of the same sentence. +// +// GTD-only signals — @context, energy, deep-work, source, stage, priority — +// deliberately do NOT move into this row: they are this app's identity, and +// `taskCard.ts`'s own header says what is shared is the vocabulary for the +// facts both apps have, not a flattening of the two apps into one. + +import { type MetaChip, taskMeta } from "@/lib/taskCard"; + +import type { GtdItem } from "./types"; + +/** + * The shared-fact chips for a GTD item, in the shared reading order: + * due (with the overdue escalation), subtask count, attachments, estimate. + */ +export function gtdMetaChips(item: GtdItem, nowMs = Date.now()): MetaChip[] { + const chips = taskMeta( + { + dueAt: item.dueAt, + completedAt: item.completedAt, + attachmentCount: item.attachments?.length ?? 0, + estimateMins: item.timeEstimateMins, + }, + nowMs, + ); + + const count = item.subtaskCount ?? 0; + if (count > 0) { + // Same slot the shared order gives subtasks: right after "due", before + // the quieter counts — so the row scans identically in both apps. + const at = chips.findIndex((c) => c.key === "due") + 1; + chips.splice(at, 0, { + key: "subtasks", + icon: "ListTree", + label: String(count), + tone: "muted", + title: `${count} subtask${count === 1 ? "" : "s"}`, + }); + } + + return chips; +} diff --git a/workbench/control_plane/src/app/tasks/lib/dropRules.test.ts b/workbench/control_plane/src/app/tasks/lib/dropRules.test.ts new file mode 100644 index 000000000..7dad8b0ba --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/dropRules.test.ts @@ -0,0 +1,51 @@ +/** + * /tasks · board drop refusals. + * + * What is pinned: the refusal set is exactly the drops the board's handlers + * ignore — same-column under a field sort (the sort owns the order) and + * select mode — and everything else stays a real drop, especially the + * cross-stage re-file under a field sort. + */ + +import { describe, expect, it } from "vitest"; + +import { dropRefusal } from "./dropRules"; + +describe("dropRefusal", () => { + it("allows every drop in Manual sort", () => { + expect( + dropRefusal({ selectMode: false, sortField: "manual", sameColumn: true }), + ).toBeNull(); + expect( + dropRefusal({ selectMode: false, sortField: "manual", sameColumn: false }), + ).toBeNull(); + }); + + it("still allows a cross-stage re-file under a field sort", () => { + expect( + dropRefusal({ selectMode: false, sortField: "priority", sameColumn: false }), + ).toBeNull(); + }); + + it("refuses a same-column drop under a field sort, naming the sort", () => { + const reason = dropRefusal({ + selectMode: false, + sortField: "priority", + sameColumn: true, + }); + expect(reason).toContain("Priority"); + expect(reason).toContain("Manual"); + }); + + it("names whichever sort is active, from the toolbar's own label map", () => { + expect( + dropRefusal({ selectMode: false, sortField: "due", sameColumn: true }), + ).toContain("Due date"); + }); + + it("refuses everything in select mode", () => { + expect( + dropRefusal({ selectMode: true, sortField: "manual", sameColumn: false }), + ).toContain("select mode"); + }); +}); diff --git a/workbench/control_plane/src/app/tasks/lib/dropRules.ts b/workbench/control_plane/src/app/tasks/lib/dropRules.ts new file mode 100644 index 000000000..110c6daf5 --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/dropRules.ts @@ -0,0 +1,37 @@ +// /tasks · why a board drop is refused — said out loud (the WS-27y pattern, +// backported from `app/projects/lib/board.dropRefusal`). +// +// The old board refused silently: under a field sort a card dropped back into +// its own column just snapped home (no rank to write, no stage to change), +// and in select mode dragging was switched off. A card that snaps back +// wordlessly teaches people the board is broken, not that Priority sort owns +// the order. So the refusal becomes a sentence, rendered as an overlay on the +// hovered target while the card is in the air — same grammar as Projects. +// +// Note what is NOT refused: a cross-stage drop under a field sort is a real +// re-file (the stage changes even though the sort owns the order within it), +// and every drop in Manual sort is a real reorder. The refusal set is exactly +// the drops the handlers already ignore. + +import { SORT_LABEL, type SortField } from "./ordering"; + +export interface DropContext { + /** Multi-select is active (drag is suppressed, a drop can do nothing). */ + selectMode: boolean; + /** The active sort — only "manual" lets a drop set the order. */ + sortField: SortField; + /** The hovered column is the one the dragged card already lives in. */ + sameColumn: boolean; +} + +/** The reason a drop into the hovered column is refused, or `null` when the + * drop is real (re-file, reorder, or both). */ +export function dropRefusal(ctx: DropContext): string | null { + if (ctx.selectMode) { + return "Selecting — leave select mode to move tasks."; + } + if (ctx.sameColumn && ctx.sortField !== "manual") { + return `Sorted by ${SORT_LABEL[ctx.sortField]} — switch to Manual sort to reorder within a stage.`; + } + return null; +} diff --git a/workbench/control_plane/src/app/tasks/lib/ordering.ts b/workbench/control_plane/src/app/tasks/lib/ordering.ts index 5af1df991..fb1e83831 100644 --- a/workbench/control_plane/src/app/tasks/lib/ordering.ts +++ b/workbench/control_plane/src/app/tasks/lib/ordering.ts @@ -78,6 +78,17 @@ export type SortField = export type SortDir = "asc" | "desc"; +/** How each sort field reads to a human — the toolbar menu AND the board's + * drop-refusal overlay name sorts from this one map, so they cannot drift. */ +export const SORT_LABEL: Record = { + manual: "Manual", + priority: "Priority", + due: "Due date", + created: "Created", + title: "Title", + energy: "Energy", +}; + // Facet filters: each is a SET of accepted values — a task matches a facet if it // falls in ANY of that facet's selected values (OR within a facet), and it must // pass EVERY active facet (AND across facets). Empty set = facet inactive. This diff --git a/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts b/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts new file mode 100644 index 000000000..bc485f448 --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts @@ -0,0 +1,46 @@ +/** + * /tasks · group-context quick-add prefill. + * + * What is pinned: every settable axis maps to the field that files the task + * into that group; every unset bucket maps to a bare create (which already + * lands there); and the computed axes refuse (`null`) rather than offering an + * add that would visibly land in a sibling group. + */ + +import { describe, expect, it } from "vitest"; + +import { NO_CONTEXT_GROUP } from "./priority"; +import { quickAddPrefill } from "./quickAdd"; + +describe("quickAddPrefill", () => { + it("files a status-axis add into its stage — board column or list section", () => { + expect(quickAddPrefill("", "IN PROCESS")).toEqual({ workflowStage: "IN PROCESS" }); + }); + + it("files a context-group add under that @context", () => { + expect(quickAddPrefill("context", "@computer")).toEqual({ context: "@computer" }); + }); + + it("lets the no-context bucket stay a bare create — a bare item already lands there", () => { + expect(quickAddPrefill("context", NO_CONTEXT_GROUP)).toEqual({}); + }); + + it("files an energy-group add at that energy, and the unset bucket bare", () => { + expect(quickAddPrefill("energy", "high")).toEqual({ energy: "high" }); + expect(quickAddPrefill("energy", "none")).toEqual({}); + }); + + it("marks a deep-work-group add deep, and shallow bare", () => { + expect(quickAddPrefill("depth", "deep")).toEqual({ deepWork: true }); + expect(quickAddPrefill("depth", "shallow")).toEqual({}); + }); + + it("treats the flat list as a plain add", () => { + expect(quickAddPrefill("none", "all")).toEqual({}); + }); + + it("refuses the computed axes — no payload can promise the landing", () => { + expect(quickAddPrefill("priority", "critical")).toBeNull(); + expect(quickAddPrefill("mode", "do")).toBeNull(); + }); +}); diff --git a/workbench/control_plane/src/app/tasks/lib/quickAdd.ts b/workbench/control_plane/src/app/tasks/lib/quickAdd.ts new file mode 100644 index 000000000..2f19b6aa3 --- /dev/null +++ b/workbench/control_plane/src/app/tasks/lib/quickAdd.ts @@ -0,0 +1,70 @@ +// /tasks · group-context quick-add (the WS-27y pattern, backported). +// +// A quick-add lives inside a group — a board column, a grouped-list section — +// and the task it creates must LAND in that group, or the add reads as a +// failure while the task sits in some default bucket off-screen. The mapping +// from "where the input is" to "what the created item must carry" is this +// module, pure and surface-agnostic, exactly like `app/projects/lib/quickAdd` +// on the other side of the wall: the payloads differ (`gtd_items` speaks +// workflowStage/context/energy, `pm_tasks` speaks status_id/tags), the +// grammar is the same. +// +// One divergence from the Projects mapping, and it is deliberate: /tasks has +// COMPUTED axes. The priority and mode groups are projections of +// important × leveraged × urgent-from-dueAt (`priority.ts`), so no create +// payload can promise the new task lands in "Critical" — urgency needs a due +// date nobody typed. Where Projects answers an unknown axis with "a plain add, +// never an error", these axes answer `null` — the surface offers no quick-add +// there at all — because in a grouped list a plain add that visibly files +// itself into a SIBLING group is not a plain add, it is a lie about where the +// task went. + +import type { GroupBy } from "./ordering"; +import { NO_CONTEXT_GROUP } from "./priority"; +import type { Energy } from "./types"; + +/** What a quick-added task must carry to belong to its group. */ +export interface QuickAddPrefill { + workflowStage?: string; + context?: string; + energy?: Energy; + deepWork?: boolean; +} + +/** The axes a /tasks quick-add can sit inside: the status axis (`""`, the + * grouped list's default and the board's columns) or a toolbar lens. */ +export type QuickAddAxis = GroupBy | ""; + +const ENERGIES: ReadonlySet = new Set(["low", "medium", "high"]); + +/** + * (axis, group key) → the prefill, or `null` when the group cannot honestly + * take a quick-add (a computed axis — see the header). + * + * The unset buckets ("No context", "No energy set", "Shallow") ask for + * nothing: a bare next action is already context-less, energy-less and + * shallow, so "create it in this group" is what a bare create does. + */ +export function quickAddPrefill( + axis: QuickAddAxis, + key: string, +): QuickAddPrefill | null { + switch (axis) { + case "": + // The status axis: the board's columns and the grouped list's stage + // sections. The key IS the stage. + return { workflowStage: key }; + case "context": + return key === NO_CONTEXT_GROUP ? {} : { context: key }; + case "energy": + return ENERGIES.has(key) ? { energy: key as Energy } : {}; + case "depth": + return key === "deep" ? { deepWork: true } : {}; + case "none": + return {}; + case "priority": + case "mode": + // Computed from flags + due date — a create cannot promise the landing. + return null; + } +} diff --git a/workbench/control_plane/src/app/tasks/lib/taskStore.ts b/workbench/control_plane/src/app/tasks/lib/taskStore.ts index 9d739764f..833f6995d 100644 --- a/workbench/control_plane/src/app/tasks/lib/taskStore.ts +++ b/workbench/control_plane/src/app/tasks/lib/taskStore.ts @@ -518,6 +518,15 @@ interface TaskState { capture: (title: string, attachments?: import("./types").TaskAttachment[], dates?: import("./api").CaptureDates) => void; /** Capture many items at once (mind sweep) — one per non-empty line. */ captureMany: (text: string) => void; + /** Group-context quick-add (WS-27y pattern): create a clarified NEXT action + * directly in a board column / list group, carrying the group's prefill + * (stage / @context / energy / deep-work) so it LANDS there — unlike + * `capture`, which always files into the Inbox. Returns the optimistic id + * (for the landing flash), or null for a blank title. */ + quickAddNext: ( + title: string, + prefill: import("./quickAdd").QuickAddPrefill, + ) => string | null; /** Undo the most recent capture batch (only items still in the inbox). */ undoLastCapture: () => void; /** Clarify an inbox item — apply the GTD decision and advance to the next. */ @@ -960,6 +969,57 @@ export const useTaskStore = create((set, get) => ({ } }, + quickAddNext: (title, prefill) => { + const t = title.trim(); + if (!t) return null; + const now = new Date().toISOString(); + // The board's own rule (see updateItem): filing into the LAST configured + // stage means the task is DONE — a quick-add into the Done column is a + // log entry, not a to-do. + const stages = get().settings.workflowStages; + const done = + prefill.workflowStage !== undefined && + stages.length > 0 && + prefill.workflowStage === stages[stages.length - 1]; + const item: GtdItem = { + ...makeCaptureItem(t), + // Born clarified: the group the add sits in already answered "what is + // this?" — it is a next action ON that stage/context/energy, and the + // title IS the next physical step. + disposition: done ? "DONE" : "NEXT", + nextAction: t, + clarifiedAt: now, + ...(done ? { completedAt: now } : {}), + ...prefill, + }; + set((s) => ({ items: [item, ...s.items] })); + if (get().backend === "live") { + sync( + // Create + clarify as capture-then-patch: /items has no "born NEXT" + // shape, and this is the same two-step Clarify itself rides. + apiCapture(t).then(async (server) => { + const body: Parameters[1] = { + disposition: item.disposition, + next_action: t, + }; + if (prefill.workflowStage !== undefined) + body.workflow_stage = prefill.workflowStage; + if (prefill.context !== undefined) body.context = prefill.context; + if (prefill.energy !== undefined) body.energy = prefill.energy; + if (prefill.deepWork !== undefined) body.deep_work = prefill.deepWork; + // If the clarify PATCH fails the capture still exists — keep the + // server row (it will show in the Inbox, which is honest) rather + // than inviting a duplicate-creating retry. + const final = await apiPatchItem(server.id, body).catch(() => server); + set((s) => ({ + items: s.items.map((i) => (i.id === item.id ? final : i)), + })); + }), + ); + } + return item.id; + }, + undoLastCapture: () => { const ids = get().lastCaptureIds; if (!ids.length) return; diff --git a/workbench/control_plane/src/components/QuickAdd.tsx b/workbench/control_plane/src/components/QuickAdd.tsx new file mode 100644 index 000000000..2722dfff6 --- /dev/null +++ b/workbench/control_plane/src/components/QuickAdd.tsx @@ -0,0 +1,93 @@ +"use client"; + +/** + * The inline group-context add (WS-27y, shared by /projects and /tasks). + * + * One control, many surfaces: a board column (or lane cell), a list group, + * a calendar day — in either task app. Collapsed it is a quiet "+ Add"; open it is a title box + * whose Enter submits AND stays open ready for the next title — batch entry + * is the whole point, a capture box that closes after one item is a capture + * box used once. Esc closes it and throws the draft away. + * + * WHAT the created task inherits from its group is not this component's + * business: the caller owns the axis→payload mapping (`lib/quickAdd.ts`) and + * this form only collects the title. That split is what the spreadsheet + * layout (WS-27x) reuses. + */ + +import Icon from "@/components/Icon"; +import { Input } from "@/components/ui/Input"; +import { useState } from "react"; + +interface Props { + /** Where the task will land, e.g. `Add to “In progress”`. Doubles as the + * input's placeholder and accessible name. */ + label: string; + /** Create the task. Throwing keeps the draft and shows the message. */ + onAdd: (title: string) => Promise; + className?: string; + /** Compact trigger for tight cells (calendar days). */ + compact?: boolean; +} + +export function QuickAdd({ label, onAdd, className = "", compact = false }: Props) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + if (!open) { + return ( + + ); + } + + return ( +
    { + if (e.key === "Escape") { + setOpen(false); + setTitle(""); + setError(null); + } + e.stopPropagation(); + }} + onSubmit={(e) => { + e.preventDefault(); + const trimmed = title.trim(); + if (!trimmed || busy) return; + setBusy(true); + setError(null); + onAdd(trimmed) + .then(() => setTitle("")) + .catch((err) => setError(String((err as Error).message))) + .finally(() => setBusy(false)); + }} + > + setTitle(e.target.value)} + placeholder={label} + aria-label={label} + // Not `disabled` while busy — disabling blurs the input, and the + // next title should be typable the instant this one lands. + readOnly={busy} + /> + {error ?

    {error}

    : null} +
    + ); +} diff --git a/workbench/control_plane/src/app/projects/components/flash.module.css b/workbench/control_plane/src/components/flash.module.css similarity index 100% rename from workbench/control_plane/src/app/projects/components/flash.module.css rename to workbench/control_plane/src/components/flash.module.css diff --git a/workbench/control_plane/src/components/useFlash.ts b/workbench/control_plane/src/components/useFlash.ts new file mode 100644 index 000000000..c2640cfce --- /dev/null +++ b/workbench/control_plane/src/components/useFlash.ts @@ -0,0 +1,91 @@ +"use client"; + +/** + * Scroll-into-view + flash for a card that just landed (WS-27y, shared). + * + * Born in /projects and promoted here so /tasks announces a drop or quick-add + * the same way — one landing animation across both task surfaces. + * + * After any drop or quick-add, the moved/created card must be SEEN to land — + * scrolled into view and briefly tinted — or the gesture ends in silence and + * people re-do it. The awkward part is timing: at the moment of the gesture + * the card's element may not exist yet (a quick-add's card appears only after + * the reload; a cross-column drop remounts the card in its new column). So a + * flash is *pending* until the element shows up: `flash(id)` fires + * immediately when the node is on the page, and otherwise the ref callback + * fires it the moment React mounts it. The pending marker expires after a few + * seconds so a card re-mounted much later (a filter change) does not flash + * out of nowhere. + * + * Refs and classList rather than state, deliberately: a flash must not + * re-render three hundred cards, and the animation itself is CSS + * (`flash.module.css`, theme tokens only). + */ + +import { useCallback, useRef } from "react"; + +import styles from "./flash.module.css"; + +const PENDING_MS = 3000; +const CLEAR_MS = 1600; + +export interface Flash { + /** Flash the element registered under `id`, now or when it next mounts. */ + flash: (id: string) => void; + /** Ref callback registering an element under `id`. */ + attach: (id: string) => (el: HTMLElement | null) => void; + /** Scroll to a registered element without flashing — the cursor's need. */ + scrollTo: (id: string) => void; +} + +export function useFlash(): Flash { + const els = useRef(new Map()); + const pending = useRef(null); + const expiry = useRef | null>(null); + + const run = useCallback((el: HTMLElement) => { + el.scrollIntoView({ block: "nearest", inline: "nearest" }); + // Remove-reflow-add restarts the animation when the same card flashes + // twice in a row (two quick-adds into one column). + el.classList.remove(styles.flash); + void el.offsetWidth; + el.classList.add(styles.flash); + setTimeout(() => el.classList.remove(styles.flash), CLEAR_MS); + }, []); + + const flash = useCallback( + (id: string) => { + // Pending survives an immediate run: a drop flashes the card where it + // stands AND re-flashes it if the reload remounts it in its new column. + pending.current = id; + if (expiry.current) clearTimeout(expiry.current); + expiry.current = setTimeout(() => { + pending.current = null; + }, PENDING_MS); + const el = els.current.get(id); + if (el) run(el); + }, + [run] + ); + + const attach = useCallback( + (id: string) => (el: HTMLElement | null) => { + if (el) { + els.current.set(id, el); + if (pending.current === id) { + pending.current = null; + run(el); + } + } else { + els.current.delete(id); + } + }, + [run] + ); + + const scrollTo = useCallback((id: string) => { + els.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, []); + + return { flash, attach, scrollTo }; +} diff --git a/workbench/control_plane/src/app/projects/lib/cursor.test.ts b/workbench/control_plane/src/lib/cursor.test.ts similarity index 98% rename from workbench/control_plane/src/app/projects/lib/cursor.test.ts rename to workbench/control_plane/src/lib/cursor.test.ts index f3a477fed..7e3a12d3c 100644 --- a/workbench/control_plane/src/app/projects/lib/cursor.test.ts +++ b/workbench/control_plane/src/lib/cursor.test.ts @@ -1,5 +1,5 @@ /** - * Projects · the keyboard cursor (WS-27y). + * The keyboard cursor shared by /projects and /tasks (WS-27y, backported). * * The transitions that decide whether arrow keys feel solid: entry from * nowhere, both boundaries, a shift-sweep in each direction, a sweep started diff --git a/workbench/control_plane/src/lib/cursor.ts b/workbench/control_plane/src/lib/cursor.ts new file mode 100644 index 000000000..de384d283 --- /dev/null +++ b/workbench/control_plane/src/lib/cursor.ts @@ -0,0 +1,116 @@ +/** + * The keyboard cursor — shared by /projects and /tasks (WS-27y, backported). + * + * ArrowUp/ArrowDown walk an active row through a list or board in render + * order; Shift+Arrow extends an EXISTING selection from the cursor; Enter + * opens the row. All of it is one pure transition — + * (rows, state, key) → next state — so the awkward cases are assertions + * rather than manual testing: a cursor on a row a filter just removed, a + * shift-sweep started from nowhere, an arrow at the boundary. + * + * Born in `app/projects/lib/cursor.ts` and promoted here so both apps walk + * their rows with the same grammar (the same reason `lib/taskCard.ts` holds + * the chip vocabulary): a member uses both surfaces in the same hour, and the + * arrow keys must not feel like two different products. + * + * Selection semantics are deliberately ADDITIVE, like the shift-click they + * extend: sweeping over rows adds them, and un-selecting is a click, exactly + * as it already was. A second removal grammar here would make the keyboard + * and the mouse disagree about what shift means. A surface with no + * shift-selection model (the /tasks list today) simply never passes + * `shift=true`, and the sweep branch stays dormant. + */ + +export interface CursorState { + /** Index into the visible rows. `-1` = no active row. */ + cursor: number; + /** Where the current shift-sweep started, or null outside a sweep. */ + anchor: number | null; + selection: ReadonlySet; +} + +export const NO_CURSOR: Pick = { + cursor: -1, + anchor: null, +}; + +export interface CursorNext extends CursorState { + /** The row id Enter asked to open, else null. */ + open: string | null; +} + +/** + * The inclusive id range between two rows of `visible`, in render order. + * + * Mirrors `app/projects/lib/selection.range` (WS-27n), which stays where it is + * because that module is the Projects selection model's home and this shared + * module must not import app code. Same contract: either end missing from the + * visible set means the anchor scrolled out from under the sweep, and + * selecting just the target is the honest fallback. + */ +function sweepRange( + visible: readonly string[], + anchor: string, + target: string +): string[] { + const from = visible.indexOf(anchor); + const to = visible.indexOf(target); + if (from === -1 || to === -1) return [target]; + const [lo, hi] = from <= to ? [from, to] : [to, from]; + return visible.slice(lo, hi + 1); +} + +/** + * One keystroke. Returns `null` for keys the cursor does not own, so callers + * can `preventDefault` exactly when the key was consumed and never eat a + * keystroke that belonged to something else. + */ +export function stepCursor( + rows: readonly string[], + state: CursorState, + key: string, + shift: boolean +): CursorNext | null { + if (rows.length === 0) return null; + const cursor = clampCursor(rows.length, state.cursor); + + if (key === "Enter") { + if (cursor < 0) return null; + return { ...state, cursor, open: rows[cursor] }; + } + if (key !== "ArrowDown" && key !== "ArrowUp") return null; + + // From nowhere, ArrowDown enters at the top and ArrowUp at the bottom — + // the row nearest where the keystroke's attention already was. + const next = + key === "ArrowDown" + ? cursor < 0 + ? 0 + : Math.min(cursor + 1, rows.length - 1) + : cursor < 0 + ? rows.length - 1 + : Math.max(cursor - 1, 0); + + if (!shift) { + // A plain arrow ends any sweep; the selection itself is untouched, and is + // returned as the SAME set so callers can cheaply see nothing changed. + return { cursor: next, anchor: null, selection: state.selection, open: null }; + } + + // Shift: the sweep runs from where it started (or from the row the cursor + // was on; or, entering from nowhere, from the entry row itself) to the new + // cursor, and everything in between joins the selection. + const anchor = state.anchor ?? (cursor >= 0 ? cursor : next); + const selection = new Set(state.selection); + for (const id of sweepRange(rows, rows[anchor], rows[next])) selection.add(id); + return { cursor: next, anchor, selection, open: null }; +} + +/** + * Where the cursor lands after the rows changed under it: clamped into the + * new bounds, or gone when there is nothing left to stand on. + */ +export function clampCursor(rowCount: number, cursor: number): number { + if (rowCount === 0 || cursor < 0) return -1; + return Math.min(cursor, rowCount - 1); +} From c543df9fbebf1fdf67cb7e8a854f3bb6fbace43e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:41:34 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20record=20WS-27u=E2=80=93z=20+=20con?= =?UTF-8?q?tinuity=20backport=20as=20built;=20owner=20activation=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §9.1 gains the build record (migration numbers 164/165/166, the no-archive-endpoint discovery, the pm_lifecycle owner step, continuity gaps). Work plan WS-27 row and HANDOVER top box updated to the restarted branch's state. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- ai-company-brain/HANDOVER.md | 28 +++++++++++++++++++ .../specs/project_management_app.md | 26 +++++++++++++++++ ai-company-brain/work_plan.md | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/ai-company-brain/HANDOVER.md b/ai-company-brain/HANDOVER.md index f27ac5e9a..17110a5e7 100644 --- a/ai-company-brain/HANDOVER.md +++ b/ai-company-brain/HANDOVER.md @@ -12,6 +12,34 @@ ## 1. Where the branch is +> ### ⚠️ 2026-08-10 — #399 MERGED; branch restarted from `main`; WS-27u–z ALL BUILT +> +> PR **#399 merged to main**. Per the merged-PR rule the branch was restarted from +> `origin/main` (same name, fresh history) — everything below this box describing "open PR +> #399" is now historical record. On the restarted branch, six parallel agents built the +> whole §9.1 beyond-parity queue **plus the owner-directed Tasks↔Projects continuity +> backport**; all merged, verified (5789 backend / 1278 frontend / tsc / ruff / theme +> conformance green) and pushed. **No PR opened yet** — not asked for. +> +> **New migrations awaiting the real box** (check `schema_migrations`, apply in order — +> they follow 160–162 from §1.1): **164** `projects_intake` (pm_intake + `triage` +> category), **165** `projects_watchers` (pm_task_watchers; seeds task authors as +> watchers), **166** `projects_lifecycle` (per-root archive/close months + timezone, +> default off). +> +> **New owner steps** (beyond §4): to activate WS-27z, author a workflow in `/workflows` +> — schedule trigger + one config-free **`pm_lifecycle`** node — and publish it (the +> canvas palette predates the Projects nodes; author via the workflows API/copilot, the +> `pm_task` precedent). Then set months/timezone per root project via the Lifecycle +> dialog. Everything defaults NULL = off. +> +> **Continuity gaps that remain** (from the backport agent's audit, for whoever continues): +> Tasks' modal select-mode vs Projects' shift-range selection (biggest; needs an anchor in +> `taskStore`); board chrome (Tasks: accent caps + drop-gap reorder; Projects: swimlanes + +> append-on-drop); Tasks' flat lists (Done/Waiting/Someday/Archive), `WaitingForView` and +> the Inbox's own j/k idiom have neither shared cursor nor quick-add; calendar asymmetry +> (known, deliberately out of scope — Tasks has a 10-file module, Projects one view). + **Tree clean, everything pushed.** Open PR **#399**. > ### ⚠️ 2026-08-09 — `main` moved, and this branch has been merged with it diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index fa530ad36..425cccf64 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -1085,6 +1085,32 @@ the standing protocol: hermetic tests against the fake, mutation-tested guards, Postgres run, and R1 (migration numbers resolved at build time — every number below is a description, not an assignment). +> **✅ ALL SIX BUILT 2026-08-10** on the restarted branch (after #399 merged), six parallel +> agents + two integration merges; full suites green (5789 backend / 1278 frontend). +> Build-time facts that differ from or sharpen the ticket text: +> - Migration numbers resolved as **164** (intake), **165** (watchers), **166** (lifecycle); +> `main` had taken 163 in the interim. WS-27w needed **no** migration. +> - **WS-27w item 1**: no archive endpoint existed at all — `archived_at` had no writer. The +> ticket's guard therefore shipped as new `POST …/archive` + `/unarchive` endpoints with +> the 422 guard built in (guard phrased as `category not in CLOSING_CATEGORIES`, so +> `triage` is refused without depending on WS-27u). +> - **WS-27v**: migration 165 seeds each task's author as a watcher (the pre-watchers +> audience included authors; the switch must not silently unsubscribe them), and only +> *delivered* mentions auto-subscribe. +> - **WS-27z**: `/workflows` workflows are DB rows, not files — so the deliverable is the +> sweep (`automation.run_lifecycle_sweep`), a config-free **`pm_lifecycle`** engine node +> mirroring `pm_task`'s wiring, and `automation: true` on `record_activity`. Authoring + +> publishing the schedule-triggered workflow is an **owner step on the live box** +> (HANDOVER §1.1). "Default closing status" does not exist in the schema; the sweep's +> recorded model is first `cancelled` lane by position, else first `done`. Root's policy +> governs the subtree; the API 422s a policy write on a child. +> - **Continuity backport (owner directive, same day)**: the WS-27y machinery was promoted +> to shared code (`src/lib/cursor.ts`, `src/components/QuickAdd.tsx` + `useFlash`) with +> re-export shims, the Tasks app's cards now draw the WS-27s chip vocabulary, and its +> board/list gained the cursor, group-context quick-add, and drop-refusal/flash grammar. +> Remaining divergences are recorded in HANDOVER (Tasks' modal selection vs Projects' +> shift-range is the biggest). + **WS-27u — intake/triage: the front door.** 🟢 AGENT-SAFE *(P-1)*. A captured task is real from birth, parked out of sight until a human rules on it. Done when: (1) a migration adds a `pm_intake` join table (`task_id` unique, `status ∈ diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index b24cc15b4..4f2949712 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -186,7 +186,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | ⏸ PARKED | `drawio_integration.md` | **PARKED BY OWNER 2026-08-10 (D25.7)** — no agent time until a real need (proposal diagrams, KB visuals) pulls it back. The spec's acceptance structure keeps; anchors need re-verification at un-park. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead BUILT, PR #403 OPEN** — owner: merge, then 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–n merged · **o–t on PR #399** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ✅ **WS-27u–z ALL BUILT 2026-08-10 on the restarted branch** (#399 merged; branch restarted from main per the merged-PR rule) — migrations **164** intake · **165** watchers · **166** lifecycle; plus the **Tasks↔Projects continuity backport** (shared chips/cursor/QuickAdd/flash promoted to `src/lib`+`src/components`, both apps consume one implementation; remaining gaps recorded in HANDOVER). z's sweeper is wired as a `pm_lifecycle` workflow node — the scheduled workflow itself is an owner authoring step on the live box (workflows are DB rows, never files). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07 · updated 2026-08-10) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | ---