Project

General

Profile

Actions

Feature #1554

closed
RA

Feature #1511: Replace ticket view with Kanban board for CTO and assigned developers

API: per-category counts on the board payload, so the tab pills stop undercounting

Feature #1554: API: per-category counts on the board payload, so the tab pills stop undercounting

Added by Redmine Admin 1 day ago. Updated 1 day ago.

Status:
Shipped
Priority:
Normal
Assignee:
-
Start date:
08/03/2026
Due date:
% Done:

0%

Estimated time:
spec_ref:

docs/superpowers/specs/2026-08-03-kanban-board-design.md

build_status:
build_number:
ci_run_url:
scan_status:
scan_report_url:
deploy_status:
preprod_url:
deployed_at:
branch:
feat/1554-board-category-counts
pr_url:
https://github.com/omdev-tech/PipeLiner-Client/pull/100
security_key:
severity:
paused:

Description

**Surface:** backend (`api/`)
**Depends on:** #1542 (merged)
**Blocks:** the Feature/Bug/SecOps pill counts being correct in #1546
**Found during:** #1546 review

### The regression, and why this epic owns it

The ticketing screen's Feature / Bug / SecOps tab pills (#85) show a count per category. They are computed client-side by `tabCategoryCounts` over the board payload.

That worked while the route used `mode=list`, which returned every ticket. #1542 switched it to **board mode, capped at 50 per status** — so the pills now count only what survived the cap.

**Concrete failure:** a project with 60 tickets in Spec, 12 of them bugs, all ranked below the top 50. The **Bug pill reads 0** while twelve bug tickets exist. A client clicks Bug, sees an empty board, and concludes their bug reports were lost.

This is a regression introduced by this epic, not pre-existing — hence a child of #1511 rather than a parked follow-up. It carries no complexity score of its own; #1511 is pinned at 7/10 and already charged.

### Why it cannot be fixed in the frontend

The tickets are not in the response at all. No amount of client-side counting can recover them. #1546 has made the pills and the column headers derive from the same slice, so they at least never contradict each other on screen — but both are still wrong against the project's real totals.

### What to build

Return per-category totals on the board payload, computed server-side over the **whole** project before the per-status cap is applied — the same "count before you truncate" discipline the sort already follows.

Shape is yours to choose; the frontend needs Feature, Bug and SecOps totals for the project, honouring the caller's D2 visibility scope (a developer's pills must count only the tickets a developer can see). Note the existing convention: `tabCategoryCounts` excludes Shipped from the pill counts, since a shipped ticket is done and must not inflate what the client reads as open work — preserve that.

## Acceptance criteria
- [ ] The board payload carries per-category counts (feature / bug / secops) computed over the whole project, not over the capped slice.
- [ ] Counts respect the D2 visibility matrix: a developer's counts include only tickets they can see, a client's include the whole project, CTO/admin all — parametrised over every viewer kind.
- [ ] Shipped is excluded from the counts, matching the existing `tabCategoryCounts` behaviour (regression test).
- [ ] Test the exact failure above: 60 tickets in one status, 12 of them bugs ranked below the cap, and the bug count reads **12**, not 0.
- [ ] No new per-ticket call and no N+1 — a call/query-count assertion, as on the rest of this endpoint.
- [ ] A degraded or absent aggregator still yields a schema-valid 200 with the counts zeroed or null, never a 500.
- [ ] `src/features/tasks/types.ts`'s `tabCategoryCounts` is updated to read the server counts, and its stale client-side derivation is removed rather than left as a fallback that could silently disagree.
- [ ] Gates green: `python -m pytest -q` (**run serially**), `ruff check .`, `mypy src`; and the frontend gates for the consuming change.

RA Updated by Redmine Admin 1 day ago Actions #1

  • Status changed from Spec to In development

RA Updated by Redmine Admin 1 day ago Actions #2

  • Status changed from In development to QA
  • branch set to feat/1554-board-category-counts
  • pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/100

PR ready for QA: https://github.com/omdev-tech/PipeLiner-Client/pull/100 (base `dev`, branch `feat/1554-board-category-counts`).

**Backend.** `count_by_category` (domain) counts one number per tab over the viewer's whole scoped project **before** `build_board_columns` caps each column — the same "count before you truncate" discipline the sort already follows. `TaskBoardView.of` derives it next to `capabilities` and `read_only`, so board mode and list mode cannot answer the pills differently. It ships as `TaskBoardOut.category_counts` (`CategoryCountsOut`: three zero-defaulted ints), so a degraded or absent aggregator returns a schema-valid 200 with zeroes, never a 500 (gotcha #2). Scope is the D2 matrix's, untouched: a developer's counts cover their own queue, a client's the whole project, CTO/admin everything — a count is a disclosure too. No new call and no N+1: the counts come off the ticket list the board already fetched, so a 300-card board is still one aggregator read and zero Redmine reads.

