From 0a97cc49d64e98098c9788e8d53caa12d5c47171 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 22 Jun 2026 21:54:22 +0800 Subject: [PATCH] feat(kanban): swimlanes (2-D grouping in the board view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional second grouping dimension rendered as horizontal swimlane rows, so the board becomes a lane × column grid (e.g. priority lanes × status columns). A pure presentational layer over the shared model — works on both the file board and the DB board (both render BoardSurface). No backend/migration/WS changes. - shared/lib/board.ts: `swimlaneBy` config field + partitionSwimlanes() (laneValue → columnValue → cards), reusing groupValueOf/effectiveColumns for lane values. - shared/components/board/BoardSwimlanes.tsx: the 2-D grid view. A read-friendly overview; clicking a card opens the same BoardPeek (where its status / swimlane attribute can be changed). Pointer drag stays in the 1-D board view by design. - BoardSurface: a "Swimlane" toolbar select (None / the non-column dimensions) and a render branch that swaps in the grid when a swimlane is chosen. - Thread swimlaneBy through the three platform config types (desktop BoardConfig, web file-board BoardConfigJSON, web DB-board view). - tests/unit/boardSwimlanes.spec.ts + i18n (zh translations). Verified: root+web tsc clean, unit tests pass, and a throwaway harness confirmed the priority×status grid (cards in the right cells, overdue red, task counts, empty cells), card-click → peek, and that switching Swimlane→None restores the normal drag-enabled board. Co-Authored-By: Claude Opus 4.8 --- .../jtype-web/frontend/src/pages/Kanban.tsx | 1 + .../frontend/src/pages/WebBoardView.tsx | 2 + shared/components/board/BoardSurface.tsx | 42 ++++++ shared/components/board/BoardSwimlanes.tsx | 125 ++++++++++++++++++ shared/components/board/index.ts | 1 + shared/i18n/locales/en/messages.mjs | 2 +- shared/i18n/locales/en/messages.po | 17 +++ shared/i18n/locales/ja/messages.mjs | 2 +- shared/i18n/locales/ja/messages.po | 17 +++ shared/i18n/locales/ko/messages.mjs | 2 +- shared/i18n/locales/ko/messages.po | 17 +++ shared/i18n/locales/zh/messages.mjs | 2 +- shared/i18n/locales/zh/messages.po | 17 +++ shared/lib/board.ts | 29 ++++ src/components/BoardView.tsx | 1 + src/lib/types.ts | 2 + tests/unit/boardSwimlanes.spec.ts | 36 +++++ 17 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 shared/components/board/BoardSwimlanes.tsx create mode 100644 tests/unit/boardSwimlanes.spec.ts diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx index 225c182..d476410 100644 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ b/services/jtype-web/frontend/src/pages/Kanban.tsx @@ -148,6 +148,7 @@ export function Kanban() { columns: board.columns.slice().sort((a, b) => a.position - b.position).map(c => ({ key: c.id, name: c.name, color: c.color, limit: c.wipLimit })), groupBy: (view.groupBy as BoardViewConfig['groupBy']) ?? 'status', viewType: view.viewType ?? 'board', + swimlaneBy: view.swimlaneBy, colorColumns: view.colorColumns, doneColumn: view.doneColumn, } diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index 3cc3c9c..2728e1c 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -23,6 +23,7 @@ type BoardConfigJSON = { doneColumn?: string colorColumns?: boolean viewType?: 'board' | 'table' + swimlaneBy?: 'status' | 'priority' | 'assignee' } function rand() { @@ -175,6 +176,7 @@ export function WebBoardView({ doneColumn: config.doneColumn, colorColumns: config.colorColumns, viewType: config.viewType, + swimlaneBy: config.swimlaneBy as BoardViewConfig['swimlaneBy'], groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status', } : { title: boardDir, columns: [] }, diff --git a/shared/components/board/BoardSurface.tsx b/shared/components/board/BoardSurface.tsx index 787f3f1..fbc0ea5 100644 --- a/shared/components/board/BoardSurface.tsx +++ b/shared/components/board/BoardSurface.tsx @@ -23,6 +23,7 @@ import { MagnifyingGlassIcon, FunnelIcon, BarsArrowDownIcon, + Bars3Icon, RectangleGroupIcon, XMarkIcon, TableCellsIcon, @@ -46,6 +47,7 @@ import { } from "../../lib/board"; import { BoardPeek } from "./BoardPeek"; import { BoardTable } from "./BoardTable"; +import { BoardSwimlanes } from "./BoardSwimlanes"; import type { BoardSurfaceProps } from "./types"; type DropTarget = { col: string; index: number }; @@ -110,6 +112,15 @@ export function BoardSurface({ () => effectiveColumns(config, cards, groupKey, t`Unassigned`), [config, cards, groupKey], ); + // Swimlanes: a second grouping dimension rendered as rows (must differ from + // the column dimension). Only meaningful in the board view. + const swimlaneKey: BoardGroupKey | null = + config.swimlaneBy && config.swimlaneBy !== groupKey ? config.swimlaneBy : null; + const swimlaneActive = viewType === "board" && !!swimlaneKey; + const lanes = useMemo( + () => (swimlaneKey ? effectiveColumns(config, cards, swimlaneKey, t`Unassigned`) : []), + [config, cards, swimlaneKey], + ); const vis = useMemo(() => visibleCardsFn(cards, search, filter), [cards, search, filter]); const assignees = useMemo(() => [...new Set(cards.map((c) => c.assignee).filter(Boolean) as string[])], [cards]); const allTags = useMemo(() => [...new Set(cards.flatMap((c) => c.tags.map((tg) => tg.label)))], [cards]); @@ -334,6 +345,25 @@ export function BoardSurface({ )} + {viewType === "board" && ( + + )}