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
This commit is contained in:
Victor Wiebe 2026-07-03 13:30:44 +00:00 committed by Victor Wiebe
parent 1cc6f184f0
commit 4cb6700a20
13 changed files with 841 additions and 10 deletions

View File

@ -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.) 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 ## 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. 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.

24
SPEC.md
View File

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

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "0.3.1", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "0.3.1", "version": "0.3.2",
"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.3.2", "version": "0.4.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",

151
src/Gtd/collectMatrix.ts Normal file
View File

@ -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<MatrixResult> {
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<typeof extractGtdBlock>["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;
});
}

View File

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

View File

@ -126,3 +126,31 @@ export interface KanbanBoard {
inProgress: KanbanCard[]; inProgress: KanbanCard[];
done: 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
}

View File

@ -360,3 +360,50 @@
.gtd-unscheduled-group + .gtd-unscheduled-group { .gtd-unscheduled-group + .gtd-unscheduled-group {
margin-top: 0.5em; 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);
}

View File

@ -1,5 +1,6 @@
const CALENDAR_FENCE = "gtd-calendar"; const CALENDAR_FENCE = "gtd-calendar";
const KANBAN_FENCE = "gtd-kanban"; const KANBAN_FENCE = "gtd-kanban";
const MATRIX_FENCE = "gtd-matrix";
/** /**
* Markdown-it content script. * Markdown-it content script.
@ -38,14 +39,21 @@ export default function (context: { contentScriptId: string }) {
const token = tokens[idx]; const token = tokens[idx];
const isCalendar = token.info === CALENDAR_FENCE; const isCalendar = token.info === CALENDAR_FENCE;
const isKanban = token.info === KANBAN_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); return defaultRender(tokens, idx, options, env, self);
const encodedConfig = encodeURIComponent(token.content); const encodedConfig = encodeURIComponent(token.content);
const blockType = isCalendar ? "calendar" : "kanban"; const blockType = isCalendar
? "calendar"
: isKanban
? "kanban"
: "matrix";
const label = isCalendar const label = isCalendar
? "Loading GTD Calendar…" ? "Loading GTD Calendar…"
: "Loading GTD Kanban…"; : isKanban
? "Loading GTD Kanban…"
: "Loading GTD Matrix…";
return ( return (
`<div class="gtd-calendar-placeholder"` + `<div class="gtd-calendar-placeholder"` +

View File

@ -40,7 +40,11 @@
} }
const messageType = const messageType =
blockType === "kanban" ? "getKanban" : "getEvents"; blockType === "kanban"
? "getKanban"
: blockType === "matrix"
? "getMatrix"
: "getEvents";
webviewApi webviewApi
.postMessage(contentScriptId, { .postMessage(contentScriptId, {
@ -54,6 +58,8 @@
} }
if (blockType === "kanban") { if (blockType === "kanban") {
renderKanban(el, payload, contentScriptId); renderKanban(el, payload, contentScriptId);
} else if (blockType === "matrix") {
renderMatrix(el, payload, contentScriptId);
} else { } else {
renderPayload(el, payload, contentScriptId); renderPayload(el, payload, contentScriptId);
} }
@ -590,6 +596,138 @@
}); });
} }
// ------------------------------------------------------------------
// Eisenhower matrix (gtd-matrix block)
// ------------------------------------------------------------------
function renderMatrix(el, payload, contentScriptId) {
el.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "gtd-matrix";
if (payload.title) {
const heading = document.createElement("h3");
heading.className = "gtd-calendar-title";
heading.textContent = payload.title;
wrapper.appendChild(heading);
}
if (payload.configError) {
const error = document.createElement("p");
error.className = "gtd-calendar-error";
error.textContent = "Config problem: " + payload.configError;
wrapper.appendChild(error);
}
(payload.warnings || []).forEach(function (warning) {
const warn = document.createElement("p");
warn.className = "gtd-calendar-warning";
warn.textContent = "\u26A0 " + warning;
wrapper.appendChild(warn);
});
const board = payload.board || {
doFirst: [],
schedule: [],
delegate: [],
eliminate: [],
};
const detail = payload.cardDetail || "hover";
// Axis header row: blank corner + Urgent / Not urgent
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("Important"));
grid.appendChild(
matrixQuadrant(
"Do First",
board.doFirst,
"do-first",
detail,
contentScriptId
)
);
grid.appendChild(
matrixQuadrant(
"Schedule",
board.schedule,
"schedule",
detail,
contentScriptId
)
);
grid.appendChild(matrixAxisCell("Not important"));
grid.appendChild(
matrixQuadrant(
"Delegate",
board.delegate,
"delegate",
detail,
contentScriptId
)
);
grid.appendChild(
matrixQuadrant(
"Eliminate",
board.eliminate,
"eliminate",
detail,
contentScriptId
)
);
wrapper.appendChild(grid);
if (payload.stats) {
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
meta.textContent =
payload.stats.cardCount +
" card(s) from " +
payload.stats.scannedNotes +
" note(s) in " +
payload.stats.scannedFolders +
" folder(s)";
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
}
function matrixAxisCell(label) {
const cell = document.createElement("div");
cell.className = "gtd-matrix-axis";
cell.textContent = label;
return cell;
}
function matrixQuadrant(title, cards, kind, detail, contentScriptId) {
const quadrant = document.createElement("div");
quadrant.className = "gtd-matrix-quadrant gtd-matrix-" + kind;
const header = document.createElement("div");
header.className = "gtd-kanban-column-header";
header.textContent = title + " (" + cards.length + ")";
quadrant.appendChild(header);
const list = document.createElement("div");
list.className = "gtd-kanban-cards";
cards.forEach(function (card) {
list.appendChild(renderCard(card, detail, contentScriptId));
});
quadrant.appendChild(list);
return quadrant;
}
processPlaceholders(); processPlaceholders();
// Joplin may replace rendered content without re-running asset scripts; // Joplin may replace rendered content without re-running asset scripts;

View File

@ -8,6 +8,8 @@ import collectEvents from "./Gtd/collectEvents";
import groupEvents from "./Gtd/groupEvents"; import groupEvents from "./Gtd/groupEvents";
import parseKanbanConfig from "./Gtd/parseKanbanConfig"; import parseKanbanConfig from "./Gtd/parseKanbanConfig";
import collectKanban from "./Gtd/collectKanban"; import collectKanban from "./Gtd/collectKanban";
import parseMatrixConfig from "./Gtd/parseMatrixConfig";
import collectMatrix from "./Gtd/collectMatrix";
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";
@ -76,6 +78,8 @@ joplin.plugins.register({
return handleGetEvents(message); return handleGetEvents(message);
case "getKanban": case "getKanban":
return handleGetKanban(message); return handleGetKanban(message);
case "getMatrix":
return handleGetMatrix(message);
case "openNote": case "openNote":
return handleOpenNote(message); return handleOpenNote(message);
default: default:
@ -213,6 +217,65 @@ async function handleGetKanban(message: { rawConfig?: string }) {
}; };
} }
async function handleGetMatrix(message: { rawConfig?: string }) {
let rawParsed: any = {};
let configError: string | null = null;
try {
rawParsed = YAML.parse(message.rawConfig || "") || {};
} catch (error) {
configError = String(error);
}
const config = parseMatrixConfig(rawParsed);
let sourceNote: { id: string; title: string; parentId: string } | null =
null;
try {
const note = await joplin.workspace.selectedNote();
if (note) {
sourceNote = {
id: note.id,
title: note.title,
parentId: note.parent_id,
};
}
} catch (error) {
console.warn("gtd-matrix: could not resolve selected note", error);
}
if (!sourceNote) {
return {
title: config.title,
cardDetail: config.cardDetail,
configError:
configError ||
"Could not determine which note contains this matrix.",
warnings: config.warnings,
board: { doFirst: [], schedule: [], delegate: [], eliminate: [] },
stats: null,
};
}
const result = await collectMatrix(
joplinAdapter,
sourceNote.id,
sourceNote.parentId,
config
);
return {
title: config.title,
cardDetail: config.cardDetail,
configError,
warnings: [...config.warnings, ...result.warnings],
board: result.board,
stats: {
scannedFolders: result.scannedFolders,
scannedNotes: result.scannedNotes,
cardCount: result.cardCount,
},
};
}
async function handleOpenNote(message: { noteId?: string }) { async function handleOpenNote(message: { noteId?: string }) {
if (!message.noteId) return { ok: false, error: "No noteId provided" }; if (!message.noteId) return { ok: false, error: "No noteId provided" };
try { try {

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.3.2", "version": "0.4.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

@ -0,0 +1,211 @@
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
import collectMatrix from "../../Gtd/collectMatrix";
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// ---------------------------------------------------------------------------
// parseMatrixConfig
// ---------------------------------------------------------------------------
describe("parseMatrixConfig", () => {
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>): 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<string, string[]> = {}
): 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);
});
});