**Frontend.** `src/features/tasks/types.ts`'s `tabCategoryCounts` now reads the server's counts and keeps **no** client-side fallback — a fallback would be a second answer that disagrees with the first in exactly the capped case, silently, and only on projects large enough to hit the cap. `app/projects/[id]/ticketing/page.tsx` feeds it `board.data` instead of the groups. `filterBoardByCategory` is untouched, so #1547's "+N more" arithmetic is unaffected: column headers still count what is shown, the pills now count the project.

**Acceptance criteria.** All met. The exact reported failure is tested at three levels (domain, use case, HTTP) plus a page-level test that the Bug pill reads 12 when the payload contains no bug card; Shipped exclusion has its own regression test; the D2 matrix is parametrised over ordinary client / developer / CTO / admin in both `list` and `board` modes; there is a call-count assertion; and the degraded-aggregator envelope is asserted.

**Mutation-verified.** Every guard was confirmed to go red against a deliberate break: counting the capped columns instead of the input (3 tests red), excluding Blocked instead of Shipped (12 red), counting the unscoped project in `BuildTaskBoard` (2 red), reinstating the client-side fallback (1 red), and reverting the page to counting `allGroups` (1 red). `boardContract.test.ts` was not modified or weakened.

**Docs.** §KB.1a of the design system amended: the pills count the project, the column headers count the slice.

**Gates green.** `pytest -q` 1276 passed / 2 skipped, run serially and green **twice** on one ephemeral pgvector database; `ruff check .` clean; `mypy src` clean. `vitest run` 1178 passed; `tsc --noEmit`, `eslint .` and `npm run build` all clean.

RA Updated by Redmine Admin 1 day ago Actions #3

## Merged — review passed

PR https://github.com/omdev-tech/PipeLiner-Client/pull/100 merged to `dev` (`8359846`). Gates both sets: pytest **1277 passed** / 2 skipped (serially, green twice on a fresh ephemeral DB), ruff and mypy clean; vitest **1180 passed**, tsc, eslint, build clean. Jenkins PR-100 green.

**No blocking findings.** The reviewer pushed hard on the one real risk — whether a count can disclose the existence of a ticket the viewer cannot open — and could not break it: `category_counts` has exactly one producer (`TaskBoardView.of`) with two call sites, both fed by `visible_tasks`; no response caching exists anywhere in the API; and MCP's only count-shaped output was already scoped and untouched. It independently re-ran the D2-leak mutation and confirmed a developer's counts go from `{bug:1}` to `{feature:2,bug:2,secops:1}` when `mine` is swapped for `tasks`.

### The half this ticket had left undone
The pill read 12 while the copy beside it still said *"No bugs reported yet."* — `emptyCategory` was derived from the cards, not the count. Before the change the screen was consistently wrong; after it, it contradicted itself. That sentence is the one the ticket exists to end, quoted six times in its own docstrings as justification.

Now gated on the server number, with a new state given its own copy rather than silence: `tasks.board.categoryBeyondCap`, both catalogs, identical ICU plural shape — *"Cet onglet compte # tickets, au-delà des plus récents que chaque colonne charge."* Mutation-verified in both directions.

### Design-system drift, inherited and now closed
The amendment cited §KB.6e for the opposite of what §KB.6e said. The drift arrived with #1547, which amended no doc; this ticket is what asserted the new rule. `:6215` and the §KB.6e bullet now carry the three-number model — **header = what is shown, `+N more` = what was never fetched, pill = the whole project** — each marked with the ticket that changed it.

Also taken: `counts[task.category]` raised `KeyError` on a category outside the three literals — a 500 on the board route. Unreachable from a correctly-typed caller, but stricter than the frontend predecessor it replaced and against the epic's "never a 500" rule.

### On the ticket's own arithmetic
The scenario as written ("60 tickets, 12 bugs, all below the top 50") is impossible — 48 features cannot fill a cap of 50. The reproduction uses 62 (50 features + 12 bugs), which the reviewer confirmed is provably the smallest faithful one, documented in the test docstring rather than left as a silent deviation.

**Epic #1511 is now code-complete: all seven original children plus this one are merged to `dev`.**

RA Updated by Redmine Admin 1 day ago Actions #4

  • Status changed from QA to Shipped

Shipped to production in master build #109 (`c784f95`). PR #100.

Actions

Also available in: PDF Atom