v0.5.0: the Skeleton Matrix (mode option on gtd-matrix)

- 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
This commit is contained in:
Victor Wiebe 2026-07-03 13:58:37 +00:00 committed by Victor Wiebe
parent 4cb6700a20
commit 6bb3227574
12 changed files with 353 additions and 77 deletions

View File

@ -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.) 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 ```gtd-matrix
title: Priorities title: Priorities
todos: all todos: all
urgent-tag: urgent mode: skeleton
important-tag: important
``` ```
```` ````
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 | | **Active** | Do Next | Scheduled |
| **Not important** | Delegate | Eliminate | | **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 | | Option | Values | Default | Description |
|---|---|---|---| |---|---|---|---|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
| `title` | text | — | Heading above the matrix. | | `title` | text | — | Heading above the matrix. |
| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar and kanban. | | `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. | | `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-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. | | `sort` | `asc`, `desc` | `asc` | Sort direction. |
| `urgent-tag` | any tag name | `urgent` | The tag marking the urgent 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` | The tag marking the important axis. | | `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. | | `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. Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only.

View File

@ -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.) **`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.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. 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.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "0.3.2", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "0.3.2", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"date-fns": "^2.29.3", "date-fns": "^2.29.3",

View File

@ -1,6 +1,6 @@
{ {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "0.4.0", "version": "0.5.0",
"scripts": { "scripts": {
"test": "jest", "test": "jest",
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",

View File

@ -36,7 +36,8 @@ export default async function collectMatrix(
adapter: DataAdapter, adapter: DataAdapter,
matrixNoteId: string, matrixNoteId: string,
matrixFolderId: string, matrixFolderId: string,
config: MatrixConfig config: MatrixConfig,
now: Date = new Date()
): Promise<MatrixResult> { ): Promise<MatrixResult> {
const warnings: string[] = []; const warnings: string[] = [];
@ -48,10 +49,13 @@ export default async function collectMatrix(
); );
let scannedNotes = 0; let scannedNotes = 0;
const doFirst: KanbanCard[] = []; const topLeft: KanbanCard[] = [];
const schedule: KanbanCard[] = []; const topRight: KanbanCard[] = [];
const delegate: KanbanCard[] = []; const bottomLeft: KanbanCard[] = [];
const eliminate: 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) { for (const folderId of folderIds) {
const notes = await adapter.getNotesInFolder(folderId); const notes = await adapter.getNotesInFolder(folderId);
@ -74,34 +78,60 @@ export default async function collectMatrix(
const tags = await adapter.getNoteTagTitles(note.id); const tags = await adapter.getNoteTagTitles(note.id);
const card = buildCard(note, result.block, tags); const card = buildCard(note, result.block, tags);
const urgent = tags.includes(config.urgentTag); let topRow: boolean;
const important = tags.includes(config.importantTag); let leftColumn: boolean;
if (urgent && important) doFirst.push(card); if (config.mode === "eisenhower") {
else if (important) schedule.push(card); // Rows: important / not. Columns: urgent / not.
else if (urgent) delegate.push(card); topRow = tags.includes(config.importantTag);
else eliminate.push(card); 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(topLeft, config);
sortColumn(schedule, config); sortColumn(topRight, config);
sortColumn(delegate, config); sortColumn(bottomLeft, config);
sortColumn(eliminate, config); sortColumn(bottomRight, config);
return { return {
board: { doFirst, schedule, delegate, eliminate }, board: { topLeft, topRight, bottomLeft, bottomRight },
warnings, warnings,
scannedFolders: folderIds.length, scannedFolders: folderIds.length,
scannedNotes, scannedNotes,
cardCount: cardCount:
doFirst.length + topLeft.length +
schedule.length + topRight.length +
delegate.length + bottomLeft.length +
eliminate.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( function buildCard(
note: RawNote, note: RawNote,
block: ReturnType<typeof extractGtdBlock>["block"], block: ReturnType<typeof extractGtdBlock>["block"],

28
src/Gtd/matrixLabels.ts Normal file
View File

@ -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",
},
};
}

View File

@ -2,8 +2,10 @@ import {
InclusionMode, InclusionMode,
KanbanSortType, KanbanSortType,
MatrixConfig, MatrixConfig,
MatrixMode,
URGENT_TAG, URGENT_TAG,
IMPORTANT_TAG, IMPORTANT_TAG,
IN_PROGRESS_TAG,
} from "./types"; } from "./types";
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; 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 input = raw && typeof raw === "object" ? raw : {};
const knownKeys = [ const knownKeys = [
"mode",
"title", "title",
"scope", "scope",
"todos", "todos",
@ -26,12 +29,25 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
"sort", "sort",
"urgent-tag", "urgent-tag",
"important-tag", "important-tag",
"in-progress-tag",
"urgent-window",
"card-detail", "card-detail",
]; ];
for (const key of Object.keys(input)) { for (const key of Object.keys(input)) {
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`); 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 = const title =
input.title !== undefined && input.title !== null input.title !== undefined && input.title !== null
? String(input.title) ? 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 // card-detail
let cardDetail: MatrixConfig["cardDetail"] = "hover"; let cardDetail: MatrixConfig["cardDetail"] = "hover";
if (input["card-detail"] !== undefined) { if (input["card-detail"] !== undefined) {
@ -111,6 +140,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
} }
return { return {
mode,
title, title,
scopeDepth, scopeDepth,
todos, todos,
@ -118,6 +148,8 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
sort, sort,
urgentTag, urgentTag,
importantTag, importantTag,
inProgressTag,
urgentWindow,
cardDetail, cardDetail,
warnings, warnings,
}; };

