Feature #1546
closedFeature #1511: Replace ticket view with Kanban board for CTO and assigned developers
Frontend: Kanban board replaces the ticketing list view
0%
docs/superpowers/specs/2026-08-03-kanban-board-design.md
Description
**Surface:** frontend (`src/` + `app/`)
**Depends on:** "API: per-viewer visibility matrix and per-card drag capability", "API: per-user board column preferences", "Design: Kanban board section in the design system"
**Blocks:** "Frontend: board polling, manual refresh and card-move animation"
Replace the master–detail list at `app/projects/[id]/ticketing/page.tsx` with the Kanban board — **in place, for every viewer** (decision **D1**). No overlay, no new tab, no toggle back to a list. This supersedes the epic's original AC #2 ("visible only to the CTO and the assigned developer"): the board is visible to all, and *card scope and drag rights* follow the D2 matrix, which the server already applied to the payload.
- Add **dnd-kit** to `package.json` — no DnD library exists in the tree today.
- Columns from the existing `STAGE_ORDER` (`src/features/overview/kpis.ts:313`). Do not re-derive or hard-code an order.
- Cards render title, assignee, priority and parent from the extended list payload, per the approved design section.
- **Drag rights come only from the server fields** on each card (`can_move_forward`, `next_status_id`, `allowed_statuses`). No role predicate, no status-order array, no client-side transition rules — the internal-roles rule in `docs/superpowers/specs/2026-07-31-internal-roles-design.md`.
- Drops go through the shipped `useTransitionTask` hook (`src/features/tasks/useTransitionTask.ts`), optimistically, with rollback and a visible localised error on 403 and 409.
- Column filter reads/writes the preferences endpoints, optimistic locally and reconciled against the server.
- Build with Atomic Design placement under `src/components/`; no recharts import.
## Acceptance criteria
- [ ] `/projects/[id]/ticketing` renders the board; the previous list/table is **gone** — no overlay, no new tab, no toggle back to it. Every viewer lands on the board.
- [ ] Columns follow `STAGE_ORDER` with no locally redefined status order; Shipped is hidden on first visit.
- [ ] Cards display title, assignee, priority and parent, with defined fallbacks for null assignee, null priority and no parent.
- [ ] Drag enablement and legal targets derive **exclusively** from the server capability fields; a grep-level or lint assertion proves no `internal_role` check and no status-order array was added under `src/`.
- [ ] **A client cannot initiate a drag at all** — no drag handle rendered and the pointer sensor never activates; asserted by a test that fires pointer-down + move and expects no drag-start. Not merely a rejected drop.
- [ ] The same holds for any card whose `can_move_forward` is false, including every Backlog card.
- [ ] A developer's board shows only their assigned tickets, each accepting only its single server-provided forward target.
- [ ] A CTO's board accepts the full server-allowed set including backward moves.
- [ ] Backlog and Blocked columns render but reject every drop for every role, showing the designed invalid-target state, and **fire zero network calls** when a drop is attempted on them.
- [ ] A legal drop moves the card optimistically; on 403 or 409 it rolls back to its origin column and surfaces the designed localised error. Both outcomes covered by tests.
- [ ] Over-cap: a column whose `has_more` is true renders the designed "+N more" affordance; no infinite scroll in v1.
- [ ] The column filter persists via the preferences endpoints, applies optimistically, reverts on write failure, and survives a full page reload.
- [ ] A keyboard-only alternative to moving a card works per the design section, driven in a test with keyboard events only.
- [ ] Every visible string comes from next-intl; **both** `en.ts` and `fr.ts` updated with identical keys and ICU placeholders/plural branches, `fr.ts` a real French translation. A test asserts no hardcoded user-facing literal in the board components.
- [ ] Gates green: `npx vitest run && npx tsc --noEmit && npx eslint . && npm run build` (node 20.20.2 via nvm).
Files
RA Updated by Redmine Admin 1 day ago
## Amendment after the design review (#1545)
**1. ⚠ The rail has two other inhabitants that must not die with the list.** Deleting the left rail at `app/projects/[id]/ticketing/page.tsx` would also delete:
- the **`New request` CTA** — the only ticket-intake path from this screen (it routes to `/projects/{id}/assistant?new=1`);
- **`KnowledgeStrip`** — the app's **only** reindex control, which has no other mount anywhere.
Both are recomposed into the board toolbar per §KB.1a. `KnowledgeStrip` gains an `orientation="inline"` prop that reverts it to its original horizontal §PK.1 form. New AC: a test asserts both are still reachable from `/projects/[id]/ticketing` after the list is removed.
**2. Parent/child indentation has no board expression.** `buildTaskHierarchy` indents subtickets under their parent in the list, but children do not live in their parent's column. The card's parent row (§KB.2) replaces that affordance. Confirm nothing else depends on the indentation before removing it.
**3. The detail is a full takeover, not a side pane.** Opening a card sets `?task=` and takes over the screen with the existing breadcrumb back, exactly as today. Keeping master–detail beside a board would squeeze the columns to an unusable width.
**4. Assignee shows for every viewer**, ordinary clients included (PO decision) — see #1543, which lifts the withholding on both the list and the detail payloads.
**5. Priority styling keys off `position` (the rank), never `key` or `name`** — `priority_ref.key` is a slug of the display name and is not locale-stable. Normal and null priorities render **no chip at all**; priority still appears in the card's accessible name.
Design section: `docs/design/design-system.md` §KB (KB.0–KB.11). §KB.11 carries the Atomic-Design component inventory and token ledger.
RA Updated by Redmine Admin 1 day ago
## New requirement from #1543 — fetch the real allowed set at drop time
#1543 computes each card's legal targets from the **canonical pipeline**, not from that issue's actual Redmine allowed-status set, because the latter costs one HTTP call per card and the no-N+1 AC forbids it. Writing the narrow-workflow test exposed that this is worse than first assessed:
For a **developer**, the card's target and Redmine's real target are **disjoint, not nested**. Their single step is resolved against the canonical order, so on a project whose workflow skips a stage the card offers (say) QA while Redmine will only accept Preprod. The board therefore simultaneously **advertises a drop that will fail** and **hides one that would have worked**.
This is not a disclosure — no id crosses that the role rule forbids, and the write path re-derives everything from the real allowed set — but it is a real usability defect on any project whose Redmine workflow is narrower than the canonical pipeline.
**New AC:** on drag start (or drop), fetch the real allowed-status set for **the one card being dragged** and use it to decide the legal target. One call for one card is not an N+1 and is not what the cap AC forbids. Cover the narrowed-workflow case with a test.
**Also:** a rejected drop can surface as **403, not only 409**. `TransitionTask` checks the role rule before the workflow rule, so a developer dropping on a canonically-correct but workflow-invalid target gets `ProgressionForbidden` → 403 on their own ticket; only a CTO gets the 409. The rollback path must treat both identically — the existing AC mentions both, but the design note's claim that this lands as a 409 is wrong for the developer case.
See `BOARD_CANDIDATE_STATUSES` in `domain/tasks/progression.py` for the documented tradeoff.
RA Updated by Redmine Admin 1 day ago
- Status changed from Spec to In development
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
- branch set to feat/1546-kanban-board
- pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/98
## Board shipped — PR https://github.com/omdev-tech/PipeLiner-Client/pull/98 (base `dev`)
Branch `feat/1546-kanban-board`, branched off **`origin/dev`** (not `master`): every backend dependency #1541–#1544 is on `dev` and none is on `master` yet. Base verified at the #97 visibility-matrix merge.
### The four risk points
1. **The rail's other inhabitants live.** `New request` and `KnowledgeStrip` are recomposed into the board toolbar (§KB.1a); `KnowledgeStrip` gained `orientation="inline"` (its original §PK.1 horizontal form). A route-level test asserts both are still reachable after the list is gone.
2. **No role logic in `src/`.** `boardContract.test.ts` runs both greps as tests: no `internal_role` in the board tree, and no second status-order array anywhere under `src/`/`app/`.
3. **A client cannot initiate a drag.** Pointer-down + move produces no drag-start — asserted, with the positive case asserted too so the negatives cannot pass vacuously. Same for `can_move_forward: false` and every Backlog card.
4. **Backlog(16)/Blocked(14) fire zero network calls.** The locked-column check runs before the one-card detail read, so a drop on either costs literally nothing. Both still render, with their two different lock reasons.
### New requirement from #1543
On drop, the **real** allowed set for the one card being dropped is fetched and decides the target. The narrowed-workflow case is covered in both directions: the advertised-but-invalid target is refused without a doomed write, and the hidden-but-valid one succeeds. **403 and 409** both roll back identically with the designed localised copy.
### Two bugs found and fixed on the way
- The board rendered ten empty columns while its query was still *disabled* by auth — neither loading nor successful — which reads as "this project has no tickets". Regression test added.
- The keyboard move path dead-ended: choosing a target destroys the row focus was standing on. `TransitionControl` now moves focus to the confirm step.
### Three deliberate deviations from §KB, for review
1. `TransitionControl` gained a compact `variant="card"` trigger. §KB.9 asks for a 24px icon button on the card *and* says the molecule needs no change — both cannot hold, since its own trigger is `h-10 min-w-56`. Menu, confirm, Esc-close and focus-return are shared verbatim.
2. dnd-kit's KeyboardSensor is **not** bound to the card: its Space/Enter would hijack the card's own activation. §KB.9 already names the Move menu the primary path and the sensor a bonus; the Move menu is fully keyboard-operable and asserted with keys alone.
3. No `--board-chrome` variable: a `shrink-0` toolbar over a `min-h-0 flex-1` scroller single-sources the offset by construction, which is what the token existed for.
### Scope
Polling, manual refresh, freshness and the card-move animation (§KB.7/§KB.8) belong to **#1547** and are not in this PR; their i18n keys land with them.
### Gates
`npx vitest run` → 1024 passed / 154 files · `npx tsc --noEmit` clean · `npx eslint .` clean · `npm run build` compiled successfully (the `/admin` `ENVIRONMENT_FALLBACK` prerender log is pre-existing and non-fatal). Node 20.20.2.
RA Updated by Redmine Admin 1 day ago
- Status changed from QA to In development
## Code review: FAIL — back to In development
PR https://github.com/omdev-tech/PipeLiner-Client/pull/98 is not shippable yet. Four user-visible paths are dead or silent. B1 and B4 independently re-confirmed.
**What is strong and must survive the fix:** the reviewer found **no client-side permission decision anywhere** — no role comparison, no status ordering, no derived capability boolean. `isCardDraggable` reads `can_move_forward` with a closed default, `dragTargets` takes the allowed set as given, `board_read_only` is used as a field and never inferred. The i18n work is real French with mirrored ICU shapes. `New request` and `KnowledgeStrip` genuinely survived the rail's removal, asserted by clicking the real controls.
### B1 — Blocked is unreachable from the Move menu
`useBoardMove.ts:119` puts the locked-column guard at the top of `move()`, which is shared by the drag path *and* the menu. A CTO choosing Blocked gets `"inert"`: no request, no error, no announcement, menu stays open. §KB.4b's argument — blocking is a decision, not a gesture, so it stays reachable through the menu — is dead code.
### B2 — the Move menu renders a stale target list it can never update
`targetsFor()` reads `allowed.peek()`, a non-subscribing `getQueryData`, and nothing mounts a query on that key. In the exact case `useCardAllowedStatuses` was written for — developer, narrowed workflow, canonical and real sets **disjoint** — the menu offers only the target that will fail, and the one that would work is never offered anywhere. Fails silently.
### B3 — focus return on closing a ticket is broken
`lastTaskTrigger` holds a DOM node, but the board now unmounts entirely behind the detail takeover, so the node is detached and focus falls to `<body>`.
### B4 — dnd-kit's a11y layer left at English defaults, documenting a sensor that is not bound
`announcements` is not overridden, so dnd-kit announces *"Picked up draggable item 501."* in English, **assertively** — a third live region where §KB.9 allows two. And `board.dragInstructions` is `aria-describedby`'d onto every card saying *"Press Space to pick up the ticket"* while only `PointerSensor` is registered: a screen-reader user presses Space and the card's `onClick` replaces the board with the detail.
### Also required
**S1** §KB.4c optimism is not implemented — `pending` only restyles the card in its *origin* column, producing exactly the "reads as rejected, then a spurious second animation" §KB.4c departed from §IR.5 to avoid. **S2** two quick moves clobber each other's pending marker and error. **S3** counts are wrong under the tab filter and the cap — a Bug pill can read 0 while bugs exist below the cap. **S4** `useCardAllowedStatuses` never revalidates. **S5** the contract greps are weaker than their names claim, and #1547's new files would escape them silently.
### Accepted deviations
`variant="card"` and the dropped `--board-chrome` are both accepted — the latter verified to single-source the offset with no compensating magic number. Dropping `KeyboardSensor` is accepted **once B4's copy is fixed**.
### Two §KB self-contradictions to resolve later
Toolbar DOM order: §KB.1a says tabs → knowledge → Columns → New request; §KB.9's tab-order sentence says New request first. And the priority scale keys on `position` 1–5, assuming the stock five-entry Redmine enumeration.
Fix in progress on the same branch and PR.
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
## Merged — review passed on round 2
PR https://github.com/omdev-tech/PipeLiner-Client/pull/98 merged to `dev` (`449a83f`, +5733/−1213, 38 files). Gates: vitest **1065 passed** / 156 files, tsc, eslint, build all clean; Jenkins PR-98 green.
All four blockers fixed, each with a test the reviewer could trace back to the original defect rather than to a restatement of the new code:
- **B1** — the locked-column guard moved out of the shared `move()` into `boardDrop.ts`, on the drag path only. Paired tests: a *drag* onto Blocked never reaches `move()`; the same card via the *Move menu* does POST `{status_id: 14}`. §KB.4b's escape hatch is live again.
- **B2** — `useCardAllowedStatuses.peek` deleted; `useBoardMove` holds a real `useQuery` on the engaged card, so an already-open menu re-renders when the real set lands. The disjoint narrowed-workflow case now completes.
- **B3** — remembers the issue id and refocuses the re-mounted card via `data-issue-id`. Verified to fail against the old implementation.
- **B4** — `announcements` silenced across all four phases (dnd-kit calls them unconditionally); `dragInstructions` rewritten in both catalogs to describe the Move button, which is the control that actually exists. The test drives a real pointer drag and asserts every `[aria-live="assertive"]` node stays empty.
Plus S1 (optimism now drawn from `applyPendingMoves`, with tests that render a board and assert both position and column counts), S2 (`pending` keyed by issue id, three independent guards against a stale-`status_id` third drag), S3 second half, S4, S5, and the mirrored Cancel focus bug.
### Two improvements beyond the review
**The frontend's Backlog strip was removed** — a second copy of the credit gate, which §IR.3 warns against. The reviewer checked the server rather than taking it on faith and confirmed the guarantee got *stronger*: Backlog is unreachable by three independent server facts (`may_set_status` refuses it for every role including admin; `FORWARD_ORDER` cannot even enumerate it; `TransitionTask` re-checks on write). One implementation, three readers, instead of two implementations that could drift.
**The contract greps went from decoration to teeth.** The sweep is now discovered by pattern with a canary asserting its own anchors, so a regex that stops matching fails loudly instead of passing vacuously. It also disproved the review's claim that `internal_role` appears nowhere under `src/` — there are six legitimate readers, including §IR's documented rendering hint in `TaskDetailPane`. The pin is an exact sorted `toEqual`, so a seventh appears in the diff as a named failure.
### Known, accepted, not blocking
Three cosmetic residuals: a lift-and-drop inside the allowed-set RTT loses the disjoint benefit on the drag path (self-consistent — the column never lit up); concurrent menu moves on different cards share one `errorKey` slot; the `internal_role` allowlist is per file rather than per occurrence.
### Carried elsewhere
The tab-pill undercount is **#1554** (per-category counts must come from the server — not fixable client-side, tickets below the cap are not in the response at all). The priority scale's assumption of a five-entry Redmine enumeration is commented in place.
RA Updated by Redmine Admin 1 day ago
RA Updated by Redmine Admin 1 day ago
- File qa-1546-01-board-en.png qa-1546-01-board-en.png added
RA Updated by Redmine Admin 1 day ago
- File qa-1546-02-board-fr.png qa-1546-02-board-fr.png added
RA Updated by Redmine Admin 1 day ago
RA Updated by Redmine Admin 1 day ago
RA Updated by Redmine Admin 1 day ago
RA Updated by Redmine Admin 1 day ago
## QA smoke test — PASS. Stays in QA.
Scope: epic **#1511** as a whole (all eight children, master build #109, commit `c784f95`) against **https://pipeliner.omdev.tech**. No acceptance criterion came back KO, so **no child is implicated** and nothing is sent back.
### ⚠ Read this first — how the evidence was obtained
**The gstack `/browse` daemon is not installed on this machine** (`~/.claude/skills/gstack` does not exist; `/usr/bin/browse` is a symlink to `xdg-open`). **No prod client credentials were available**, so **there was no authenticated production session**. I did not invent browser evidence. Instead, three real layers:
- **L1 — live production.** A real Chrome 150 against prod: `/projects/1/ticketing` → HTTP 200, the board chunks are served, `/api/auth/me` and `/api/projects/1` → 401, redirect to `/login`. Backend children live and auth-gated (401, never 404): `/api/auth/me/board-columns/1` (#1544), `/api/projects/1/tasks?mode=board` (#1542). The deployed chunks carry the board code and the **real French catalog** (`1srf4qgx5z11l.js`: "Tableau des tickets", "reconnexion en cours", "Dépôt impossible"; `2_9sp7epp_tud.js`: `board_read_only`, `can_move_forward`, `has_more`, `cursor-grabbing`, `snap-x`).
- **L2 — the shipped components rendered from commit `c784f95` and screenshotted under the deployed production CSS chunk.** This is where the four PNGs of the board come from. Real shipped code, real production styles — but not a live authenticated session.
- **L3 — the full suite on the exact prod commit.** `npx vitest run` → **163 files / 1180 tests, all passed**. Board subset re-run verbosely: **15 files / 216 tests**.
### Acceptance criteria
| # | Check | Owner | Result |
|---|---|---|---|
| 1 | `/ticketing` is a Kanban board, canonical stage order, Shipped hidden | #1546 | **OK** |
| 2 | Cards: title, assignee, priority, parent; no chip for Normal/null, still in the a11y name | #1542/#1546 | **OK** |
| 3 | A failed poll degrades, never blanks the board | #1547 | **OK** |
| 4 | Tab pills count the project; beyond-cap note, no contradiction | #1554 | **OK** |
| 5 | Backlog + Blocked render, reject every drop, zero network | #1546 | **OK** |
| 6 | Move menu reaches Blocked; keyboard path works with keys alone | #1546 | **OK** |
| 7 | Manual refresh refetches, shows busy, cannot double-fire | #1547 | **OK** |
| 8 | Column filter persists across a full reload | #1544 | **OK** |
| 9 | French is a real translation, freshness line + relative time included | #1546 | **OK** |
| 10 | `New request` and the KnowledgeStrip reindex control still reachable | #1546 | **OK** |
**Highlights on the two named regressions.**
- **#1547's blocker is fixed** — `keeps the cards on screen when a later poll fails`, `keeps a first-time client's guidance panel when a later poll fails`, and the full-region error still appears only when there is genuinely nothing to show. The amber line renders: "Updated 4 minutes ago — reconnecting" / "Actualisé il y a 4 minutes — reconnexion en cours".
- **#1554's contradiction is resolved** — Bugs pill = 12 with zero cards in view renders *"This tab has 12 tickets, beyond the most recent ones each column loads."* and **not** "No bugs reported yet." The two never co-occur (`does not tell a client their twelve bugs do not exist`).
Also confirmed: the earlier dead paths are alive — Blocked is reachable from the Move menu (`MOVES a card to Blocked when the menu asks`), and the Move menu now updates to the real allowed set. Priority label derives from `position` (4 → Urgent, 5 → Immediate), never `key`/`name`, per the #1545 amendment.
### What I could NOT test, and why
1. **No authenticated prod session** (no credentials; the secrets store is out of bounds). So a real mouse drag against the real API, a real 403/409 rollback, the real network panel on a locked-column drop, and a real page reload of the column filter rest on the prod-commit suite rather than on prod runtime.
2. **Drag was deliberately not exercised on production.** The only way to drag on prod would be to move a real client's live ticket. I stopped rather than mutate client data. **To close item 5 and the drag half of item 6 against the live API, a disposable ticket on a throwaway project is needed.**
3. The language selector in the FR screenshot still reads "English" — an artifact of my harness forcing the locale through the intl provider, **not** a product defect; the board content beneath it is fully French.
### Observation (cosmetic, no owner, not a failure)
Two freshness/refresh controls share the screen: the pre-existing project header "Updated 2 hours ago | Refresh data" and the new board toolbar "Refresh | Updated just now". They report different things but read as redundant side by side. Worth a design pass.
### Attachments
`qa-1546-technical-log.txt` (full log, secrets-filtered) · `qa-1546-01-board-en.png` · `qa-1546-02-board-fr.png` · `qa-1546-03-tab-pill-beyond-cap.png` · `qa-1546-04-freshness-reconnecting-fr.png` · `qa-1546-05-prod-live-auth-gate.png`