4.2 Backlog UI (groom + rank + assign to sprint)
The Backlog / sprint-planning screen — Motir's clone of the Jira backlog. A new nav destination (/backlog, project-scoped) showing the project's ranked backlog (issues with sprint_id IS NULL, in backlog_rank order) beneath a stack of sprint-planning containers, where a team grooms and plans: drag-to-reorder (rank), drag an issue into a sprint (assign), multi-select + bulk-move, inline create, and the start-sprint entry point. It is the READ/WRITE face of the data model Story 4.1 shipped — 4.2 owns no entity and no rules, it BINDS the bounded API to a real grooming UI.
What 4.2 owns vs. what Story 4.1 already shipped (the clean seam). Story 4.1 owns the persistence + rules: the Sprint entity + CRUD (4.1.3), the issue↔sprint association + backlog_rank single-row writes, and the bounded, cursor-paginated getBacklog(projectId, { cursor, limit }) + getSprintIssues(sprintId) reads + listByProject (4.1.4). 4.2 owns the UI that binds to them + ONE thin backend composition (4.2.2: atomic bulk assign + create-into-sprint over 4.1.4's primitives). 4.2 does NOT re-implement ranking, association, the sprint state machine, or the bounded reads — it consumes them.
Mistake-#32 resolution — the points/velocity SEAM (see the module header). Story points are Story 4.3 and velocity is Story 4.6, both numbered AFTER 4.2, so 4.2 reading them would be a forward-pointing dependency (a planning bug). It is NOT cut — it is re-owned: 4.2 renders a documented points seam (an estimate slot on each row, a committed-points slot in each sprint header) that Story 4.3 fills (inline estimate + per-sprint roll-up — 4.3 already scopes "roll-ups to sprint + epic level") and a velocity seam that Story 4.6 fills ("committed vs velocity"). This is the exact seam pattern Story 4.5 used to leave the burndown chart to 4.6. Every dependsOn here points backward (Story 4.1) or sideways (a 4.2 sibling) — the forward audit passes.
The two stacked regions (the Jira backlog layout, mirror rung 1). Top: zero-or-more sprint-planning containers, each a collapsible panel with the sprint name + state chip, the date range, an issue count, the committed-points SLOT (→ 4.3), a Start-sprint entry point (the start FLOW is Story 4.4 — 4.2 mounts/seams it, exactly as 4.5 seams Complete-sprint), its ranked issue rows, and an inline + Create issue row; plus a Create sprint affordance to add an empty planned sprint (4.1.3 createSprint). Bottom: the Backlog container — the ranked list of unassigned issues with its own count header ("N issues") and inline create. Both regions hold the SAME issue-row component.
The issue row. A backlog row composes the work-items row vocabulary (Story 2.x list): a drag handle, the issue-type icon (its --el-type-* hue), the key (PROD-42), the summary, an epic chip, the estimate SLOT (→ 4.3), the assignee avatar, the status Pill, a selection affordance, and a ⋯ context menu (move to sprint ▸, move to top/bottom of backlog, …). Rows are reused identically in the backlog and inside sprint containers (one global rank field, so a row drags between regions).
Drag (reuse the Story-3.2 dnd-kit contract, NOT a new one). Reorder within a region (writes backlog_rank via 4.1.4 rankIssue — a single-row keyBetween write, never an N-row renumber), drag a row from the backlog into a sprint (assign), and drag between sprints / back to the backlog. Built on the SAME @dnd-kit/core + @dnd-kit/sortable + boardMove.ts move contract the board (3.2) already ships, and on the SAME useRowWindow virtualization (3.2.5) so a drag out of a windowed list keeps its node. Writes are optimistic with snap-back on error (the 3.2 board contract). A drag that crosses projects is impossible here (the backlog is single-project), but the 4.1.4 same-project guard still backstops the assign write.
Multi-select + bulk (real grooming, atomic at scale). Click / shift-range / ⌘-toggle to select N rows; a selection bar offers bulk Move to sprint ▸ and Move to backlog, executed as ONE transaction via 4.2.2 (a bounded-batch bulkAssignToSprint / bulkMoveToBacklog), not N sequential single-issue calls (Jira moves multi-selections atomically; N round-trips would be slow and leave partial state on failure). Inline + Create issue creates straight into the backlog or a target sprint (4.2.2 createBacklogIssue — create + rank-append + optional assign in one tx).
Completeness / scale (finding #57 — the load-bearing non-functional axis). A real backlog is thousands of issues, so the list is lazy-loaded + virtualized, NEVER load-all: it binds to 4.1.4's cursor-paginated getBacklog (fetch a page, append on scroll via the existing load-more/useRowWindow machinery) and shows a bounded count header. Sprint containers bind to getSprintIssues (also paged-capable). Reordering and assigning stay O(1) single-row (or bounded bulk) writes against the fractional index. A backlog UI that fetched every row to render the list or summed every loaded card would be prototype-thinking — flag and forbid it (finding #57).
The real-product states. Empty backlog (no unassigned issues) → an EmptyState with a create CTA; no sprints planned → the sprint region shows only the Create-sprint affordance; loading → a backlog skeleton (reuse the 3.2.2 scaffold idiom); error → ErrorState with retry; scale → the bounded count header + virtualized list. Every state is drawn in 4.2.1, not improvised.
See all issues — the issue-navigator link (mirror rung 1, Jira "View in Issue Navigator"). The page-head toolbar carries a View all issues link that deep-links to the project's issue navigator — the Story-2.5 /issues List/Tree (every issue across the backlog AND all sprints, sortable/filterable/paginated). VERIFIED against Jira (June 2026): the backlog/board does NOT flatten its grouped planning view into a flat "all issues" list on the same page — it LINKS OUT to the navigator with the board filter applied. Motir already ships that navigator, so 4.2 reuses it via a plain <a> (no new view, no flat list duplicated here — "no complexity for nothing"); when Epic-6 board/saved filters land the link can carry the active filter query.
Nav + a11y + tokens. A new Backlog sidebar item + ⌘K entry (project-scoped, adjacent to Boards, with a nav.backlog i18n key); drag is keyboard-operable (dnd-kit keyboard sensor, per 3.2); selection + counts are read as text (not colour/shape alone — finding #35); colour via --el-*, shape via the element shape tokens (no Tier-0 --color-* / raw rounded-* — motir-core/CLAUDE.md).
Out of scope (Epic-4 siblings / Epic 6 / later): story-point estimation + the inline-estimate badge + the committed-points roll-up that fills the seam (Story 4.3); the velocity comparison that fills the velocity seam (Story 4.6); the sprint START / COMPLETE flows + scope-lock + carry-over + sprint report — 4.2 mounts/seams the Start-sprint entry point but the flow is Story 4.4; the Scrum BOARD view (Story 4.5); the sprint entity / association / rank writes / bounded reads themselves (Story 4.1 — consumed, not built); rich backlog filtering / quick filters / the filter builder (Epic 6 — the backlog ships its data-bound list; a saved-filter or field/operator filter layer is the Epic-6 search surface, not duplicated here — no complexity for nothing); board CRUD / multi-board nav (Story 3.7); cross-project backlogs (the backlog is single-project by 4.1's sprint.projectId model).
Verification
- Pull the Story branch,
pnpm install,pnpm prisma migrate dev(no 4.2 migration — the sprint +backlog_rankschema is Story 4.1),pnpm db:seed,pnpm dev. (Requires Story 4.1 merged so sprints + the bounded backlog/sprint reads exist to bind to.) - Design exists first:
design/backlog/backlog.mock.html+backlog-scale.mock.html+ a PNG export +design/backlog/design-notes.mdexist (subtask 4.2.1), built fromcomponents/ui/*+--el-*/element-shape tokens only, AA-safe, passing the render checklist — and the points/velocity/start-sprint SEAMS are named in the notes as filled by Stories 4.3 / 4.6 / 4.4. pnpm test— vitest (real Postgres) covers the 4.2.2 composition:bulkAssignToSprint/bulkMoveToBacklogmove every selected issue in ONE transaction (partial failure rolls back), the same-project guard rejects a cross-project member of the batch, the batch size is bounded, andcreateBacklogIssuecreates + appends abacklog_rank+ optionally assigns in one tx and records a 1.4.6 revision.pnpm test:e2e --grep backlog— Playwright drives a real grooming session: the/backlogpage renders the sprint containers + the ranked backlog; drag a row to reorder (itsbacklog_rankchanges, neighbours do not); drag a row into a sprint (it leaves the backlog and the sprint count increments); multi-select two rows and bulk-move them to a sprint atomically; inline-create an issue into the backlog and into a sprint; the Start-sprint entry point is present (the flow is 4.4).- Backlog render check: sign in as
zhuyue@motir.co, open themotirproject → the Backlog nav item leads to/backlog, which shows the sprint-planning container(s) above the ranked backlog list, each issue row with type icon / key / summary / epic chip / assignee / status, the empty estimate slot (the 4.3 seam, not a number yet) and the sprint header's empty committed-points slot (the 4.3 seam) + velocity slot (the 4.6 seam). Layout matchesdesign/backlog/backlog.mock.html. - View-all-issues check: the page-head toolbar shows a View all issues link that navigates to the project's issue navigator (
/issues, Story 2.5) — every issue across the backlog and all sprints in the sortable/filterable list; the backlog page does NOT rebuild a flat all-issues list (Jira's "View in Issue Navigator" mirror). - Rank check: dragging an issue between two neighbours writes a single
backlog_rank(4.1.4keyBetween) that lands it strictly between them; no other row's rank changes; the order survives reload. - Assign check: dragging a backlog issue into a sprint sets its
sprint_id(it disappears from the backlog list, appears in the sprint container, the counts update); dragging it back to the backlog restores it in rank order. - Bulk check: selecting multiple rows (click + shift-range + ⌘-toggle) and choosing "Move to sprint" moves them all atomically (one request); a forced mid-batch failure leaves NONE moved (transaction rollback), not a partial set.
- Create check: the inline "+ Create issue" row creates an issue into the backlog (ranked at the end) or directly into a sprint (assigned) in one action.
- Scale check (finding #57):
pnpm db:seed:large(a project with thousands of backlog issues) →/backlogrenders a bounded first page + a count header, lazy-loads more on scroll (theuseRowWindowwindow + cursor pages, never a load-all), drag still works out of the virtualized list, and the DOM row count stays bounded; matchesdesign/backlog/backlog-scale.mock.html. - States check: empty backlog →
EmptyState+ create CTA; no sprints → Create-sprint affordance only; loading → skeleton; error →ErrorState+ retry. - a11y check: the backlog + each sprint container are labelled landmarks; drag is keyboard-operable (dnd-kit keyboard sensor); selection state + counts read as text (not colour-alone, finding #35).