From 4cb6700a20c1001d1ff0a45bdf0674593e179057 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 3 Jul 2026 13:30:44 +0000 Subject: [PATCH] v0.4.0: Eisenhower Matrix (gtd-matrix block) - New gtd-matrix block: 2x2 prioritisation grid with axis headers (Urgent / Not urgent x Important / Not important) - Quadrants from two configurable tags (urgent-tag default 'urgent', important-tag default 'important'): both -> Do First; important -> Schedule; urgent -> Delegate; neither -> Eliminate (default bucket) - Completed to-dos excluded entirely (prioritisation view, not tracking; kanban Done is where completions live); to-dos only; matrix note excluded; warns when both axis tags are identical - Same scope/todos/sort-type/sort/card-detail options as kanban; cards reuse kanban rendering (compact, recurrence mark, drilldown) - Quadrant colour accents: red Do First, blue Schedule, amber Delegate, grey Eliminate - SPEC roadmap restructured: matrix shipped as v0.4.0 (views axis); drag-and-drop + create-from-view clustered as the v0.5.0+ authoring epic with multi-board refresh and trigger/target questions recorded - Version 0.4.0; 11 new tests (80 total); README documented --- README.md | 35 ++++++ SPEC.md | 24 +++- package-lock.json | 4 +- package.json | 2 +- src/Gtd/collectMatrix.ts | 151 +++++++++++++++++++++++++ src/Gtd/parseMatrixConfig.ts | 130 +++++++++++++++++++++ src/Gtd/types.ts | 28 +++++ src/event-calendar.css | 47 ++++++++ src/gtd-calendar-renderer.ts | 14 ++- src/gtd-calendar-webview.js | 140 ++++++++++++++++++++++- src/index.ts | 63 +++++++++++ src/manifest.json | 2 +- src/tests/Gtd/matrix.test.ts | 211 +++++++++++++++++++++++++++++++++++ 13 files changed, 841 insertions(+), 10 deletions(-) create mode 100644 src/Gtd/collectMatrix.ts create mode 100644 src/Gtd/parseMatrixConfig.ts create mode 100644 src/tests/Gtd/matrix.test.ts diff --git a/README.md b/README.md index 480832d..f89659e 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,41 @@ A completed to-do always lands in **Done**, even if it still carries the in-prog Cards are compact (title, recurrence ↻ if applicable) and click through to the to-do. The board is **read-only** in this version — it reflects your to-dos' state but doesn't change it. (Drag-and-drop to move cards between columns is planned.) +## Eisenhower Matrix + +A third view: a 2×2 prioritisation matrix, driven by two tags. Add a `gtd-matrix` block anywhere: + +```` +```gtd-matrix +title: Priorities +todos: all +urgent-tag: urgent +important-tag: important +``` +```` + +To-dos are sorted into four quadrants by which of the two axis tags they carry: + +| | **Urgent** | **Not urgent** | +|---|---|---| +| **Important** | Do First | Schedule | +| **Not important** | Delegate | Eliminate | + +A to-do with neither tag lands in **Eliminate** — the matrix's pointed default. **Completed to-dos don't appear at all**: the matrix is for prioritising open work (the kanban's Done column is where completions live). + +| Option | Values | Default | Description | +|---|---|---|---| +| `title` | text | — | Heading above the matrix. | +| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar and kanban. | +| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. Plain notes never do. | +| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. | +| `sort` | `asc`, `desc` | `asc` | Sort direction. | +| `urgent-tag` | any tag name | `urgent` | The tag marking the urgent axis. | +| `important-tag` | any tag name | `important` | The tag marking the important axis. | +| `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. | + +Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only. + ## Drilldown Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note. diff --git a/SPEC.md b/SPEC.md index e118be9..ca3efea 100644 --- a/SPEC.md +++ b/SPEC.md @@ -236,9 +236,24 @@ done-window: 7 # days; integer or "all"; default 7 **`sort-type` vocabulary reconciliation:** the calendar block's `sort-type` (currently `title | modified_date`) is unified toward `due-date | title | modified-date` across both blocks so they read consistently. For the calendar, `due-date` is accepted but falls back to chronological where less meaningful. (Note the spelling shift `modified_date` → `modified-date` for cross-block consistency; the old spelling stays accepted as an alias to avoid breaking existing calendars.) -### v0.4.0 — Drag-and-drop kanban (write-back) +### v0.4.0 — Eisenhower Matrix (`gtd-matrix` block) — SHIPPED -The first feature that makes the plugin a **controller** of data rather than a viewer — warrants its own design pass. +A fourth display block: a 2×2 prioritisation matrix, tag-driven like the kanban. Chosen ahead of drag-and-drop deliberately: it is on the **views axis** (another rendering of tagged to-dos, reusing the whole data layer, read-only) rather than the **authoring axis** (vault mutation), so it deepens what the plugin already is without committing to write-back. + +- **Quadrants** from two configurable tags (`urgent-tag:` default `urgent`; `important-tag:` default `important`): + - urgent + important → **Do First** + - important only → **Schedule** + - urgent only → **Delegate** + - neither → **Eliminate** (the default bucket for untagged to-dos — a deliberately pointed default) +- **Completed to-dos are excluded entirely** — the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live. +- To-dos only; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as the kanban; same compact clickable cards with ↻. +- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics. + +### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic + +Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault: + +**Drag-and-drop kanban (write-back).** The first feature making the plugin a **controller** of data rather than a viewer — warrants its own design pass. - Dragging a card writes back to Joplin via the existing webview→plugin channel: - → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate). @@ -246,3 +261,8 @@ The first feature that makes the plugin a **controller** of data rather than a v - → *Backlog*: remove the tag / clear completion. - **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes. - Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging. +- **Multi-board refresh:** with Persistent Layout keeping several dashboards rendered, a write from one board leaves others stale until re-render — the refresh question is sharper in the fractal-dashboard architecture than for single-board use. + +**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer. + +**Create-to-do from calendar.** Click/affordance on a day cell creates a to-do due that day. Open questions: trigger mechanism in the read-only viewer (double-click/right-click may be claimed by Joplin; per-cell "+" affordance likelier), target notebook rule, whether created to-dos get an empty gtd block so they appear under `todos: gtd-only`. Prototype the trigger before designing the rest. diff --git a/package-lock.json b/package-lock.json index 46773ff..a82f4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "joplin-plugin-gtd-calendar", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "date-fns": "^2.29.3", diff --git a/package.json b/package.json index 603543c..fd5b655 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.3.2", + "version": "0.4.0", "scripts": { "test": "jest", "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", diff --git a/src/Gtd/collectMatrix.ts b/src/Gtd/collectMatrix.ts new file mode 100644 index 0000000..0cc7281 --- /dev/null +++ b/src/Gtd/collectMatrix.ts @@ -0,0 +1,151 @@ +import { + DataAdapter, + KanbanCard, + MatrixBoard, + MatrixConfig, + RawNote, + RECURRING_TAG, +} from "./types"; +import resolveScopedFolderIds from "./folderScope"; +import extractGtdBlock from "./gtdBlock"; +import { resolveEventDate } from "./resolveDate"; + +export interface MatrixResult { + board: MatrixBoard; + warnings: string[]; + scannedFolders: number; + scannedNotes: number; + cardCount: number; +} + +/** + * Build a 2x2 Eisenhower matrix from to-dos in scope. + * + * - Notes are excluded (to-dos only). + * - Completed to-dos 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 First + * important only -> Schedule + * urgent only -> Delegate + * neither -> Eliminate (default bucket for untagged) + * + * The matrix note itself is always excluded. + */ +export default async function collectMatrix( + adapter: DataAdapter, + matrixNoteId: string, + matrixFolderId: string, + config: MatrixConfig +): Promise { + const warnings: string[] = []; + + const folders = await adapter.getFolders(); + const folderIds = resolveScopedFolderIds( + folders, + matrixFolderId, + config.scopeDepth + ); + + let scannedNotes = 0; + const doFirst: KanbanCard[] = []; + const schedule: KanbanCard[] = []; + const delegate: KanbanCard[] = []; + const eliminate: KanbanCard[] = []; + + for (const folderId of folderIds) { + const notes = await adapter.getNotesInFolder(folderId); + for (const note of notes) { + scannedNotes += 1; + if (note.id === matrixNoteId) continue; + if (!note.is_todo) continue; // to-dos only + if (note.todo_completed > 0) continue; // prioritisation view + if (config.todos === "none") continue; + + const result = extractGtdBlock(note.body); + if (config.todos === "gtd-only" && !result.found) continue; + + if (result.error) { + warnings.push( + `"${note.title}": gtd block problem — ${result.error}` + ); + } + + const tags = await adapter.getNoteTagTitles(note.id); + const card = buildCard(note, result.block, tags); + + const urgent = tags.includes(config.urgentTag); + const important = tags.includes(config.importantTag); + + if (urgent && important) doFirst.push(card); + else if (important) schedule.push(card); + else if (urgent) delegate.push(card); + else eliminate.push(card); + } + } + + sortColumn(doFirst, config); + sortColumn(schedule, config); + sortColumn(delegate, config); + sortColumn(eliminate, config); + + return { + board: { doFirst, schedule, delegate, eliminate }, + warnings, + scannedFolders: folderIds.length, + scannedNotes, + cardCount: + doFirst.length + + schedule.length + + delegate.length + + eliminate.length, + }; +} + +function buildCard( + note: RawNote, + block: ReturnType["block"], + tags: string[] +): KanbanCard { + return { + id: note.id, + title: block && block.title ? block.title : note.title, + date: resolveEventDate(note, block), + completed: false, + completedTime: 0, + isRecurring: tags.includes(RECURRING_TAG), + bgColour: block ? block.bgColour : null, + fgColour: block ? block.fgColour : null, + icon: block ? block.icon : null, + text: block ? block.text : null, + updatedTime: note.updated_time, + }; +} + +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; + }); +} diff --git a/src/Gtd/parseMatrixConfig.ts b/src/Gtd/parseMatrixConfig.ts new file mode 100644 index 0000000..cca5e19 --- /dev/null +++ b/src/Gtd/parseMatrixConfig.ts @@ -0,0 +1,130 @@ +import { + InclusionMode, + KanbanSortType, + MatrixConfig, + URGENT_TAG, + IMPORTANT_TAG, +} from "./types"; + +const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; +const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"]; +const CARD_DETAILS = ["hover", "always", "none"]; + +/** + * Normalise a parsed gtd-matrix YAML object into a MatrixConfig with + * SPEC defaults. Invalid values produce warnings, not failures. + */ +export default function parseMatrixConfig(raw: any): MatrixConfig { + const warnings: string[] = []; + const input = raw && typeof raw === "object" ? raw : {}; + + const knownKeys = [ + "title", + "scope", + "todos", + "sort-type", + "sort", + "urgent-tag", + "important-tag", + "card-detail", + ]; + for (const key of Object.keys(input)) { + if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`); + } + + const title = + input.title !== undefined && input.title !== null + ? String(input.title) + : null; + + // scope (shared semantics with gtd-calendar / gtd-kanban) + let scopeDepth = 0; + if (input.scope !== undefined) { + const value = input.scope; + if (value === "this-folder") { + scopeDepth = 0; + } else if (value === "children") { + scopeDepth = Infinity; + } else if (Number.isInteger(Number(value)) && Number(value) >= 0) { + scopeDepth = Number(value); + } else { + warnings.push(`Invalid scope "${value}" (using "this-folder")`); + } + } + + // todos inclusion + let todos: InclusionMode = "gtd-only"; + if (input.todos !== undefined) { + const candidate = String(input.todos).toLowerCase() as InclusionMode; + if (INCLUSION_MODES.includes(candidate)) { + todos = candidate; + } else { + warnings.push(`Invalid todos "${input.todos}" (using "gtd-only")`); + } + } + + // sort-type + let sortType: KanbanSortType = "due-date"; + if (input["sort-type"] !== undefined) { + let candidate = String(input["sort-type"]).toLowerCase(); + if (candidate === "modified_date") candidate = "modified-date"; // alias + if (SORT_TYPES.includes(candidate as KanbanSortType)) { + sortType = candidate as KanbanSortType; + } else { + warnings.push( + `Invalid sort-type "${input["sort-type"]}" (using "due-date")` + ); + } + } + + // sort direction + let sort: MatrixConfig["sort"] = "asc"; + if (input.sort !== undefined) { + const candidate = String(input.sort).toLowerCase(); + if (candidate === "asc" || candidate === "desc") { + sort = candidate; + } else { + warnings.push(`Invalid sort "${input.sort}" (using "asc")`); + } + } + + // axis tags (configurable; lowercased for tag comparison) + const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG); + const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG); + if (urgentTag === importantTag) { + warnings.push( + `urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate` + ); + } + + // card-detail + let cardDetail: MatrixConfig["cardDetail"] = "hover"; + if (input["card-detail"] !== undefined) { + const candidate = String(input["card-detail"]).toLowerCase(); + if (CARD_DETAILS.includes(candidate)) { + cardDetail = candidate as MatrixConfig["cardDetail"]; + } else { + warnings.push( + `Invalid card-detail "${input["card-detail"]}" (using "hover")` + ); + } + } + + return { + title, + scopeDepth, + todos, + sortType, + sort, + urgentTag, + importantTag, + cardDetail, + warnings, + }; +} + +function parseTag(value: any, fallback: string): string { + if (value === undefined || value === null) return fallback; + const candidate = String(value).trim().toLowerCase(); + return candidate === "" ? fallback : candidate; +} diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 6ca13ba..2fc4ed4 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -126,3 +126,31 @@ export interface KanbanBoard { inProgress: KanbanCard[]; done: KanbanCard[]; } + +/** + * Default tags for the Eisenhower matrix axes. Overridable via the + * gtd-matrix `urgent-tag:` / `important-tag:` options. + */ +export const URGENT_TAG = "urgent"; +export const IMPORTANT_TAG = "important"; + +/** Parsed and normalised ```gtd-matrix config. */ +export interface MatrixConfig { + title: string | null; + scopeDepth: number; + todos: InclusionMode; + sortType: KanbanSortType; + sort: "asc" | "desc"; + urgentTag: string; + importantTag: string; + cardDetail: "hover" | "always" | "none"; + warnings: string[]; +} + +/** The four Eisenhower quadrants. Cards are KanbanCards (same shape). */ +export interface MatrixBoard { + doFirst: KanbanCard[]; // urgent + important + schedule: KanbanCard[]; // important only + delegate: KanbanCard[]; // urgent only + eliminate: KanbanCard[]; // neither +} diff --git a/src/event-calendar.css b/src/event-calendar.css index 8353b5f..1aaa6e5 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -360,3 +360,50 @@ .gtd-unscheduled-group + .gtd-unscheduled-group { margin-top: 0.5em; } + +/* gtd-matrix (Eisenhower) */ + +.gtd-matrix-grid { + display: grid; + grid-template-columns: auto 1fr 1fr; + gap: 6px; + align-items: stretch; +} + +.gtd-matrix-axis { + font-size: 0.78em; + font-weight: bold; + opacity: 0.75; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.2em 0.3em; + writing-mode: initial; +} + +.gtd-matrix-quadrant { + border: 1px solid rgba(128, 128, 128, 0.35); + border-radius: 6px; + min-height: 4em; +} + +.gtd-matrix-do-first { + background-color: rgba(220, 60, 60, 0.08); + border-color: rgba(220, 60, 60, 0.5); +} + +.gtd-matrix-schedule { + background-color: rgba(60, 140, 220, 0.08); + border-color: rgba(60, 140, 220, 0.5); +} + +.gtd-matrix-delegate { + background-color: rgba(230, 160, 40, 0.08); + border-color: rgba(230, 160, 40, 0.5); +} + +.gtd-matrix-eliminate { + background-color: rgba(128, 128, 128, 0.06); + border-color: rgba(128, 128, 128, 0.4); +} diff --git a/src/gtd-calendar-renderer.ts b/src/gtd-calendar-renderer.ts index 405d274..bc8384c 100644 --- a/src/gtd-calendar-renderer.ts +++ b/src/gtd-calendar-renderer.ts @@ -1,5 +1,6 @@ const CALENDAR_FENCE = "gtd-calendar"; const KANBAN_FENCE = "gtd-kanban"; +const MATRIX_FENCE = "gtd-matrix"; /** * Markdown-it content script. @@ -38,14 +39,21 @@ export default function (context: { contentScriptId: string }) { const token = tokens[idx]; const isCalendar = token.info === CALENDAR_FENCE; const isKanban = token.info === KANBAN_FENCE; - if (!isCalendar && !isKanban) + const isMatrix = token.info === MATRIX_FENCE; + if (!isCalendar && !isKanban && !isMatrix) return defaultRender(tokens, idx, options, env, self); const encodedConfig = encodeURIComponent(token.content); - const blockType = isCalendar ? "calendar" : "kanban"; + const blockType = isCalendar + ? "calendar" + : isKanban + ? "kanban" + : "matrix"; const label = isCalendar ? "Loading GTD Calendar…" - : "Loading GTD Kanban…"; + : isKanban + ? "Loading GTD Kanban…" + : "Loading GTD Matrix…"; return ( `
{ + test("applies SPEC defaults", () => { + const c = parseMatrixConfig({}); + expect(c).toMatchObject({ + title: null, + scopeDepth: 0, + todos: "gtd-only", + sortType: "due-date", + sort: "asc", + urgentTag: "urgent", + importantTag: "important", + cardDetail: "hover", + }); + expect(c.warnings).toHaveLength(0); + }); + + test("parses custom axis tags, lowercased", () => { + const c = parseMatrixConfig({ + "urgent-tag": "FIRE", + "important-tag": "Matters", + }); + expect(c.urgentTag).toBe("fire"); + expect(c.importantTag).toBe("matters"); + }); + + test("warns when both axis tags are identical", () => { + const c = parseMatrixConfig({ + "urgent-tag": "now", + "important-tag": "NOW", + }); + expect(c.warnings.some((w) => w.includes("will not separate"))).toBe( + true + ); + }); + + test("invalid values warn and fall back", () => { + const c = parseMatrixConfig({ + todos: "some", + "sort-type": "priority", + "card-detail": "popup", + mystery: 1, + }); + expect(c.todos).toBe("gtd-only"); + expect(c.sortType).toBe("due-date"); + expect(c.cardDetail).toBe("hover"); + expect(c.warnings.length).toBeGreaterThanOrEqual(4); + }); +}); + +// --------------------------------------------------------------------------- +// collectMatrix +// --------------------------------------------------------------------------- + +function makeNote(overrides: Partial): RawNote { + return { + id: "id", + title: "Card", + parent_id: "board", + is_todo: 1, + todo_due: 0, + todo_completed: 0, + updated_time: 0, + body: "```gtd\n```", + ...overrides, + }; +} + +function makeAdapter( + folders: RawFolder[], + notes: RawNote[], + tagsByNote: Record = {} +): DataAdapter { + return { + getFolders: async () => folders, + getNotesInFolder: async (folderId: string) => + notes.filter((n) => n.parent_id === folderId), + getNoteTagTitles: async (noteId: string) => tagsByNote[noteId] || [], + }; +} + +const folders: RawFolder[] = [{ id: "board", parent_id: "root" }]; + +describe("collectMatrix — quadrant bucketing", () => { + test("all four quadrants route correctly; untagged lands in Eliminate", async () => { + const notes = [ + makeNote({ id: "both", title: "Crisis" }), + makeNote({ id: "imp", title: "Strategy" }), + makeNote({ id: "urg", title: "Interruption" }), + makeNote({ id: "none", title: "Timewaster" }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes, { + both: ["urgent", "important"], + imp: ["important"], + urg: ["urgent"], + }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }) + ); + expect(result.board.doFirst.map((c) => c.id)).toEqual(["both"]); + expect(result.board.schedule.map((c) => c.id)).toEqual(["imp"]); + expect(result.board.delegate.map((c) => c.id)).toEqual(["urg"]); + expect(result.board.eliminate.map((c) => c.id)).toEqual(["none"]); + expect(result.cardCount).toBe(4); + }); + + test("completed to-dos are excluded entirely", async () => { + const notes = [ + makeNote({ id: "open", title: "Open" }), + makeNote({ id: "done", title: "Done", todo_completed: 123456 }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes, { done: ["urgent", "important"] }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }) + ); + const allIds = [ + ...result.board.doFirst, + ...result.board.schedule, + ...result.board.delegate, + ...result.board.eliminate, + ].map((c) => c.id); + expect(allIds).toEqual(["open"]); + }); + + test("custom axis tags are honoured", async () => { + const notes = [makeNote({ id: "x", title: "Hot" })]; + const result = await collectMatrix( + makeAdapter(folders, notes, { x: ["fire", "matters"] }), + "matrix-note", + "board", + parseMatrixConfig({ + todos: "all", + "urgent-tag": "fire", + "important-tag": "matters", + }) + ); + expect(result.board.doFirst.map((c) => c.id)).toEqual(["x"]); + }); + + test("plain notes and the matrix note itself are excluded", async () => { + const notes = [ + makeNote({ id: "matrix-note", title: "The matrix note" }), + makeNote({ id: "n", is_todo: 0, title: "A note" }), + makeNote({ id: "t", title: "A todo" }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }) + ); + expect(result.cardCount).toBe(1); + expect(result.board.eliminate.map((c) => c.id)).toEqual(["t"]); + }); + + test("gtd-only requires a gtd block", async () => { + const notes = [ + makeNote({ id: "in", body: "```gtd\n```" }), + makeNote({ id: "out", body: "no block here" }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({}) // todos: gtd-only default + ); + expect(result.cardCount).toBe(1); + expect(result.board.eliminate.map((c) => c.id)).toEqual(["in"]); + }); + + test("due-date sort within a quadrant, dateless last", async () => { + const notes = [ + makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }), + makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }), + makeNote({ id: "nodate" }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }) + ); + expect(result.board.eliminate.map((c) => c.id)).toEqual([ + "soon", + "later", + "nodate", + ]); + }); + + test("recurring tag flags cards", async () => { + const notes = [makeNote({ id: "r" })]; + const result = await collectMatrix( + makeAdapter(folders, notes, { r: ["recurring", "urgent"] }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }) + ); + expect(result.board.delegate[0].isRecurring).toBe(true); + }); +});