View File

@ -134,8 +134,11 @@ export interface KanbanBoard {
export const URGENT_TAG = "urgent"; export const URGENT_TAG = "urgent";
export const IMPORTANT_TAG = "important"; export const IMPORTANT_TAG = "important";
export type MatrixMode = "skeleton" | "eisenhower";
/** Parsed and normalised ```gtd-matrix config. */ /** Parsed and normalised ```gtd-matrix config. */
export interface MatrixConfig { export interface MatrixConfig {
mode: MatrixMode;
title: string | null; title: string | null;
scopeDepth: number; scopeDepth: number;
todos: InclusionMode; todos: InclusionMode;
@ -143,14 +146,37 @@ export interface MatrixConfig {
sort: "asc" | "desc"; sort: "asc" | "desc";
urgentTag: string; urgentTag: string;
importantTag: 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"; cardDetail: "hover" | "always" | "none";
warnings: string[]; 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 { export interface MatrixBoard {
doFirst: KanbanCard[]; // urgent + important topLeft: KanbanCard[];
schedule: KanbanCard[]; // important only topRight: KanbanCard[];
delegate: KanbanCard[]; // urgent only bottomLeft: KanbanCard[];
eliminate: KanbanCard[]; // neither 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;
};
} }

View File

@ -628,26 +628,36 @@
}); });
const board = payload.board || { const board = payload.board || {
doFirst: [], topLeft: [],
schedule: [], topRight: [],
delegate: [], bottomLeft: [],
eliminate: [], 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"; 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"); const grid = document.createElement("div");
grid.className = "gtd-matrix-grid"; grid.className = "gtd-matrix-grid";
grid.appendChild(matrixAxisCell("")); grid.appendChild(matrixAxisCell(""));
grid.appendChild(matrixAxisCell("Urgent")); grid.appendChild(matrixAxisCell(labels.columns[0]));
grid.appendChild(matrixAxisCell("Not urgent")); grid.appendChild(matrixAxisCell(labels.columns[1]));
grid.appendChild(matrixAxisCell("Important")); grid.appendChild(matrixAxisCell(labels.rows[0]));
grid.appendChild( grid.appendChild(
matrixQuadrant( matrixQuadrant(
"Do First", labels.quadrants.topLeft,
board.doFirst, board.topLeft,
"do-first", "do-first",
detail, detail,
contentScriptId contentScriptId
@ -655,19 +665,19 @@
); );
grid.appendChild( grid.appendChild(
matrixQuadrant( matrixQuadrant(
"Schedule", labels.quadrants.topRight,
board.schedule, board.topRight,
"schedule", "schedule",
detail, detail,
contentScriptId contentScriptId
) )
); );
grid.appendChild(matrixAxisCell("Not important")); grid.appendChild(matrixAxisCell(labels.rows[1]));
grid.appendChild( grid.appendChild(
matrixQuadrant( matrixQuadrant(
"Delegate", labels.quadrants.bottomLeft,
board.delegate, board.bottomLeft,
"delegate", "delegate",
detail, detail,
contentScriptId contentScriptId
@ -675,8 +685,8 @@
); );
grid.appendChild( grid.appendChild(
matrixQuadrant( matrixQuadrant(
"Eliminate", labels.quadrants.bottomRight,
board.eliminate, board.bottomRight,
"eliminate", "eliminate",
detail, detail,
contentScriptId contentScriptId

View File

@ -10,6 +10,7 @@ import parseKanbanConfig from "./Gtd/parseKanbanConfig";
import collectKanban from "./Gtd/collectKanban"; import collectKanban from "./Gtd/collectKanban";
import parseMatrixConfig from "./Gtd/parseMatrixConfig"; import parseMatrixConfig from "./Gtd/parseMatrixConfig";
import collectMatrix from "./Gtd/collectMatrix"; import collectMatrix from "./Gtd/collectMatrix";
import matrixLabels from "./Gtd/matrixLabels";
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
@ -250,7 +251,13 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
configError || configError ||
"Could not determine which note contains this matrix.", "Could not determine which note contains this matrix.",
warnings: config.warnings, warnings: config.warnings,
board: { doFirst: [], schedule: [], delegate: [], eliminate: [] }, labels: matrixLabels(config.mode),
board: {
topLeft: [],
topRight: [],
bottomLeft: [],
bottomRight: [],
},
stats: null, stats: null,
}; };
} }
@ -267,6 +274,7 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
cardDetail: config.cardDetail, cardDetail: config.cardDetail,
configError, configError,
warnings: [...config.warnings, ...result.warnings], warnings: [...config.warnings, ...result.warnings],
labels: matrixLabels(config.mode),
board: result.board, board: result.board,
stats: { stats: {
scannedFolders: result.scannedFolders, scannedFolders: result.scannedFolders,

View File

@ -2,7 +2,7 @@
"manifest_version": 1, "manifest_version": 1,
"id": "com.victorwiebe.joplin.plugin.gtd-calendar", "id": "com.victorwiebe.joplin.plugin.gtd-calendar",
"app_min_version": "2.7", "app_min_version": "2.7",
"version": "0.4.0", "version": "0.5.0",
"name": "GTD Calendar", "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.", "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", "author": "Victor Wiebe",

View File

@ -10,6 +10,7 @@ describe("parseMatrixConfig", () => {
test("applies SPEC defaults", () => { test("applies SPEC defaults", () => {
const c = parseMatrixConfig({}); const c = parseMatrixConfig({});
expect(c).toMatchObject({ expect(c).toMatchObject({
mode: "skeleton",
title: null, title: null,
scopeDepth: 0, scopeDepth: 0,
todos: "gtd-only", todos: "gtd-only",
@ -17,11 +18,22 @@ describe("parseMatrixConfig", () => {
sort: "asc", sort: "asc",
urgentTag: "urgent", urgentTag: "urgent",
importantTag: "important", importantTag: "important",
inProgressTag: "in-progress",
urgentWindow: 3,
cardDetail: "hover", cardDetail: "hover",
}); });
expect(c.warnings).toHaveLength(0); 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", () => { test("parses custom axis tags, lowercased", () => {
const c = parseMatrixConfig({ const c = parseMatrixConfig({
"urgent-tag": "FIRE", "urgent-tag": "FIRE",
@ -104,12 +116,12 @@ describe("collectMatrix — quadrant bucketing", () => {
}), }),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({ todos: "all" }) parseMatrixConfig({ todos: "all", mode: "eisenhower" })
); );
expect(result.board.doFirst.map((c) => c.id)).toEqual(["both"]); expect(result.board.topLeft.map((c) => c.id)).toEqual(["both"]);
expect(result.board.schedule.map((c) => c.id)).toEqual(["imp"]); expect(result.board.topRight.map((c) => c.id)).toEqual(["imp"]);
expect(result.board.delegate.map((c) => c.id)).toEqual(["urg"]); expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["urg"]);
expect(result.board.eliminate.map((c) => c.id)).toEqual(["none"]); expect(result.board.bottomRight.map((c) => c.id)).toEqual(["none"]);
expect(result.cardCount).toBe(4); expect(result.cardCount).toBe(4);
}); });
@ -122,13 +134,13 @@ describe("collectMatrix — quadrant bucketing", () => {
makeAdapter(folders, notes, { done: ["urgent", "important"] }), makeAdapter(folders, notes, { done: ["urgent", "important"] }),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({ todos: "all" }) parseMatrixConfig({ todos: "all", mode: "eisenhower" })
); );
const allIds = [ const allIds = [
...result.board.doFirst, ...result.board.topLeft,
...result.board.schedule, ...result.board.topRight,
...result.board.delegate, ...result.board.bottomLeft,
...result.board.eliminate, ...result.board.bottomRight,
].map((c) => c.id); ].map((c) => c.id);
expect(allIds).toEqual(["open"]); expect(allIds).toEqual(["open"]);
}); });
@ -141,11 +153,12 @@ describe("collectMatrix — quadrant bucketing", () => {
"board", "board",
parseMatrixConfig({ parseMatrixConfig({
todos: "all", todos: "all",
mode: "eisenhower",
"urgent-tag": "fire", "urgent-tag": "fire",
"important-tag": "matters", "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 () => { test("plain notes and the matrix note itself are excluded", async () => {
@ -158,10 +171,10 @@ describe("collectMatrix — quadrant bucketing", () => {
makeAdapter(folders, notes), makeAdapter(folders, notes),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({ todos: "all" }) parseMatrixConfig({ todos: "all", mode: "eisenhower" })
); );
expect(result.cardCount).toBe(1); 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 () => { test("gtd-only requires a gtd block", async () => {
@ -173,10 +186,10 @@ describe("collectMatrix — quadrant bucketing", () => {
makeAdapter(folders, notes), makeAdapter(folders, notes),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({}) // todos: gtd-only default parseMatrixConfig({ mode: "eisenhower" }) // todos: gtd-only default
); );
expect(result.cardCount).toBe(1); 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 () => { test("due-date sort within a quadrant, dateless last", async () => {
@ -189,9 +202,9 @@ describe("collectMatrix — quadrant bucketing", () => {
makeAdapter(folders, notes), makeAdapter(folders, notes),
"matrix-note", "matrix-note",
"board", "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", "soon",
"later", "later",
"nodate", "nodate",
@ -204,8 +217,122 @@ describe("collectMatrix — quadrant bucketing", () => {
makeAdapter(folders, notes, { r: ["recurring", "urgent"] }), makeAdapter(folders, notes, { r: ["recurring", "urgent"] }),
"matrix-note", "matrix-note",
"board", "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"]);
}); });
}); });