Feature #1547
closedFeature #1511: Replace ticket view with Kanban board for CTO and assigned developers
Frontend: board polling, manual refresh and card-move animation
0%
docs/superpowers/specs/2026-08-03-kanban-board-design.md
Description
**Surface:** frontend (`src/`)
**Depends on:** "Frontend: Kanban board replaces the ticketing list view"
Decision **D3**: "real-time" for this epic means **polling only**. This narrows the epic's original AC ("tickets move instantly") to *"tickets move within one poll interval, with an animated transition, plus an on-demand manual refresh"*.
An SSE or websocket channel is **explicitly out of scope** and becomes a follow-up ticket. There is precedent for both approaches in the repo — SSE in `src/features/chat/useAssistantStream.ts`, polling in `src/features/projects/useProject.ts:26` and `src/features/usage/useHostMetrics.ts:24` — and we are deliberately taking the cheaper one for v1.
Reuse the existing `refetchInterval` convention rather than inventing a new one; the interval lives in **one named constant**. **Proposed: 30s** — confirm with the PO.
## Acceptance criteria
- [ ] The board query polls on an interval defined in one named constant and refetches on window focus; a fake-timer test asserts exactly one refetch per interval.
- [ ] Polling **pauses when the tab is hidden** and resumes on focus: a test dispatching `visibilitychange` asserts zero fetches while hidden and exactly one refetch on focus.
- [ ] No duplicate intervals after remount: a mount/unmount/mount cycle followed by one tick issues exactly one refetch.
- [ ] **In-flight drag protection, concrete test:** start a transition mutation and leave it pending; force a refetch that returns the server state with the card still in its origin column → the card stays at its optimistic position while the mutation is pending, and reconciles to server truth only after it settles. Both phases asserted.
- [ ] A card whose status changed server-side moves to its new column on the next poll with the animated transition from the design section.
- [ ] `prefers-reduced-motion: reduce` disables or reduces the animation — asserted under a mocked `matchMedia`.
- [ ] A manual refresh control triggers an immediate refetch, shows the designed loading state, and is disabled while a fetch is in flight — clicking it mid-fetch issues no second request.
- [ ] No `EventSource`, WebSocket or SSE endpoint is introduced (assertion in test or lint).
- [ ] Refresh-related strings exist in **both** `en.ts` and `fr.ts` with identical ICU placeholders.
- [ ] Gates green: `npx vitest run && npx tsc --noEmit && npx eslint . && npm run build`.
RA Updated by Redmine Admin 1 day ago
## Amendment after the design review (#1545)
**1. Polling must be suspended for the duration of a drag.** A poll that re-sorts a column under a moving pointer is the classic board bug — the drop lands somewhere the user did not aim. This is distinct from the existing in-flight-mutation AC, which covers the window *after* the drop.
New AC: while a drag is active, no refetch is issued; polling resumes when the drag ends, whether it was dropped or cancelled. Asserted with fake timers across a drag-start → interval-tick → drag-end sequence.
**2. §IR.5's "never optimistic" rule is departed from, deliberately and narrowly.** §KB.4c scopes it: the card stays where it was dropped in a `pending` state, but the pending state is an **overlay, never a cache write**, and nothing outside the board — hero badge, detail pill, pipeline rail — is ever optimistic. New AC: a test asserts no other surface reflects the un-settled move.
**3. The card-move animation keys off the card's status change between polls**, per §KB.8, and honours `prefers-reduced-motion` (already an AC).
Design section: `docs/design/design-system.md` §KB, motion spec in §KB.8.
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/1547-board-polling-refresh
- pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/99
## Frontend livré — PR https://github.com/omdev-tech/PipeLiner-Client/pull/99 (base `dev`)
Branche : `feat/1547-board-polling-refresh`, partie de `origin/dev` (l'épopée #1511 vit sur `dev`, rien n'est encore sur `master`).
### Ce qui est fait
**Polling seul (décision D3).** Cadence dans **une seule constante nommée** (`src/features/tasks/boardPolling.ts`, `BOARD_POLL_MS = 30_000`) ; le seuil « périmé » en est *dérivé* (3 ×), pour qu'un changement de cadence ne laisse jamais un avertissement qui mesure un intervalle disparu. Convention `refetchInterval` réutilisée, comme dans `useProject.ts` et `useHostMetrics.ts`.
- Rafraîchissement automatique sur l'intervalle **et** au retour du focus.
- **Onglet caché : zéro requête** (`refetchIntervalInBackground: false`), et exactement un refetch au retour. `staleTime: 0` sur le tableau, sinon la valeur globale de 30 s refuserait ce refetch.
- Aucun intervalle en double après démontage / remontage.
**Un poll ne se bat jamais avec un glisser-déposer.** Le polling est suspendu pendant toute la durée du drag, et repris à la fin — que la carte soit déposée **ou** le drag annulé (un tableau qui ne reprendrait qu'après un dépôt réussi cesserait de se rafraîchir définitivement au premier `Échap`, en silence). Testé avec des minuteurs simulés sur la séquence début → tick → fin.
**Un poll n'écrase pas un déplacement en vol.** Fenêtre distincte, qui s'ouvre *après* le dépôt. Le calque optimiste de #1546 maintient la carte à sa position ; les **deux phases** sont assertées de bout en bout : un refetch forcé en plein transfert, renvoyant la carte dans sa colonne d'origine, ne la ressuscite pas là-bas, et la réconciliation n'a lieu qu'une fois la mutation réglée. Le même test vérifie que le cache de requêtes contient toujours la vérité serveur — c'est l'amendement 2 (« aucune autre surface ne reflète le déplacement non validé ») : le badge du héros, la pastille du détail et le rail de pipeline lisent tous ce cache.
**Animation §KB.8.** Trajet FLIP (220 ms, `ease-out`, transform uniquement) sur la carte déplacée *et* sur celles que son départ décale ; anneau d'arrivée de 900 ms. Sous `prefers-reduced-motion: reduce` : aucune animation créée, et l'anneau est **remplacé** par un liseré gauche statique qui tient jusqu'au poll suivant — supprimer l'animation ne doit pas supprimer l'information. Assertions sous `matchMedia` simulé. L'annonce est indépendante du mouvement et se réduit à un décompte au-delà de trois cartes.
**Rafraîchir (§KB.7).** Bouton `secondary sm` + libellé (jamais icône seule), `aria-busy` et `aria-disabled` — jamais `disabled`, pour garder sa place dans l'ordre de tabulation ; libellé et largeur inchangés pendant le chargement. Un clic en pleine requête n'en déclenche **aucune seconde**. Ligne de fraîcheur `Actualisé …` dans un `<time dateTime title>`, qui passe en **ambre** (jamais rouge — gotcha #6) au bout de 3 intervalles sans réponse, sans jamais vider le tableau.
**Aucun flux.** Ni `EventSource`, ni WebSocket, ni SSE : asserté à la fois par un balayage de sources dans `boardContract.test.ts` et à l'exécution dans le test de la page.
### Accessibilité
Quatre messages, toujours **deux** régions live (§KB.9). La région polie est désormais *dérivée* du plus récent parmi drag / déplacements par poll / périmé / rafraîchissement terminé, au lieu de quatre effets qui s'écrasaient. Le contrôle Rafraîchir n'en possède aucune — asserté.
### i18n
Huit nouvelles clés `tasks.board.*` dans `en.ts` **et** `fr.ts`, vraie traduction française, mêmes marqueurs ICU et mêmes branches plurielles.
### Écart connu, signalé et non masqué
Les fondus d'**entrée / sortie** du §KB.8.3 (carte qui apparaît sur le tableau, carte qui en disparaît) **ne sont pas** implémentés. La moitié « sortie » exige un mécanisme de rétention au démontage, et ne livrer que l'entrée laisserait un vocabulaire incohérent — précisément ce que le §KB.8.3 cherche à éviter. À traiter dans un ticket dédié.
### Portes de qualité
`npx vitest run` **1151 tests, 163 fichiers** ✓ · `npx tsc --noEmit` ✓ · `npx eslint .` ✓ · `npm run build` ✓ (le log `ENVIRONMENT_FALLBACK` de `/admin` est préexistant et non bloquant).
Passage en **QA**.
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/99. The reviewer built a real worktree, installed `@dnd-kit` and ran the actual gates (**1151/1151, tsc, eslint** all pass), then mutation-tested 13 guards: **11 bite, and no test in the diff was weakened.** The mutation discipline claimed on this ticket largely holds up.
Two blocking findings, both confirmed independently.
### B1 — a single failed poll blanks the board, which is precisely what §KB.7 forbids
`app/projects/[id]/ticketing/page.tsx:334` — `error={board.isError}`. TanStack v5 sets `status: "error"` on a **background** refetch failure while keeping `data`. Before this ticket the board fetched once, so `isError` implied "no data"; polling makes background failure routine. A 401 on session expiry or a 403 blanks a working board on the *first* poll, unattended, since 4xx is not retried.
§KB.7: *"the board is not blanked and no error view replaces it… the full-region error block is reserved for the case where there is no data to show."* The amber-stale design at 90 s is decoration for a state the user reaches ~89 s **after** the board has already become an error page. The existing test named *"the board is NOT blanked"* only checks `query.data` is defined and never `isError`.
### B2 — the drag suspension covers only `refetchInterval`; two refetch paths get through
Both reproduced by probe.
- The refusal path clears `pending` **before** a fire-and-forget `invalidateQueries`, so `isMoving` goes false, drag re-enables, and the refetch lands mid-drag. Reached by: drop → 403 → drag again. Exactly the bug this ticket exists to prevent, through the one path that doesn't await.
- `refetchOnWindowFocus: true` is unconditional and `staleTime: 0` guarantees it fires — lift with the keyboard, alt-tab, return, and the column re-sorts under the pointer.
### Also required
**A failed manual refresh announces "Board refreshed."** — `refreshedAt` is stamped in a `.finally()`, and since the polite sentence is `max(at)` that false stamp beats the stale warning. **The FLIP measures viewport coordinates and never re-measures on scroll**, so scrolling 400 px and then waiting for a poll slides the entire board from off-screen. **A hung invalidation now suspends polling forever**, not just the spinner, because `isMoving` gates the interval. **The `staleTime: 0` mutation survives** because the test QueryClient is a bare one whose default is already 0 — so the focus-refetch test proves nothing about production. **`formatRelativeTime` is half-fixed**: it uses the runtime locale rather than the app locale, and `justNow` was threaded for one caller of six.
Also a **second §KB.8 gap** beyond the declared §KB.8.3 one: the reduced-motion marker is cleared only by a poll that relocates something else, never by a quiet poll or by focus/activation as §KB.8 requires.
### Confirmed good — must survive the fix
The two-window suspension design, the `wasPending` frame that stops a settled user-move being re-announced as a poll-move (verified end to end), the derived single polite sentence, and the new duplicate-cadence and `EventSource`/`WebSocket` sweeps.
Fix in progress on the same branch.
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
## Merged — review passed after three rounds
PR https://github.com/omdev-tech/PipeLiner-Client/pull/99 merged to `dev` (`431a1f0`, +3694/−121). Gates: vitest **1173 / 163 files**, tsc, eslint, build clean; Jenkins PR-99 green. The reviewer independently re-ran all four gates and mutation-tested 16 guards.
### The blocker this ticket created for itself
`error={board.isError}` was correct until today: the board fetched once, so `isError` implied "no data". Polling changes what the flag means — TanStack keeps `data` on a **background** failure — so the first failed poll replaced a working board with "Couldn't load tasks.", and a 401 on session expiry did it unattended. §KB.7 forbids exactly that, and the amber-stale design at 90 s was decoration for a state reached 89 s *after* the board had already blanked. Now `board.isError && board.data === undefined`, guarded in both directions: reverting fails the 401 test, forcing `error={false}` fails the never-loaded test.
### Three sibling paths of the same bug
Suspension originally covered only `refetchInterval`, so a refetch still landed mid-drag via (a) the server-refusal path's unawaited invalidation, (b) `refetchOnWindowFocus`, and (c) the **client-side** refusal branch, which survived the first fix. That last one matters most: the hook's own docstring says the payload set and the real set are **disjoint** for a developer on a narrowed workflow, so it is the ordinary path for exactly the user this ticket's hardest work serves.
The fix is a `settling` flag folded into `isMoving`, deliberately **not** a pending marker — a pending marker is the optimism overlay, and drawing a refused card in the column it was just refused from would be worse than the race it closes. A test asserts `pending.size === 0` after a client refusal so nobody can later "simplify" it and stay green. The bare `await settleBoard(...)` that looks like a fix also fails the test, because `isMoving` stays false during the await.
### Two tests that proved nothing, now closed
`onSeen` was entirely unguarded — deleting the wiring from both `TicketBoard` and `BoardCard` left 1173 green, so §KB.8's "until the card is focused or activated" was tested only through the hook harness's own handler. Now driven through the real card under mocked reduced motion (with motion allowed the ring expires on a timer, so the test could not distinguish dismissed from timed-out), with each of the three links failing independently.
`BOARD_SETTLE_TIMEOUT_MS` had no magnitude test — advancing by `TIMEOUT + 100` meant raising it to ten million ms stayed green, so a ten-minute freeze could ship. Now asserted between 2 s and 15 s.
### Also fixed
A failed manual refresh announced *"Board refreshed."* — `refetch()` **resolves** on failure, so `.finally()` could never distinguish; `lastRefresh: {at, ok}` plus a `refreshFailed` sentence, and `SpokenRank` makes stale outrank refresh on an exact tie. The FLIP measured viewport coordinates, so scrolling 400 px and waiting for a poll slid the whole board from off-screen; it now measures in content space, verified across board scroll, independent column scroll and resize. A hung invalidation could suspend polling forever; bounded by the settle timeout. `formatRelativeTime` used the **runtime** locale rather than the app locale — now `useRelativeTime()` across all six callers, both language ladders verified end to end, provider coverage total. The reduced-motion cue had no quiet-poll or focus clear. `firstRun` had B1's bug in miniature.
### Known, accepted
§KB.8.3's enter/exit fades are not implemented — the exit half needs mount retention, and enter-only would produce the teleport-into-the-gap §KB.8.3 exists to prevent. When the settle timeout fires, `pending` clears with a refetch still in flight; a board one fetch behind beats a board frozen until reload.
**#1511's original seven children are now all merged.** Only #1554 (per-category tab counts) remains before the epic is code-complete.