Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(sheets): kanban view with lanes and drag-drop (#73)' (#128) from feat/kanban-view into main

scott 931e6c30 73ef1ca5

+367
+192
src/sheets/kanban-view.ts
··· 1 + /** 2 + * Kanban View — card-based board grouped by a status/category column. 3 + * 4 + * Pure logic module: lane construction, card ordering, drag-drop moves. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export interface KanbanCard { 9 + rowIndex: number; 10 + /** Display title (first non-group column value or row number) */ 11 + title: string; 12 + /** Additional fields to show on the card */ 13 + fields: { label: string; value: string }[]; 14 + } 15 + 16 + export interface KanbanLane { 17 + id: string; 18 + /** Column value that defines this lane */ 19 + value: string; 20 + cards: KanbanCard[]; 21 + } 22 + 23 + export interface KanbanConfig { 24 + /** Column index to group by (the "status" column) */ 25 + groupByCol: number; 26 + /** Column index to use as card title */ 27 + titleCol: number; 28 + /** Additional column indices to show on cards */ 29 + fieldCols: number[]; 30 + } 31 + 32 + /** 33 + * Build kanban lanes from sheet data. 34 + */ 35 + export function buildKanbanLanes( 36 + cellValues: Map<string, unknown>, 37 + rowCount: number, 38 + config: KanbanConfig, 39 + colToLetter: (col: number) => string, 40 + colHeaders: string[], 41 + ): KanbanLane[] { 42 + const lanesMap = new Map<string, KanbanCard[]>(); 43 + 44 + for (let r = 1; r <= rowCount; r++) { 45 + const groupCellId = `${colToLetter(config.groupByCol)}${r}`; 46 + const raw = cellValues.get(groupCellId); 47 + const laneValue = raw !== null && raw !== undefined && raw !== '' 48 + ? String(raw).trim() 49 + : '(unassigned)'; 50 + 51 + // Build card 52 + const titleCellId = `${colToLetter(config.titleCol)}${r}`; 53 + const titleRaw = cellValues.get(titleCellId); 54 + const title = titleRaw ? String(titleRaw).trim() : `Row ${r}`; 55 + 56 + const fields: KanbanCard['fields'] = []; 57 + for (const colIdx of config.fieldCols) { 58 + const cellId = `${colToLetter(colIdx)}${r}`; 59 + const val = cellValues.get(cellId); 60 + fields.push({ 61 + label: colHeaders[colIdx] || colToLetter(colIdx), 62 + value: val !== null && val !== undefined ? String(val) : '', 63 + }); 64 + } 65 + 66 + if (!lanesMap.has(laneValue)) { 67 + lanesMap.set(laneValue, []); 68 + } 69 + lanesMap.get(laneValue)!.push({ rowIndex: r, title, fields }); 70 + } 71 + 72 + const lanes: KanbanLane[] = []; 73 + let idCounter = 0; 74 + 75 + for (const [value, cards] of lanesMap) { 76 + lanes.push({ 77 + id: `lane-${++idCounter}`, 78 + value, 79 + cards, 80 + }); 81 + } 82 + 83 + // Sort lanes: alphabetical, with (unassigned) last 84 + lanes.sort((a, b) => { 85 + if (a.value === '(unassigned)') return 1; 86 + if (b.value === '(unassigned)') return -1; 87 + return a.value.localeCompare(b.value); 88 + }); 89 + 90 + return lanes; 91 + } 92 + 93 + /** 94 + * Move a card from one lane to another. 95 + * Returns the new lane value to write to the groupBy column. 96 + */ 97 + export function moveCard( 98 + lanes: KanbanLane[], 99 + cardRowIndex: number, 100 + targetLaneId: string, 101 + targetIndex: number, 102 + ): { lanes: KanbanLane[]; newValue: string } | null { 103 + // Find source lane 104 + let sourceLane: KanbanLane | undefined; 105 + let cardIndex = -1; 106 + 107 + for (const lane of lanes) { 108 + const idx = lane.cards.findIndex(c => c.rowIndex === cardRowIndex); 109 + if (idx !== -1) { 110 + sourceLane = lane; 111 + cardIndex = idx; 112 + break; 113 + } 114 + } 115 + 116 + if (!sourceLane || cardIndex === -1) return null; 117 + 118 + const targetLane = lanes.find(l => l.id === targetLaneId); 119 + if (!targetLane) return null; 120 + 121 + // Remove from source 122 + const card = sourceLane.cards[cardIndex]; 123 + const newLanes = lanes.map(lane => { 124 + if (lane.id === sourceLane!.id) { 125 + return { 126 + ...lane, 127 + cards: lane.cards.filter(c => c.rowIndex !== cardRowIndex), 128 + }; 129 + } 130 + if (lane.id === targetLaneId) { 131 + const newCards = [...lane.cards]; 132 + const insertAt = Math.min(targetIndex, newCards.length); 133 + newCards.splice(insertAt, 0, card); 134 + return { ...lane, cards: newCards }; 135 + } 136 + return lane; 137 + }); 138 + 139 + return { 140 + lanes: newLanes, 141 + newValue: targetLane.value === '(unassigned)' ? '' : targetLane.value, 142 + }; 143 + } 144 + 145 + /** 146 + * Reorder a card within the same lane. 147 + */ 148 + export function reorderCard( 149 + lanes: KanbanLane[], 150 + laneId: string, 151 + fromIndex: number, 152 + toIndex: number, 153 + ): KanbanLane[] { 154 + return lanes.map(lane => { 155 + if (lane.id !== laneId) return lane; 156 + if (fromIndex < 0 || fromIndex >= lane.cards.length) return lane; 157 + 158 + const cards = [...lane.cards]; 159 + const [moved] = cards.splice(fromIndex, 1); 160 + const insertAt = Math.min(toIndex, cards.length); 161 + cards.splice(insertAt, 0, moved); 162 + return { ...lane, cards }; 163 + }); 164 + } 165 + 166 + /** 167 + * Get card count per lane. 168 + */ 169 + export function laneCardCounts(lanes: KanbanLane[]): Map<string, number> { 170 + const counts = new Map<string, number>(); 171 + for (const lane of lanes) { 172 + counts.set(lane.id, lane.cards.length); 173 + } 174 + return counts; 175 + } 176 + 177 + /** 178 + * Find which lane a card is in. 179 + */ 180 + export function findCardLane( 181 + lanes: KanbanLane[], 182 + rowIndex: number, 183 + ): KanbanLane | null { 184 + return lanes.find(l => l.cards.some(c => c.rowIndex === rowIndex)) || null; 185 + } 186 + 187 + /** 188 + * Get total card count across all lanes. 189 + */ 190 + export function totalCardCount(lanes: KanbanLane[]): number { 191 + return lanes.reduce((sum, lane) => sum + lane.cards.length, 0); 192 + }
+175
tests/kanban-view.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + buildKanbanLanes, 4 + moveCard, 5 + reorderCard, 6 + laneCardCounts, 7 + findCardLane, 8 + totalCardCount, 9 + type KanbanConfig, 10 + } from '../src/sheets/kanban-view.js'; 11 + 12 + function colToLetter(col: number): string { 13 + return String.fromCharCode(65 + col); 14 + } 15 + 16 + function makeCells(obj: Record<string, unknown>): Map<string, unknown> { 17 + return new Map(Object.entries(obj)); 18 + } 19 + 20 + const HEADERS = ['Status', 'Task', 'Assignee', 'Priority']; 21 + 22 + const CONFIG: KanbanConfig = { 23 + groupByCol: 0, // A = Status 24 + titleCol: 1, // B = Task 25 + fieldCols: [2, 3], // C = Assignee, D = Priority 26 + }; 27 + 28 + const CELLS = makeCells({ 29 + A1: 'Todo', B1: 'Design UI', C1: 'Alice', D1: 'High', 30 + A2: 'In Progress', B2: 'Build API', C2: 'Bob', D2: 'High', 31 + A3: 'Todo', B3: 'Write Tests', C3: 'Carol', D3: 'Medium', 32 + A4: 'Done', B4: 'Setup CI', C4: 'Alice', D4: 'Low', 33 + A5: 'In Progress', B5: 'Review PR', C5: 'Bob', D5: 'Medium', 34 + }); 35 + 36 + describe('Kanban View', () => { 37 + describe('buildKanbanLanes', () => { 38 + it('groups rows into lanes by column value', () => { 39 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 40 + expect(lanes.length).toBe(3); // Done, In Progress, Todo 41 + }); 42 + 43 + it('sorts lanes alphabetically', () => { 44 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 45 + expect(lanes[0].value).toBe('Done'); 46 + expect(lanes[1].value).toBe('In Progress'); 47 + expect(lanes[2].value).toBe('Todo'); 48 + }); 49 + 50 + it('puts correct cards in each lane', () => { 51 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 52 + const todo = lanes.find(l => l.value === 'Todo')!; 53 + expect(todo.cards).toHaveLength(2); 54 + expect(todo.cards[0].title).toBe('Design UI'); 55 + expect(todo.cards[1].title).toBe('Write Tests'); 56 + }); 57 + 58 + it('includes field data on cards', () => { 59 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 60 + const done = lanes.find(l => l.value === 'Done')!; 61 + expect(done.cards[0].fields).toHaveLength(2); 62 + expect(done.cards[0].fields[0].label).toBe('Assignee'); 63 + expect(done.cards[0].fields[0].value).toBe('Alice'); 64 + }); 65 + 66 + it('handles empty group values as (unassigned)', () => { 67 + const cells = makeCells({ A1: '', B1: 'Orphan task' }); 68 + const lanes = buildKanbanLanes(cells, 1, CONFIG, colToLetter, HEADERS); 69 + expect(lanes[0].value).toBe('(unassigned)'); 70 + }); 71 + 72 + it('puts (unassigned) lane last', () => { 73 + const cells = makeCells({ 74 + A1: '', B1: 'Orphan', A2: 'Active', B2: 'Normal', 75 + }); 76 + const lanes = buildKanbanLanes(cells, 2, CONFIG, colToLetter, HEADERS); 77 + expect(lanes[lanes.length - 1].value).toBe('(unassigned)'); 78 + }); 79 + 80 + it('handles empty data', () => { 81 + const lanes = buildKanbanLanes(new Map(), 0, CONFIG, colToLetter, HEADERS); 82 + expect(lanes).toEqual([]); 83 + }); 84 + }); 85 + 86 + describe('moveCard', () => { 87 + it('moves a card between lanes', () => { 88 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 89 + const todoLane = lanes.find(l => l.value === 'Todo')!; 90 + const inProgressLane = lanes.find(l => l.value === 'In Progress')!; 91 + 92 + const result = moveCard(lanes, 1, inProgressLane.id, 0); 93 + expect(result).not.toBeNull(); 94 + expect(result!.newValue).toBe('In Progress'); 95 + 96 + const newTodo = result!.lanes.find(l => l.value === 'Todo')!; 97 + const newInProgress = result!.lanes.find(l => l.value === 'In Progress')!; 98 + expect(newTodo.cards).toHaveLength(1); 99 + expect(newInProgress.cards).toHaveLength(3); 100 + }); 101 + 102 + it('returns null for unknown card', () => { 103 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 104 + expect(moveCard(lanes, 999, lanes[0].id, 0)).toBeNull(); 105 + }); 106 + 107 + it('returns null for unknown target lane', () => { 108 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 109 + expect(moveCard(lanes, 1, 'nonexistent', 0)).toBeNull(); 110 + }); 111 + 112 + it('returns empty string for moves to (unassigned)', () => { 113 + const cells = makeCells({ 114 + A1: 'Active', B1: 'Task 1', A2: '', B2: 'Task 2', 115 + }); 116 + const lanes = buildKanbanLanes(cells, 2, CONFIG, colToLetter, HEADERS); 117 + const unassigned = lanes.find(l => l.value === '(unassigned)')!; 118 + const result = moveCard(lanes, 1, unassigned.id, 0); 119 + expect(result!.newValue).toBe(''); 120 + }); 121 + }); 122 + 123 + describe('reorderCard', () => { 124 + it('reorders cards within a lane', () => { 125 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 126 + const todo = lanes.find(l => l.value === 'Todo')!; 127 + const reordered = reorderCard(lanes, todo.id, 0, 1); 128 + const newTodo = reordered.find(l => l.value === 'Todo')!; 129 + expect(newTodo.cards[0].title).toBe('Write Tests'); 130 + expect(newTodo.cards[1].title).toBe('Design UI'); 131 + }); 132 + 133 + it('ignores invalid indices', () => { 134 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 135 + const todo = lanes.find(l => l.value === 'Todo')!; 136 + const result = reorderCard(lanes, todo.id, -1, 0); 137 + const newTodo = result.find(l => l.value === 'Todo')!; 138 + expect(newTodo.cards[0].title).toBe('Design UI'); 139 + }); 140 + }); 141 + 142 + describe('laneCardCounts', () => { 143 + it('returns card count per lane', () => { 144 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 145 + const counts = laneCardCounts(lanes); 146 + expect(counts.get(lanes.find(l => l.value === 'Todo')!.id)).toBe(2); 147 + expect(counts.get(lanes.find(l => l.value === 'In Progress')!.id)).toBe(2); 148 + expect(counts.get(lanes.find(l => l.value === 'Done')!.id)).toBe(1); 149 + }); 150 + }); 151 + 152 + describe('findCardLane', () => { 153 + it('finds the lane containing a card', () => { 154 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 155 + const lane = findCardLane(lanes, 4); 156 + expect(lane!.value).toBe('Done'); 157 + }); 158 + 159 + it('returns null for unknown row', () => { 160 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 161 + expect(findCardLane(lanes, 99)).toBeNull(); 162 + }); 163 + }); 164 + 165 + describe('totalCardCount', () => { 166 + it('sums all cards across lanes', () => { 167 + const lanes = buildKanbanLanes(CELLS, 5, CONFIG, colToLetter, HEADERS); 168 + expect(totalCardCount(lanes)).toBe(5); 169 + }); 170 + 171 + it('returns 0 for empty lanes', () => { 172 + expect(totalCardCount([])).toBe(0); 173 + }); 174 + }); 175 + });