import { DataAdapter, KanbanCard, MatrixBoard, MatrixConfig, MatrixLayout, NotebookMatrixGroup, } from "./types"; import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderMetadata from "./folderTree"; import extractGtdBlock from "./gtdBlock"; import buildKanbanCard from "./buildKanbanCard"; export interface MatrixResult { layout: MatrixLayout; /** @deprecated Phase 4 moves payload consumers to layout. Single-view alias. */ board: MatrixBoard; warnings: string[]; scannedFolders: number; scannedNotes: number; cardCount: number; } /** * Build a 2x2 matrix from admitted to-dos and opted-in notes. * * - notes and todos filters apply independently. * - Completed to-dos and done-tag notes are excluded entirely: the matrix is a * prioritisation view; completions live in the kanban's Done column. * - Quadrants from the two axis tags: * urgent + important -> Do Next * important only -> Scheduled * urgent only -> On Deck * neither -> Backlog (default bucket for untagged) * * The matrix note itself is always excluded. */ export default async function collectMatrix( adapter: DataAdapter, matrixNoteId: string, matrixFolderId: string, config: MatrixConfig, now: Date = new Date() ): Promise { const warnings: string[] = []; const folders = await adapter.getFolders(); const folderMetadata = config.groupByNotebook ? resolveScopedFolderMetadata(folders, matrixFolderId) : []; const folderIds = config.groupByNotebook ? folderMetadata.map((folder) => folder.id) : resolveScopedFolderIds( folders, matrixFolderId, config.scopeDepth, config.scopeAll ); let scannedNotes = 0; const topLeft: KanbanCard[] = []; const topRight: KanbanCard[] = []; const bottomLeft: KanbanCard[] = []; const bottomRight: KanbanCard[] = []; const grouped = new Map(); for (const folder of folderMetadata) { grouped.set(folder.id, { board: { topLeft: [], topRight: [], bottomLeft: [], bottomRight: [] }, warnings: [], }); } // Skeleton mode: a date on or before this ISO threshold is "due soon". const soonThreshold = isoDaysFromNow(now, config.urgentWindow); // 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 || { topLeft, topRight, bottomLeft, bottomRight, }; const notes = await adapter.getNotesInFolder(folderId, includeBody); for (const note of notes) { scannedNotes += 1; if (note.id === matrixNoteId) continue; if (!note.is_todo && config.notes === "none") continue; const result = extractGtdBlock(note.body); if (note.is_todo) { if (note.todo_completed > 0) continue; // prioritisation view 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); if (card.completed) continue; let topRow: boolean; let leftColumn: boolean; if (config.mode === "eisenhower") { // Rows: important / not. Columns: urgent / not. topRow = tags.includes(config.importantTag); leftColumn = tags.includes(config.urgentTag); } else { // Skeleton. Rows: active (in-progress) / not. // Columns: due soon (urgent tag overrides; else date within // the window, overdue included) / not. topRow = tags.includes(config.inProgressTag); leftColumn = tags.includes(config.urgentTag) || (card.date !== null && card.date <= soonThreshold); } if (topRow && leftColumn) targetBoard.topLeft.push(card); else if (topRow) targetBoard.topRight.push(card); else if (leftColumn) targetBoard.bottomLeft.push(card); else targetBoard.bottomRight.push(card); } } const groups: NotebookMatrixGroup[] = []; for (const folder of folderMetadata) { const target = grouped.get(folder.id) as { board: MatrixBoard; 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({ topLeft, topRight, bottomLeft, bottomRight }, config); } const board = { topLeft, topRight, bottomLeft, bottomRight }; const layout: MatrixLayout = 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: MatrixBoard, config: MatrixConfig): void { sortColumn(board.topLeft, config); sortColumn(board.topRight, config); sortColumn(board.bottomLeft, config); sortColumn(board.bottomRight, config); } function countBoard(board: MatrixBoard): number { return ( board.topLeft.length + board.topRight.length + board.bottomLeft.length + board.bottomRight.length ); } /** ISO yyyy-mm-dd for local `now` plus `days`. */ function isoDaysFromNow(now: Date, days: number): string { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days); return ( String(d.getFullYear()).padStart(4, "0") + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0") ); } function sortColumn(cards: KanbanCard[], config: MatrixConfig): void { const direction = config.sort === "desc" ? -1 : 1; cards.sort((a, b) => { let comparison: number; if (config.sortType === "due-date") { 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; }); }