import { DataAdapter, KanbanBoard, KanbanCard, KanbanConfig, KanbanLayout, NotebookKanbanGroup, } from "./types"; import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderMetadata from "./folderTree"; import extractGtdBlock from "./gtdBlock"; import buildKanbanCard from "./buildKanbanCard"; export interface KanbanResult { layout: KanbanLayout; /** @deprecated Phase 4 moves payload consumers to layout. Single-view alias. */ board: KanbanBoard; warnings: string[]; scannedFolders: number; scannedNotes: number; cardCount: number; } /** * Build a three-column kanban board from admitted to-dos and opted-in notes. * * - notes and todos filters apply independently. * - Bucketing: completion wins; else in-progress tag; * else Backlog. * - done-window filters native completed to-dos only. * - Each column sorted per config. * * The kanban note itself is always excluded. */ export default async function collectKanban( adapter: DataAdapter, kanbanNoteId: string, kanbanFolderId: string, config: KanbanConfig, now: Date = new Date() ): Promise { const warnings: string[] = []; const folders = await adapter.getFolders(); const folderMetadata = config.groupByNotebook ? resolveScopedFolderMetadata(folders, kanbanFolderId) : []; const folderIds = config.groupByNotebook ? folderMetadata.map((folder) => folder.id) : resolveScopedFolderIds( folders, kanbanFolderId, config.scopeDepth, config.scopeAll ); let scannedNotes = 0; const backlog: KanbanCard[] = []; const inProgress: KanbanCard[] = []; const done: KanbanCard[] = []; const grouped = new Map(); for (const folder of folderMetadata) { grouped.set(folder.id, { board: { backlog: [], inProgress: [], done: [] }, warnings: [], }); } const doneCutoff = config.doneWindow === Infinity ? -Infinity : now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000; // Bodies preserve explicit note opt-in and optional to-do card overrides. // When both types are disabled, metadata alone is sufficient for scan totals. const includeBody = config.notes !== "none" || config.todos !== "none"; for (const folderId of folderIds) { const target = grouped.get(folderId); const targetBoard = target?.board || { backlog, inProgress, done }; const notes = await adapter.getNotesInFolder(folderId, includeBody); for (const note of notes) { scannedNotes += 1; if (note.id === kanbanNoteId) continue; if (!note.is_todo && config.notes === "none") continue; const result = extractGtdBlock(note.body); if (note.is_todo) { if (config.todos === "none") continue; if (config.todos === "gtd-only" && !result.found) continue; } else if (!result.found) { continue; } if (result.error) { const warning = `"${note.title}": gtd block problem — ${result.error}`; (target?.warnings || warnings).push(warning); } const tags = await adapter.getNoteTagTitles(note.id); const card = buildKanbanCard(note, result.block, tags, config.doneTag); // Bucketing — Done wins. if (card.completed) { if (!card.isTodo || card.completedTime >= doneCutoff) { targetBoard.done.push(card); } // Only native to-dos can be completed outside the timestamp window. } else if (tags.includes(config.inProgressTag)) { targetBoard.inProgress.push(card); } else { targetBoard.backlog.push(card); } } } const groups: NotebookKanbanGroup[] = []; for (const folder of folderMetadata) { const target = grouped.get(folder.id) as { board: KanbanBoard; warnings: string[]; }; sortBoard(target.board, config); const cardCount = countBoard(target.board); if (cardCount > 0) { groups.push({ folderId: folder.id, notebookPath: folder.notebookPath, board: target.board, cardCount, warnings: target.warnings, }); } } if (!config.groupByNotebook) sortBoard({ backlog, inProgress, done }, config); const board = { backlog, inProgress, done }; const layout: KanbanLayout = config.groupByNotebook ? { kind: "notebooks", groups } : { kind: "single", board }; const cardCount = config.groupByNotebook ? groups.reduce((sum, group) => sum + group.cardCount, 0) : countBoard(board); return { layout, board, warnings, scannedFolders: folderIds.length, scannedNotes, cardCount, }; } function sortBoard(board: KanbanBoard, config: KanbanConfig): void { sortColumn(board.backlog, config); sortColumn(board.inProgress, config); sortColumn(board.done, config); } function countBoard(board: KanbanBoard): number { return board.backlog.length + board.inProgress.length + board.done.length; } function sortColumn(cards: KanbanCard[], config: KanbanConfig): void { const direction = config.sort === "desc" ? -1 : 1; cards.sort((a, b) => { let comparison: number; if (config.sortType === "due-date") { // Dateless cards always sort after dated ones, regardless of // direction. const aHas = a.date !== null; const bHas = b.date !== null; if (aHas && !bHas) return -1; if (!aHas && bHas) return 1; if (!aHas && !bHas) { comparison = 0; } else { comparison = (a.date as string).localeCompare(b.date as string); } } else if (config.sortType === "modified-date") { comparison = a.updatedTime - b.updatedTime; } else { comparison = a.title.localeCompare(b.title, undefined, { sensitivity: "base", }); } return comparison * direction; }); }