230 lines
6.6 KiB
TypeScript

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<MatrixResult> {
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<string, { board: MatrixBoard; warnings: string[] }>();
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;
});
}