From 6bb3227574238de7566abf66e970c7ec003db408 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 3 Jul 2026 13:58:37 +0000 Subject: [PATCH] v0.5.0: the Skeleton Matrix (mode option on gtd-matrix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New default mode 'skeleton': rows = active (in-progress tag, shared with the kanban); columns = due soon (due date within urgent-window days, overdue included, or the urgent tag as manual override) - Quadrants: Do Next / Scheduled / On Deck / Backlog — no 'Eliminate'; a committed vault needs when-and-what's-moving, not triage - in-progress + urgent + dateless -> Do Next (urgent is the override) - Dateless active work -> Scheduled; completed to-dos still excluded - mode: eisenhower preserves the classic (urgent/important tags) - MatrixBoard now positional (topLeft..bottomRight); per-mode labels travel in the payload; rendering fully shared between modes - New options: mode, in-progress-tag, urgent-window (default 3) - Version 0.5.0; 8 new tests (88 total); README + SPEC updated --- README.md | 34 +++++--- SPEC.md | 3 + package-lock.json | 4 +- package.json | 2 +- src/Gtd/collectMatrix.ts | 70 ++++++++++----- src/Gtd/matrixLabels.ts | 28 ++++++ src/Gtd/parseMatrixConfig.ts | 32 +++++++ src/Gtd/types.ts | 36 ++++++-- src/gtd-calendar-webview.js | 44 ++++++---- src/index.ts | 10 ++- src/manifest.json | 2 +- src/tests/Gtd/matrix.test.ts | 165 +++++++++++++++++++++++++++++++---- 12 files changed, 353 insertions(+), 77 deletions(-) create mode 100644 src/Gtd/matrixLabels.ts diff --git a/README.md b/README.md index f89659e..49cc484 100644 --- a/README.md +++ b/README.md @@ -118,37 +118,49 @@ 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 +## The Matrix (Skeleton & Eisenhower) -A third view: a 2×2 prioritisation matrix, driven by two tags. Add a `gtd-matrix` block anywhere: +A third view: a 2×2 prioritisation matrix. Add a `gtd-matrix` block anywhere: ```` ```gtd-matrix title: Priorities todos: all -urgent-tag: urgent -important-tag: important +mode: skeleton ``` ```` -To-dos are sorted into four quadrants by which of the two axis tags they carry: +Two modes: -| | **Urgent** | **Not urgent** | +### `mode: skeleton` (default) — the Skeleton Matrix + +Built to complement the kanban and calendar, using the tags and dates you already maintain. Rows ask *am I on this?* (the in-progress tag); columns ask *is the clock running?* (due within `urgent-window` days — overdue counts — or carrying the urgent tag as a manual override): + +| | **Due soon** | **Not due soon** | |---|---|---| -| **Important** | Do First | Schedule | -| **Not important** | Delegate | Eliminate | +| **Active** | Do Next | Scheduled | +| **Not active** | On Deck | Backlog | -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). +**On Deck** is the quadrant to watch: due soon, not yet started. Nothing is ever labelled "Eliminate" — a Backlog item is simply low priority, not condemned. Dateless in-progress work sits in Scheduled (active, no clock). Completed to-dos don't appear at all. + +### `mode: eisenhower` — the classic + +Quadrants from the urgent/important tags: Do First (both), Schedule (important), Delegate (urgent), Eliminate (neither). + +### Options | Option | Values | Default | Description | |---|---|---|---| +| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. | | `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. | +| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. | +| `important-tag` | any tag name | `important` | Eisenhower's important axis (unused in Skeleton mode). | +| `in-progress-tag` | any tag name | `in-progress` | Skeleton mode's active row — same tag as the kanban's In Progress column. | +| `urgent-window` | integer (days) | `3` | Skeleton mode: how close a due date must be to count as "due soon". | | `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. diff --git a/SPEC.md b/SPEC.md index ca3efea..f797282 100644 --- a/SPEC.md +++ b/SPEC.md @@ -237,6 +237,9 @@ 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 — Eisenhower Matrix (`gtd-matrix` block) — SHIPPED +### v0.5.0 — Skeleton Matrix (`mode:` on gtd-matrix) — SHIPPED + +Real-world feedback: Eisenhower's opinion axes (and "Eliminate" in particular) fit triage, not a vault of already-committed work. The **Skeleton Matrix** replaces them with two derived axes the system already maintains: rows = active (`in-progress-tag`, shared with the kanban); columns = due soon (`urgent-window` days, default 3, overdue included; `urgent` tag as manual override — in-progress + urgent + dateless is Do Next by design). Quadrants: Do Next / Scheduled / On Deck / Backlog; dateless active work → Scheduled; nothing is ever "Eliminate". Implemented as `mode: skeleton | eisenhower` on the same block — positional MatrixBoard (topLeft…bottomRight), labels supplied per mode via payload, shared rendering. Skeleton is the default (it matches the plugin's own tag/date vocabulary); classic Eisenhower preserved via `mode: eisenhower`. 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. diff --git a/package-lock.json b/package-lock.json index a82f4ef..7577ab6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "joplin-plugin-gtd-calendar", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "date-fns": "^2.29.3", diff --git a/package.json b/package.json index fd5b655..0557e87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.4.0", + "version": "0.5.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 index 0cc7281..edd6c7a 100644 --- a/src/Gtd/collectMatrix.ts +++ b/src/Gtd/collectMatrix.ts @@ -36,7 +36,8 @@ export default async function collectMatrix( adapter: DataAdapter, matrixNoteId: string, matrixFolderId: string, - config: MatrixConfig + config: MatrixConfig, + now: Date = new Date() ): Promise { const warnings: string[] = []; @@ -48,10 +49,13 @@ export default async function collectMatrix( ); let scannedNotes = 0; - const doFirst: KanbanCard[] = []; - const schedule: KanbanCard[] = []; - const delegate: KanbanCard[] = []; - const eliminate: KanbanCard[] = []; + const topLeft: KanbanCard[] = []; + const topRight: KanbanCard[] = []; + const bottomLeft: KanbanCard[] = []; + const bottomRight: KanbanCard[] = []; + + // Skeleton mode: a date on or before this ISO threshold is "due soon". + const soonThreshold = isoDaysFromNow(now, config.urgentWindow); for (const folderId of folderIds) { const notes = await adapter.getNotesInFolder(folderId); @@ -74,34 +78,60 @@ export default async function collectMatrix( 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); + let topRow: boolean; + let leftColumn: boolean; - if (urgent && important) doFirst.push(card); - else if (important) schedule.push(card); - else if (urgent) delegate.push(card); - else eliminate.push(card); + 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) topLeft.push(card); + else if (topRow) topRight.push(card); + else if (leftColumn) bottomLeft.push(card); + else bottomRight.push(card); } } - sortColumn(doFirst, config); - sortColumn(schedule, config); - sortColumn(delegate, config); - sortColumn(eliminate, config); + sortColumn(topLeft, config); + sortColumn(topRight, config); + sortColumn(bottomLeft, config); + sortColumn(bottomRight, config); return { - board: { doFirst, schedule, delegate, eliminate }, + board: { topLeft, topRight, bottomLeft, bottomRight }, warnings, scannedFolders: folderIds.length, scannedNotes, cardCount: - doFirst.length + - schedule.length + - delegate.length + - eliminate.length, + topLeft.length + + topRight.length + + bottomLeft.length + + 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 buildCard( note: RawNote, block: ReturnType["block"], diff --git a/src/Gtd/matrixLabels.ts b/src/Gtd/matrixLabels.ts new file mode 100644 index 0000000..bf7aea2 --- /dev/null +++ b/src/Gtd/matrixLabels.ts @@ -0,0 +1,28 @@ +import { MatrixLabels, MatrixMode } from "./types"; + +/** Axis and quadrant display labels per matrix mode. */ +export default function matrixLabels(mode: MatrixMode): MatrixLabels { + if (mode === "eisenhower") { + return { + columns: ["Urgent", "Not urgent"], + rows: ["Important", "Not important"], + quadrants: { + topLeft: "Do First", + topRight: "Schedule", + bottomLeft: "Delegate", + bottomRight: "Eliminate", + }, + }; + } + // skeleton + return { + columns: ["Due soon", "Not due soon"], + rows: ["Active", "Not active"], + quadrants: { + topLeft: "Do Next", + topRight: "Scheduled", + bottomLeft: "On Deck", + bottomRight: "Backlog", + }, + }; +} diff --git a/src/Gtd/parseMatrixConfig.ts b/src/Gtd/parseMatrixConfig.ts index cca5e19..929ff54 100644 --- a/src/Gtd/parseMatrixConfig.ts +++ b/src/Gtd/parseMatrixConfig.ts @@ -2,8 +2,10 @@ import { InclusionMode, KanbanSortType, MatrixConfig, + MatrixMode, URGENT_TAG, IMPORTANT_TAG, + IN_PROGRESS_TAG, } from "./types"; const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; @@ -19,6 +21,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { const input = raw && typeof raw === "object" ? raw : {}; const knownKeys = [ + "mode", "title", "scope", "todos", @@ -26,12 +29,25 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { "sort", "urgent-tag", "important-tag", + "in-progress-tag", + "urgent-window", "card-detail", ]; for (const key of Object.keys(input)) { if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`); } + // mode + let mode: MatrixMode = "skeleton"; + if (input.mode !== undefined) { + const candidate = String(input.mode).toLowerCase(); + if (candidate === "skeleton" || candidate === "eisenhower") { + mode = candidate as MatrixMode; + } else { + warnings.push(`Invalid mode "${input.mode}" (using "skeleton")`); + } + } + const title = input.title !== undefined && input.title !== null ? String(input.title) @@ -97,6 +113,19 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { ); } + // skeleton mode: in-progress tag + urgent window + const inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG); + + let urgentWindow = 3; + if (input["urgent-window"] !== undefined) { + const value = input["urgent-window"]; + if (Number.isInteger(Number(value)) && Number(value) >= 0) { + urgentWindow = Number(value); + } else { + warnings.push(`Invalid urgent-window "${value}" (using 3)`); + } + } + // card-detail let cardDetail: MatrixConfig["cardDetail"] = "hover"; if (input["card-detail"] !== undefined) { @@ -111,6 +140,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { } return { + mode, title, scopeDepth, todos, @@ -118,6 +148,8 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { sort, urgentTag, importantTag, + inProgressTag, + urgentWindow, cardDetail, warnings, }; diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 2fc4ed4..0666c20 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -134,8 +134,11 @@ export interface KanbanBoard { export const URGENT_TAG = "urgent"; export const IMPORTANT_TAG = "important"; +export type MatrixMode = "skeleton" | "eisenhower"; + /** Parsed and normalised ```gtd-matrix config. */ export interface MatrixConfig { + mode: MatrixMode; title: string | null; scopeDepth: number; todos: InclusionMode; @@ -143,14 +146,37 @@ export interface MatrixConfig { sort: "asc" | "desc"; urgentTag: string; importantTag: string; + /** Skeleton mode: tag marking active work (shared with the kanban). */ + inProgressTag: string; + /** Skeleton mode: days ahead within which a due date counts as "due soon". */ + urgentWindow: number; cardDetail: "hover" | "always" | "none"; warnings: string[]; } -/** The four Eisenhower quadrants. Cards are KanbanCards (same shape). */ +/** + * The four matrix quadrants, positional (row x column): + * topLeft topRight + * bottomLeft bottomRight + * + * Eisenhower: Do First / Schedule / Delegate / Eliminate. + * Skeleton: Do Next / Scheduled / On Deck / Backlog. + */ export interface MatrixBoard { - doFirst: KanbanCard[]; // urgent + important - schedule: KanbanCard[]; // important only - delegate: KanbanCard[]; // urgent only - eliminate: KanbanCard[]; // neither + topLeft: KanbanCard[]; + topRight: KanbanCard[]; + bottomLeft: KanbanCard[]; + bottomRight: KanbanCard[]; +} + +/** Display labels for a matrix mode, sent to the webview. */ +export interface MatrixLabels { + columns: [string, string]; + rows: [string, string]; + quadrants: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + }; } diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 9185b56..45b77a9 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -628,26 +628,36 @@ }); const board = payload.board || { - doFirst: [], - schedule: [], - delegate: [], - eliminate: [], + topLeft: [], + topRight: [], + bottomLeft: [], + bottomRight: [], + }; + const labels = payload.labels || { + columns: ["Urgent", "Not urgent"], + rows: ["Important", "Not important"], + quadrants: { + topLeft: "Do First", + topRight: "Schedule", + bottomLeft: "Delegate", + bottomRight: "Eliminate", + }, }; const detail = payload.cardDetail || "hover"; - // Axis header row: blank corner + Urgent / Not urgent + // Axis header row: blank corner + column labels const grid = document.createElement("div"); grid.className = "gtd-matrix-grid"; grid.appendChild(matrixAxisCell("")); - grid.appendChild(matrixAxisCell("Urgent")); - grid.appendChild(matrixAxisCell("Not urgent")); + grid.appendChild(matrixAxisCell(labels.columns[0])); + grid.appendChild(matrixAxisCell(labels.columns[1])); - grid.appendChild(matrixAxisCell("Important")); + grid.appendChild(matrixAxisCell(labels.rows[0])); grid.appendChild( matrixQuadrant( - "Do First", - board.doFirst, + labels.quadrants.topLeft, + board.topLeft, "do-first", detail, contentScriptId @@ -655,19 +665,19 @@ ); grid.appendChild( matrixQuadrant( - "Schedule", - board.schedule, + labels.quadrants.topRight, + board.topRight, "schedule", detail, contentScriptId ) ); - grid.appendChild(matrixAxisCell("Not important")); + grid.appendChild(matrixAxisCell(labels.rows[1])); grid.appendChild( matrixQuadrant( - "Delegate", - board.delegate, + labels.quadrants.bottomLeft, + board.bottomLeft, "delegate", detail, contentScriptId @@ -675,8 +685,8 @@ ); grid.appendChild( matrixQuadrant( - "Eliminate", - board.eliminate, + labels.quadrants.bottomRight, + board.bottomRight, "eliminate", detail, contentScriptId diff --git a/src/index.ts b/src/index.ts index b24e366..fc05788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import parseKanbanConfig from "./Gtd/parseKanbanConfig"; import collectKanban from "./Gtd/collectKanban"; import parseMatrixConfig from "./Gtd/parseMatrixConfig"; import collectMatrix from "./Gtd/collectMatrix"; +import matrixLabels from "./Gtd/matrixLabels"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; @@ -250,7 +251,13 @@ async function handleGetMatrix(message: { rawConfig?: string }) { configError || "Could not determine which note contains this matrix.", warnings: config.warnings, - board: { doFirst: [], schedule: [], delegate: [], eliminate: [] }, + labels: matrixLabels(config.mode), + board: { + topLeft: [], + topRight: [], + bottomLeft: [], + bottomRight: [], + }, stats: null, }; } @@ -267,6 +274,7 @@ async function handleGetMatrix(message: { rawConfig?: string }) { cardDetail: config.cardDetail, configError, warnings: [...config.warnings, ...result.warnings], + labels: matrixLabels(config.mode), board: result.board, stats: { scannedFolders: result.scannedFolders, diff --git a/src/manifest.json b/src/manifest.json index ee46a7f..ec120e2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.victorwiebe.joplin.plugin.gtd-calendar", "app_min_version": "2.7", - "version": "0.4.0", + "version": "0.5.0", "name": "GTD Calendar", "description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.", "author": "Victor Wiebe", diff --git a/src/tests/Gtd/matrix.test.ts b/src/tests/Gtd/matrix.test.ts index a784c8b..fae7c64 100644 --- a/src/tests/Gtd/matrix.test.ts +++ b/src/tests/Gtd/matrix.test.ts @@ -10,6 +10,7 @@ describe("parseMatrixConfig", () => { test("applies SPEC defaults", () => { const c = parseMatrixConfig({}); expect(c).toMatchObject({ + mode: "skeleton", title: null, scopeDepth: 0, todos: "gtd-only", @@ -17,11 +18,22 @@ describe("parseMatrixConfig", () => { sort: "asc", urgentTag: "urgent", importantTag: "important", + inProgressTag: "in-progress", + urgentWindow: 3, cardDetail: "hover", }); expect(c.warnings).toHaveLength(0); }); + test("mode parses and warns on invalid", () => { + expect(parseMatrixConfig({ mode: "eisenhower" }).mode).toBe( + "eisenhower" + ); + const bad = parseMatrixConfig({ mode: "wiebe" }); + expect(bad.mode).toBe("skeleton"); + expect(bad.warnings.some((w) => w.includes("mode"))).toBe(true); + }); + test("parses custom axis tags, lowercased", () => { const c = parseMatrixConfig({ "urgent-tag": "FIRE", @@ -104,12 +116,12 @@ describe("collectMatrix — quadrant bucketing", () => { }), "matrix-note", "board", - parseMatrixConfig({ todos: "all" }) + parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); - 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.board.topLeft.map((c) => c.id)).toEqual(["both"]); + expect(result.board.topRight.map((c) => c.id)).toEqual(["imp"]); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["urg"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["none"]); expect(result.cardCount).toBe(4); }); @@ -122,13 +134,13 @@ describe("collectMatrix — quadrant bucketing", () => { makeAdapter(folders, notes, { done: ["urgent", "important"] }), "matrix-note", "board", - parseMatrixConfig({ todos: "all" }) + parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); const allIds = [ - ...result.board.doFirst, - ...result.board.schedule, - ...result.board.delegate, - ...result.board.eliminate, + ...result.board.topLeft, + ...result.board.topRight, + ...result.board.bottomLeft, + ...result.board.bottomRight, ].map((c) => c.id); expect(allIds).toEqual(["open"]); }); @@ -141,11 +153,12 @@ describe("collectMatrix — quadrant bucketing", () => { "board", parseMatrixConfig({ todos: "all", + mode: "eisenhower", "urgent-tag": "fire", "important-tag": "matters", }) ); - expect(result.board.doFirst.map((c) => c.id)).toEqual(["x"]); + expect(result.board.topLeft.map((c) => c.id)).toEqual(["x"]); }); test("plain notes and the matrix note itself are excluded", async () => { @@ -158,10 +171,10 @@ describe("collectMatrix — quadrant bucketing", () => { makeAdapter(folders, notes), "matrix-note", "board", - parseMatrixConfig({ todos: "all" }) + parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); expect(result.cardCount).toBe(1); - expect(result.board.eliminate.map((c) => c.id)).toEqual(["t"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["t"]); }); test("gtd-only requires a gtd block", async () => { @@ -173,10 +186,10 @@ describe("collectMatrix — quadrant bucketing", () => { makeAdapter(folders, notes), "matrix-note", "board", - parseMatrixConfig({}) // todos: gtd-only default + parseMatrixConfig({ mode: "eisenhower" }) // todos: gtd-only default ); expect(result.cardCount).toBe(1); - expect(result.board.eliminate.map((c) => c.id)).toEqual(["in"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["in"]); }); test("due-date sort within a quadrant, dateless last", async () => { @@ -189,9 +202,9 @@ describe("collectMatrix — quadrant bucketing", () => { makeAdapter(folders, notes), "matrix-note", "board", - parseMatrixConfig({ todos: "all" }) + parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); - expect(result.board.eliminate.map((c) => c.id)).toEqual([ + expect(result.board.bottomRight.map((c) => c.id)).toEqual([ "soon", "later", "nodate", @@ -204,8 +217,122 @@ describe("collectMatrix — quadrant bucketing", () => { makeAdapter(folders, notes, { r: ["recurring", "urgent"] }), "matrix-note", "board", - parseMatrixConfig({ todos: "all" }) + parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); - expect(result.board.delegate[0].isRecurring).toBe(true); + expect(result.board.bottomLeft[0].isRecurring).toBe(true); + }); +}); + +describe("collectMatrix — Skeleton mode", () => { + const NOW = new Date(2026, 5, 15); // Jun 15 2026 + + function iso(y: number, m: number, d: number): number { + return new Date(y, m - 1, d, 12).getTime(); + } + + test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => { + const notes = [ + // active + due within window -> Do Next + makeNote({ id: "donext", todo_due: iso(2026, 6, 17) }), + // active + due later -> Scheduled + makeNote({ id: "sched", todo_due: iso(2026, 6, 25) }), + // not active + due soon -> On Deck + makeNote({ id: "ondeck", todo_due: iso(2026, 6, 16) }), + // not active, no date -> Backlog + makeNote({ id: "backlog" }), + ]; + const result = await collectMatrix( + makeAdapter(folders, notes, { + donext: ["in-progress"], + sched: ["in-progress"], + }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }), + NOW + ); + expect(result.board.topLeft.map((c) => c.id)).toEqual(["donext"]); + expect(result.board.topRight.map((c) => c.id)).toEqual(["sched"]); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["ondeck"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]); + }); + + test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => { + const notes = [makeNote({ id: "hot" })]; + const result = await collectMatrix( + makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }), + NOW + ); + expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]); + }); + + test("urgent tag alone (no date, not active) -> On Deck", async () => { + const notes = [makeNote({ id: "flag" })]; + const result = await collectMatrix( + makeAdapter(folders, notes, { flag: ["urgent"] }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }), + NOW + ); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["flag"]); + }); + + test("overdue counts as due soon", async () => { + const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }), + NOW + ); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["late"]); + }); + + test("dateless in-progress work -> Scheduled (active, no clock)", async () => { + const notes = [makeNote({ id: "wip" })]; + const result = await collectMatrix( + makeAdapter(folders, notes, { wip: ["in-progress"] }), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all" }), + NOW + ); + expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]); + }); + + test("urgent-window boundary: exactly N days out is due soon; N+1 is not", async () => { + const notes = [ + makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days + makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4 + ]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({ todos: "all", "urgent-window": 3 }), + NOW + ); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual([ + "past-edge", + ]); + }); + + test("gtd date override affects due-soon just like a due date", async () => { + const body = "```gtd\ndate: 2026-06-16\n```"; + const notes = [makeNote({ id: "g", body: body })]; + const result = await collectMatrix( + makeAdapter(folders, notes), + "matrix-note", + "board", + parseMatrixConfig({}), + NOW + ); + expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]); }); });