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:
parent
1cc6f184f0
commit
4cb6700a20
35
README.md
35
README.md
@ -118,6 +118,41 @@ A completed to-do always lands in **Done**, even if it still carries the in-prog
|
||||
|
||||
Cards are compact (title, recurrence ↻ if applicable) and click through to the to-do. The board is **read-only** in this version — it reflects your to-dos' state but doesn't change it. (Drag-and-drop to move cards between columns is planned.)
|
||||
|
||||
## Eisenhower Matrix
|
||||
|
||||
A third view: a 2×2 prioritisation matrix, driven by two tags. Add a `gtd-matrix` block anywhere:
|
||||
|
||||
````
|
||||
```gtd-matrix
|
||||
title: Priorities
|
||||
todos: all
|
||||
urgent-tag: urgent
|
||||
important-tag: important
|
||||
```
|
||||
````
|
||||
|
||||
To-dos are sorted into four quadrants by which of the two axis tags they carry:
|
||||
|
||||
| | **Urgent** | **Not urgent** |
|
||||
|---|---|---|
|
||||
| **Important** | Do First | Schedule |
|
||||
| **Not important** | Delegate | Eliminate |
|
||||
|
||||
A to-do with neither tag lands in **Eliminate** — the matrix's pointed default. **Completed to-dos don't appear at all**: the matrix is for prioritising open work (the kanban's Done column is where completions live).
|
||||
|
||||
| Option | Values | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `title` | text | — | Heading above the matrix. |
|
||||
| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar and kanban. |
|
||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. Plain notes never do. |
|
||||
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
|
||||
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||
| `urgent-tag` | any tag name | `urgent` | The tag marking the urgent axis. |
|
||||
| `important-tag` | any tag name | `important` | The tag marking the important axis. |
|
||||
| `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. |
|
||||
|
||||
Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only.
|
||||
|
||||
## Drilldown
|
||||
|
||||
Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note.
|
||||
|
||||
24
SPEC.md
24
SPEC.md
@ -236,9 +236,24 @@ done-window: 7 # days; integer or "all"; default 7
|
||||
|
||||
**`sort-type` vocabulary reconciliation:** the calendar block's `sort-type` (currently `title | modified_date`) is unified toward `due-date | title | modified-date` across both blocks so they read consistently. For the calendar, `due-date` is accepted but falls back to chronological where less meaningful. (Note the spelling shift `modified_date` → `modified-date` for cross-block consistency; the old spelling stays accepted as an alias to avoid breaking existing calendars.)
|
||||
|
||||
### v0.4.0 — Drag-and-drop kanban (write-back)
|
||||
### v0.4.0 — Eisenhower Matrix (`gtd-matrix` block) — SHIPPED
|
||||
|
||||
The first feature that makes the plugin a **controller** of data rather than a viewer — warrants its own design pass.
|
||||
A fourth display block: a 2×2 prioritisation matrix, tag-driven like the kanban. Chosen ahead of drag-and-drop deliberately: it is on the **views axis** (another rendering of tagged to-dos, reusing the whole data layer, read-only) rather than the **authoring axis** (vault mutation), so it deepens what the plugin already is without committing to write-back.
|
||||
|
||||
- **Quadrants** from two configurable tags (`urgent-tag:` default `urgent`; `important-tag:` default `important`):
|
||||
- urgent + important → **Do First**
|
||||
- important only → **Schedule**
|
||||
- urgent only → **Delegate**
|
||||
- neither → **Eliminate** (the default bucket for untagged to-dos — a deliberately pointed default)
|
||||
- **Completed to-dos are excluded entirely** — the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live.
|
||||
- To-dos only; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as the kanban; same compact clickable cards with ↻.
|
||||
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
|
||||
|
||||
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic
|
||||
|
||||
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
|
||||
|
||||
**Drag-and-drop kanban (write-back).** The first feature making the plugin a **controller** of data rather than a viewer — warrants its own design pass.
|
||||
|
||||
- Dragging a card writes back to Joplin via the existing webview→plugin channel:
|
||||
- → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate).
|
||||
@ -246,3 +261,8 @@ The first feature that makes the plugin a **controller** of data rather than a v
|
||||
- → *Backlog*: remove the tag / clear completion.
|
||||
- **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes.
|
||||
- Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging.
|
||||
- **Multi-board refresh:** with Persistent Layout keeping several dashboards rendered, a write from one board leaves others stale until re-render — the refresh question is sharper in the fractal-dashboard architecture than for single-board use.
|
||||
|
||||
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.
|
||||
|
||||
**Create-to-do from calendar.** Click/affordance on a day cell creates a to-do due that day. Open questions: trigger mechanism in the read-only viewer (double-click/right-click may be claimed by Joplin; per-cell "+" affordance likelier), target notebook rule, whether created to-dos get an empty gtd block so they appear under `todos: gtd-only`. Prototype the trigger before designing the rest.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "joplin-plugin-gtd-calendar",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "joplin-plugin-gtd-calendar",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"date-fns": "^2.29.3",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "joplin-plugin-gtd-calendar",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
|
||||
|
||||
151
src/Gtd/collectMatrix.ts
Normal file
151
src/Gtd/collectMatrix.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
130
src/Gtd/parseMatrixConfig.ts
Normal file
130
src/Gtd/parseMatrixConfig.ts
Normal 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;
|
||||
}
|
||||
@ -126,3 +126,31 @@ export interface KanbanBoard {
|
||||
inProgress: KanbanCard[];
|
||||
done: KanbanCard[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tags for the Eisenhower matrix axes. Overridable via the
|
||||
* gtd-matrix `urgent-tag:` / `important-tag:` options.
|
||||
*/
|
||||
export const URGENT_TAG = "urgent";
|
||||
export const IMPORTANT_TAG = "important";
|
||||
|
||||
/** Parsed and normalised ```gtd-matrix config. */
|
||||
export interface MatrixConfig {
|
||||
title: string | null;
|
||||
scopeDepth: number;
|
||||
todos: InclusionMode;
|
||||
sortType: KanbanSortType;
|
||||
sort: "asc" | "desc";
|
||||
urgentTag: string;
|
||||
importantTag: string;
|
||||
cardDetail: "hover" | "always" | "none";
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** The four Eisenhower quadrants. Cards are KanbanCards (same shape). */
|
||||
export interface MatrixBoard {
|
||||
doFirst: KanbanCard[]; // urgent + important
|
||||
schedule: KanbanCard[]; // important only
|
||||
delegate: KanbanCard[]; // urgent only
|
||||
eliminate: KanbanCard[]; // neither
|
||||
}
|
||||
|
||||
@ -360,3 +360,50 @@
|
||||
.gtd-unscheduled-group + .gtd-unscheduled-group {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
/* gtd-matrix (Eisenhower) */
|
||||
|
||||
.gtd-matrix-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
gap: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gtd-matrix-axis {
|
||||
font-size: 0.78em;
|
||||
font-weight: bold;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.2em 0.3em;
|
||||
writing-mode: initial;
|
||||
}
|
||||
|
||||
.gtd-matrix-quadrant {
|
||||
border: 1px solid rgba(128, 128, 128, 0.35);
|
||||
border-radius: 6px;
|
||||
min-height: 4em;
|
||||
}
|
||||
|
||||
.gtd-matrix-do-first {
|
||||
background-color: rgba(220, 60, 60, 0.08);
|
||||
border-color: rgba(220, 60, 60, 0.5);
|
||||
}
|
||||
|
||||
.gtd-matrix-schedule {
|
||||
background-color: rgba(60, 140, 220, 0.08);
|
||||
border-color: rgba(60, 140, 220, 0.5);
|
||||
}
|
||||
|
||||
.gtd-matrix-delegate {
|
||||
background-color: rgba(230, 160, 40, 0.08);
|
||||
border-color: rgba(230, 160, 40, 0.5);
|
||||
}
|
||||
|
||||
.gtd-matrix-eliminate {
|
||||
background-color: rgba(128, 128, 128, 0.06);
|
||||
border-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const CALENDAR_FENCE = "gtd-calendar";
|
||||
const KANBAN_FENCE = "gtd-kanban";
|
||||
const MATRIX_FENCE = "gtd-matrix";
|
||||
|
||||
/**
|
||||
* Markdown-it content script.
|
||||
@ -38,14 +39,21 @@ export default function (context: { contentScriptId: string }) {
|
||||
const token = tokens[idx];
|
||||
const isCalendar = token.info === CALENDAR_FENCE;
|
||||
const isKanban = token.info === KANBAN_FENCE;
|
||||
if (!isCalendar && !isKanban)
|
||||
const isMatrix = token.info === MATRIX_FENCE;
|
||||
if (!isCalendar && !isKanban && !isMatrix)
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
|
||||
const encodedConfig = encodeURIComponent(token.content);
|
||||
const blockType = isCalendar ? "calendar" : "kanban";
|
||||
const blockType = isCalendar
|
||||
? "calendar"
|
||||
: isKanban
|
||||
? "kanban"
|
||||
: "matrix";
|
||||
const label = isCalendar
|
||||
? "Loading GTD Calendar…"
|
||||
: "Loading GTD Kanban…";
|
||||
: isKanban
|
||||
? "Loading GTD Kanban…"
|
||||
: "Loading GTD Matrix…";
|
||||
|
||||
return (
|
||||
`<div class="gtd-calendar-placeholder"` +
|
||||
|
||||
@ -40,7 +40,11 @@
|
||||
}
|
||||
|
||||
const messageType =
|
||||
blockType === "kanban" ? "getKanban" : "getEvents";
|
||||
blockType === "kanban"
|
||||
? "getKanban"
|
||||
: blockType === "matrix"
|
||||
? "getMatrix"
|
||||
: "getEvents";
|
||||
|
||||
webviewApi
|
||||
.postMessage(contentScriptId, {
|
||||
@ -54,6 +58,8 @@
|
||||
}
|
||||
if (blockType === "kanban") {
|
||||
renderKanban(el, payload, contentScriptId);
|
||||
} else if (blockType === "matrix") {
|
||||
renderMatrix(el, payload, contentScriptId);
|
||||
} else {
|
||||
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();
|
||||
|
||||
// Joplin may replace rendered content without re-running asset scripts;
|
||||
|
||||
63
src/index.ts
63
src/index.ts
@ -8,6 +8,8 @@ import collectEvents from "./Gtd/collectEvents";
|
||||
import groupEvents from "./Gtd/groupEvents";
|
||||
import parseKanbanConfig from "./Gtd/parseKanbanConfig";
|
||||
import collectKanban from "./Gtd/collectKanban";
|
||||
import parseMatrixConfig from "./Gtd/parseMatrixConfig";
|
||||
import collectMatrix from "./Gtd/collectMatrix";
|
||||
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
|
||||
|
||||
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
||||
@ -76,6 +78,8 @@ joplin.plugins.register({
|
||||
return handleGetEvents(message);
|
||||
case "getKanban":
|
||||
return handleGetKanban(message);
|
||||
case "getMatrix":
|
||||
return handleGetMatrix(message);
|
||||
case "openNote":
|
||||
return handleOpenNote(message);
|
||||
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 }) {
|
||||
if (!message.noteId) return { ok: false, error: "No noteId provided" };
|
||||
try {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"manifest_version": 1,
|
||||
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
||||
"app_min_version": "2.7",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.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",
|
||||
|
||||
211
src/tests/Gtd/matrix.test.ts
Normal file
211
src/tests/Gtd/matrix.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user