From 2b8b17f4fe7647ed8cae8fed4ccdf053db9aafd7 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Sat, 8 Aug 2026 14:35:56 -0400 Subject: [PATCH] Make Skeleton matrix tag-driven and editable for v2.2.0 --- CHANGELOG.md | 13 +++++ README.md | 24 +++++----- package-lock.json | 4 +- package.json | 2 +- src/Gtd/collectMatrix.ts | 43 ++++++----------- src/Gtd/matrixLabels.ts | 8 ++-- src/Gtd/matrixMoveHandler.ts | 2 +- src/Gtd/matrixMutation.ts | 18 +++++++ src/Gtd/parseMatrixConfig.ts | 29 ++++-------- src/Gtd/types.ts | 11 +++-- src/gtd-calendar-webview.js | 8 ++-- src/manifest.json | 2 +- src/tests/Gtd/matrix.test.ts | 63 ++++++++++++------------- src/tests/Gtd/matrixMoveHandler.test.ts | 12 ++++- src/tests/Gtd/matrixMutation.test.ts | 25 +++++++++- 15 files changed, 152 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca28706..8297d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases. +## 2.2.0 — Unreleased + +### Tag-driven editable Skeleton matrix + +- Skeleton routing no longer uses native to-do due dates or `gtd` dates. +- Cards route by tag precedence: `urgent`, `in-progress`, `on-deck`, then + Backlog. The tags remain configurable with `urgent-tag`, `in-progress-tag`, + and the new `on-deck-tag` option. +- Skeleton quadrants are now Urgent, In Progress, On Deck, and Backlog. +- `editable: yes` enables the existing pointer drag and keyboard move controls + for Skeleton matrices. A move normalizes the three workflow tags while + preserving dates, completion, content, notebooks, and unrelated tags. + ## 2.1.0 — Unreleased ### Notebook-tree exclusions diff --git a/README.md b/README.md index bc37acf..d5f58f2 100644 --- a/README.md +++ b/README.md @@ -282,14 +282,14 @@ Two modes: ### `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): +Built to complement the kanban and calendar as a four-state, tag-driven workflow. Due dates and `gtd` dates do not affect Skeleton placement. When old or externally edited cards carry overlapping workflow tags, the first matching state below wins: -| | **Due soon** | **Not due soon** | +| | **Priority** | **Current / backlog** | |---|---|---| -| **Active** | Do Next | Scheduled | -| **Not active** | On Deck | Backlog | +| **Now** | Urgent (`urgent`) | In Progress (`in-progress`) | +| **Next / later** | On Deck (`on-deck`) | Backlog (none) | -**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). Eligible ordinary notes use their `gtd` date and the same tag rules; completed to-dos and notes carrying `done-tag` do not appear at all. +Precedence is Urgent, In Progress, On Deck, then Backlog. Completed to-dos and notes carrying `done-tag` do not appear at all. ### `mode: eisenhower` — urgent/important axes @@ -302,7 +302,7 @@ On Deck (urgent), and Backlog (neither). | Option | Values | Default | Description | |---|---|---|---| | `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. | -| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves for Eisenhower matrices on desktop. Skeleton remains read-only; invalid or ambiguous configurations warn and disable editing. | +| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves on desktop. Ambiguous tag configurations warn and disable editing. | | `title` | text | — | Heading above the matrix. | | `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). | | `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. | @@ -312,22 +312,22 @@ On Deck (urgent), and Backlog (neither). | `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. This option does not affect ordinary notes, which appear only when they contain a `gtd` block. | | `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` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. | +| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; Skeleton's Urgent destination. | | `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". | +| `in-progress-tag` | any tag name | `in-progress` | Skeleton's In Progress destination; shared with kanban by default. | +| `on-deck-tag` | any tag name | `on-deck` | Skeleton's On Deck destination. | | `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. | | `page-size` | positive integer | `10` | Cards initially shown in each quadrant and revealed by each **List more** click. Invalid values warn and fall back to 10. | Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals. -On an editable Eisenhower matrix, drag a card by its move handle or use the +On an editable matrix, drag a card by its move handle or use the disclosure arrow beneath it to open the keyboard **Move to…** menu. Do Next adds both axis tags; Scheduled keeps only important; On Deck keeps only urgent; and Backlog removes both. Moves never change completion, dates, content, notebook, or unrelated tags, and sorting plus pagination remain authoritative after the -matrix refreshes. Skeleton matrices are intentionally read-only; `editable: yes` -with Skeleton warns and exposes no inert controls. +matrix refreshes. In Skeleton mode, destinations keep exactly one of the three +workflow tags (or none for Backlog); due dates are never changed. Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄 when no icon is set, and never show completion or recurrence styling. Empty and diff --git a/package-lock.json b/package-lock.json index c5542e0..80d40f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "joplin-plugin-gtd-calendar", - "version": "2.1.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "date-fns": "^2.29.3", diff --git a/package.json b/package.json index 02009b3..b030455 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "2.1.0", + "version": "2.2.0", "scripts": { "test": "jest", "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", diff --git a/src/Gtd/collectMatrix.ts b/src/Gtd/collectMatrix.ts index 3c8125b..faf5ffc 100644 --- a/src/Gtd/collectMatrix.ts +++ b/src/Gtd/collectMatrix.ts @@ -27,11 +27,9 @@ export interface MatrixResult { * - notes and todos filters apply independently. * - Completed to-dos and done-tag notes 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 Next - * important only -> Scheduled - * urgent only -> On Deck - * neither -> Backlog (default bucket for untagged) + * - Eisenhower uses its two axis tags. + * - Skeleton uses exclusive workflow destinations with urgent > in-progress + * > on-deck precedence and Backlog as the untagged default. * * The matrix note itself is always excluded. */ @@ -40,7 +38,7 @@ export default async function collectMatrix( matrixNoteId: string, matrixFolderId: string, config: MatrixConfig, - now: Date = new Date() + _now: Date = new Date() ): Promise { const warnings: string[] = []; @@ -76,9 +74,6 @@ export default async function collectMatrix( }); } - // Skeleton mode: a date on or before this ISO threshold is "due soon". - const soonThreshold = isoDaysFromNow(now, config.urgentWindow); - // Bodies preserve explicit note opt-in and optional to-do card overrides. // When both types are disabled, metadata alone is sufficient for scan totals. const includeBody = config.notes !== "none" || config.todos !== "none"; @@ -123,13 +118,17 @@ export default async function collectMatrix( topRow = tags.includes(config.importantTag); leftColumn = tags.includes(config.urgentTag); } else { - // Skeleton. Rows: active (in-progress) / not. - // Columns: due soon (urgent tag overrides; else date within - // the window, overdue included) / not. - topRow = tags.includes(config.inProgressTag); - leftColumn = - tags.includes(config.urgentTag) || - (card.date !== null && card.date <= soonThreshold); + // Skeleton is a four-state tag workflow. Precedence makes + // overlapping legacy tags deterministic until the next move. + if (tags.includes(config.urgentTag)) { + topRow = true; leftColumn = true; + } else if (tags.includes(config.inProgressTag)) { + topRow = true; leftColumn = false; + } else if (tags.includes(config.onDeckTag)) { + topRow = false; leftColumn = true; + } else { + topRow = false; leftColumn = false; + } } if (topRow && leftColumn) targetBoard.topLeft.push(card); @@ -194,18 +193,6 @@ function countBoard(board: MatrixBoard): number { ); } -/** 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 sortColumn(cards: KanbanCard[], config: MatrixConfig): void { const direction = config.sort === "desc" ? -1 : 1; diff --git a/src/Gtd/matrixLabels.ts b/src/Gtd/matrixLabels.ts index 56ca8b7..a26340f 100644 --- a/src/Gtd/matrixLabels.ts +++ b/src/Gtd/matrixLabels.ts @@ -16,11 +16,11 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels { } // skeleton return { - columns: ["Due soon", "Not due soon"], - rows: ["Active", "Not active"], + columns: ["Priority", "Current / backlog"], + rows: ["Now", "Next / later"], quadrants: { - topLeft: "Do Next", - topRight: "Scheduled", + topLeft: "Urgent", + topRight: "In Progress", bottomLeft: "On Deck", bottomRight: "Backlog", }, diff --git a/src/Gtd/matrixMoveHandler.ts b/src/Gtd/matrixMoveHandler.ts index f0fd418..5e26422 100644 --- a/src/Gtd/matrixMoveHandler.ts +++ b/src/Gtd/matrixMoveHandler.ts @@ -99,7 +99,7 @@ export async function validateAndMoveMatrixCard( return result("stale", viewInstanceId, "The matrix configuration is no longer valid."); } const config = parseMatrixConfig(parsed); - if (!config.editable || config.mode !== "eisenhower") { + if (!config.editable) { return result("error", viewInstanceId, "This matrix is read-only."); } diff --git a/src/Gtd/matrixMutation.ts b/src/Gtd/matrixMutation.ts index 506f86b..837dc11 100644 --- a/src/Gtd/matrixMutation.ts +++ b/src/Gtd/matrixMutation.ts @@ -1,13 +1,17 @@ import { ensureNoteTag, removeNoteTag } from "./kanbanMutation"; import { MatrixDestination, + MatrixMode, MutationAdapter, RawNote, } from "./types"; export interface MatrixMutationConfig { + mode: MatrixMode; urgentTag: string; importantTag: string; + inProgressTag: string; + onDeckTag: string; } export interface MatrixMutationOutcome { @@ -36,6 +40,20 @@ export async function moveMatrixItem( if (validateFreshState) validateFreshState(note, noteTags); const target = TARGETS[destination]; let changed = false; + if (config.mode === "skeleton") { + const targetTag: Record = { + topLeft: config.urgentTag, + topRight: config.inProgressTag, + bottomLeft: config.onDeckTag, + bottomRight: null, + }; + for (const tag of [config.urgentTag, config.inProgressTag, config.onDeckTag]) { + changed = tag === targetTag[destination] + ? (await ensureNoteTag(adapter, noteId, tag, noteTags)) || changed + : (await removeNoteTag(adapter, noteId, tag, noteTags)) || changed; + } + return { note, changed }; + } changed = target.urgent ? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed diff --git a/src/Gtd/parseMatrixConfig.ts b/src/Gtd/parseMatrixConfig.ts index 37489c5..fceb081 100644 --- a/src/Gtd/parseMatrixConfig.ts +++ b/src/Gtd/parseMatrixConfig.ts @@ -5,6 +5,7 @@ import { MatrixMode, URGENT_TAG, IMPORTANT_TAG, + ON_DECK_TAG, IN_PROGRESS_TAG, DONE_TAG, NoteInclusionMode, @@ -40,9 +41,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { "sort", "urgent-tag", "important-tag", + "on-deck-tag", "in-progress-tag", "done-tag", - "urgent-window", "card-detail", "page-size", "editable", @@ -163,13 +164,14 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { // axis tags (configurable; lowercased for tag comparison) const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG); const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG); + const onDeckTag = parseTag(input["on-deck-tag"], ON_DECK_TAG); if (urgentTag === importantTag) { warnings.push( `urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate` ); } - // skeleton mode: in-progress tag + urgent window + // skeleton workflow tags const inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG); const doneTag = parseTag(input["done-tag"], DONE_TAG); if (doneTag === inProgressTag) { @@ -178,16 +180,6 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { ); } - let urgentWindow = 3; - if (input["urgent-window"] !== undefined) { - const value = input["urgent-window"]; - if (Number.isInteger(Number(value)) && Number(value) >= 0) { - urgentWindow = Number(value); - } else { - warnings.push(`Invalid urgent-window "${value}" (using 3)`); - } - } - // card-detail let cardDetail: MatrixConfig["cardDetail"] = "hover"; if (input["card-detail"] !== undefined) { @@ -214,12 +206,11 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`); } } - if (editable && mode === "skeleton") { - warnings.push("editable: yes is unavailable for Skeleton matrices (editing disabled)"); - editable = false; - } - if (editable && urgentTag === importantTag) { - warnings.push("editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)"); + const editableTags = mode === "skeleton" + ? [urgentTag, inProgressTag, onDeckTag] + : [urgentTag, importantTag]; + if (editable && new Set(editableTags).size !== editableTags.length) { + warnings.push(`editable ${mode === "skeleton" ? "Skeleton workflow" : "Eisenhower axis"} tags must be distinct (editing disabled)`); editable = false; } @@ -237,9 +228,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig { sort, urgentTag, importantTag, + onDeckTag, inProgressTag, doneTag, - urgentWindow, cardDetail, pageSize, editable, diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 51d0d2a..58b014e 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -235,6 +235,7 @@ export type KanbanLayout = */ export const URGENT_TAG = "urgent"; export const IMPORTANT_TAG = "important"; +export const ON_DECK_TAG = "on-deck"; export type MatrixMode = "skeleton" | "eisenhower"; @@ -257,16 +258,16 @@ export interface MatrixConfig { sort: "asc" | "desc"; urgentTag: string; importantTag: string; + /** Skeleton mode: tag marking work queued to start next. */ + onDeckTag: string; /** Skeleton mode: tag marking active work (shared with the kanban). */ inProgressTag: string; /** Tag that marks an opted-in ordinary note complete. */ doneTag: string; - /** Skeleton mode: days ahead within which a due date counts as "due soon". */ - urgentWindow: number; cardDetail: "hover" | "always" | "none"; /** Cards initially shown, and revealed per "List more" action. */ pageSize: number; - /** Effective editable state; only valid for distinct-axis Eisenhower mode. */ + /** Effective editable state; requires distinct workflow/axis tags. */ editable: boolean; warnings: string[]; } @@ -292,8 +293,8 @@ export interface MoveMatrixCardIntent { * topLeft topRight * bottomLeft bottomRight * - * Both modes display Do Next / Scheduled / On Deck / Backlog; their axes and - * bucketing semantics remain mode-specific. + * The modes share positional destinations while their labels and tag semantics + * remain mode-specific. */ export interface MatrixBoard { topLeft: KanbanCard[]; diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 6291ad3..c812f58 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -1340,11 +1340,11 @@ board: payload.board || emptyBoard, }; const labels = payload.labels || { - columns: ["Urgent", "Not urgent"], - rows: ["Important", "Not important"], + columns: ["Priority", "Current / backlog"], + rows: ["Now", "Next / later"], quadrants: { - topLeft: "Do Next", - topRight: "Scheduled", + topLeft: "Urgent", + topRight: "In Progress", bottomLeft: "On Deck", bottomRight: "Backlog", }, diff --git a/src/manifest.json b/src/manifest.json index 5b0f1a9..f0c79b9 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.victorwiebe.joplin.plugin.gtd-calendar", "app_min_version": "2.7", - "version": "2.1.0", + "version": "2.2.0", "name": "GTD Calendar", "description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.", "author": "Victor Wiebe", diff --git a/src/tests/Gtd/matrix.test.ts b/src/tests/Gtd/matrix.test.ts index 8ffe498..1a18b31 100644 --- a/src/tests/Gtd/matrix.test.ts +++ b/src/tests/Gtd/matrix.test.ts @@ -20,11 +20,11 @@ describe("parseMatrixConfig", () => { }, }); expect(matrixLabels("skeleton")).toEqual({ - columns: ["Due soon", "Not due soon"], - rows: ["Active", "Not active"], + columns: ["Priority", "Current / backlog"], + rows: ["Now", "Next / later"], quadrants: { - topLeft: "Do Next", - topRight: "Scheduled", + topLeft: "Urgent", + topRight: "In Progress", bottomLeft: "On Deck", bottomRight: "Backlog", }, @@ -42,9 +42,9 @@ describe("parseMatrixConfig", () => { sort: "asc", urgentTag: "urgent", importantTag: "important", + onDeckTag: "on-deck", inProgressTag: "in-progress", doneTag: "done", - urgentWindow: 3, cardDetail: "hover", pageSize: 10, editable: false, @@ -52,7 +52,7 @@ describe("parseMatrixConfig", () => { expect(c.warnings).toHaveLength(0); }); - test("editable is strict, Eisenhower-only, and requires distinct axes", () => { + test("editable is strict and requires distinct destination tags", () => { expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true); expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false); @@ -62,11 +62,7 @@ describe("parseMatrixConfig", () => { expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`); } - const skeleton = parseMatrixConfig({ editable: "yes" }); - expect(skeleton.editable).toBe(false); - expect(skeleton.warnings).toContain( - "editable: yes is unavailable for Skeleton matrices (editing disabled)" - ); + expect(parseMatrixConfig({ editable: "yes" }).editable).toBe(true); const equalAxes = parseMatrixConfig({ mode: "eisenhower", @@ -76,8 +72,10 @@ describe("parseMatrixConfig", () => { }); expect(equalAxes.editable).toBe(false); expect(equalAxes.warnings).toContain( - "editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)" + "editable Eisenhower axis tags must be distinct (editing disabled)" ); + const equalWorkflow = parseMatrixConfig({ editable: "yes", "on-deck-tag": "URGENT" }); + expect(equalWorkflow.editable).toBe(false); }); test("parses notes independently and rejects invalid or singular forms", () => { @@ -566,7 +564,7 @@ describe("collectMatrix — Skeleton mode", () => { return new Date(y, m - 1, d, 12).getTime(); } - test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => { + test("the four tag destinations: Urgent / In Progress / On Deck / Backlog", async () => { const notes = [ // active + due within window -> Do Next makeNote({ @@ -591,8 +589,9 @@ describe("collectMatrix — Skeleton mode", () => { ]; const result = await collectMatrix( makeAdapter(folders, notes, { - donext: ["in-progress"], + donext: ["urgent"], sched: ["in-progress"], + ondeck: ["on-deck"], }), "matrix-note", "board", @@ -605,10 +604,10 @@ describe("collectMatrix — Skeleton mode", () => { expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]); }); - test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => { + test("urgent takes precedence over overlapping workflow tags", async () => { const notes = [makeNote({ id: "hot", is_todo: 0 })]; const result = await collectMatrix( - makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }), + makeAdapter(folders, notes, { hot: ["on-deck", "in-progress", "urgent"] }), "matrix-note", "board", parseMatrixConfig({ todos: "all" }), @@ -617,7 +616,7 @@ describe("collectMatrix — Skeleton mode", () => { expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]); }); - test("urgent tag alone (no date, not active) -> On Deck", async () => { + test("urgent tag alone routes to Urgent", async () => { const notes = [makeNote({ id: "flag", is_todo: 0 })]; const result = await collectMatrix( makeAdapter(folders, notes, { flag: ["urgent"] }), @@ -626,10 +625,10 @@ describe("collectMatrix — Skeleton mode", () => { parseMatrixConfig({ todos: "all" }), NOW ); - expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["flag"]); + expect(result.board.topLeft.map((c) => c.id)).toEqual(["flag"]); }); - test("overdue counts as due soon", async () => { + test("native due dates do not affect Skeleton routing", async () => { const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })]; const result = await collectMatrix( makeAdapter(folders, notes), @@ -638,10 +637,10 @@ describe("collectMatrix — Skeleton mode", () => { parseMatrixConfig({ todos: "all" }), NOW ); - expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["late"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["late"]); }); - test("dateless in-progress work -> Scheduled (active, no clock)", async () => { + test("in-progress work routes to In Progress", async () => { const notes = [makeNote({ id: "wip" })]; const result = await collectMatrix( makeAdapter(folders, notes, { wip: ["in-progress"] }), @@ -653,7 +652,7 @@ describe("collectMatrix — Skeleton mode", () => { 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 () => { + test("future dates do not affect Skeleton routing", 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 @@ -662,14 +661,13 @@ describe("collectMatrix — Skeleton mode", () => { makeAdapter(folders, notes), "matrix-note", "board", - parseMatrixConfig({ todos: "all", "urgent-window": 3 }), + parseMatrixConfig({ todos: "all" }), NOW ); - expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]); - expect(result.board.bottomRight.map((c) => c.id)).toEqual(["past-edge"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["edge", "past-edge"]); }); - test("gtd date override affects due-soon just like a due date", async () => { + test("gtd date override does not affect Skeleton routing", async () => { const body = "```gtd\ndate: 2026-06-16\n```"; const notes = [makeNote({ id: "g", body: body })]; const result = await collectMatrix( @@ -679,7 +677,7 @@ describe("collectMatrix — Skeleton mode", () => { parseMatrixConfig({}), NOW ); - expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]); + expect(result.board.bottomRight.map((c) => c.id)).toEqual(["g"]); }); }); @@ -823,11 +821,12 @@ describe("collectMatrix — notebook groups", () => { makeNote({ id: "bl", parent_id: "child", is_todo: 0 }), makeNote({ id: "br", parent_id: "child", is_todo: 0 }), ]; - const tags = { - "root-left": ["urgent"], - tl: ["urgent", "important", "in-progress"], - tr: ["important", "in-progress"], - bl: ["urgent"], + const tags = mode === "skeleton" ? { + "root-left": ["on-deck"], tl: ["urgent"], + tr: ["in-progress"], bl: ["on-deck"], + } : { + "root-left": ["urgent"], tl: ["urgent", "important"], + tr: ["important"], bl: ["urgent"], }; const result = await collectMatrix( makeAdapter(tree, notes, tags), diff --git a/src/tests/Gtd/matrixMoveHandler.test.ts b/src/tests/Gtd/matrixMoveHandler.test.ts index b7a066b..7598825 100644 --- a/src/tests/Gtd/matrixMoveHandler.test.ts +++ b/src/tests/Gtd/matrixMoveHandler.test.ts @@ -70,7 +70,7 @@ describe("validateAndMoveMatrixCard", () => { test.each([ ["", "error"], ["mode: eisenhower\neditable: no", "error"], - ["mode: skeleton\neditable: yes", "error"], + ["mode: skeleton\neditable: no", "error"], ["mode: eisenhower\neditable: yes\nurgent-tag: same\nimportant-tag: SAME", "error"], ["editable: [yes", "stale"], ] as const)("rejects invalid or non-editable config", async (rawConfig, status) => { @@ -79,6 +79,16 @@ describe("validateAndMoveMatrixCard", () => { expect(writes).toEqual([]); }); + test("accepts an editable Skeleton destination", async () => { + const { read, mutation, writes } = setup(); + const moveResult = await validateAndMoveMatrixCard(intent({ + rawConfig: "mode: skeleton\neditable: yes\nnotes: all\ntodos: all", + destination: "bottomLeft", + }), host, read, mutation); + expect(moveResult.status).toBe("success"); + expect(writes).toEqual(["attach:on-deck"]); + }); + test("requires the selected host identity", async () => { for (const selected of [null, { id: "other", parentId: "board" }]) { const { read, mutation, writes } = setup(); diff --git a/src/tests/Gtd/matrixMutation.test.ts b/src/tests/Gtd/matrixMutation.test.ts index 8e2b495..4521bf4 100644 --- a/src/tests/Gtd/matrixMutation.test.ts +++ b/src/tests/Gtd/matrixMutation.test.ts @@ -35,7 +35,11 @@ const states: Array<{ { destination: "bottomRight", urgent: false, important: false }, ]; -const config = { urgentTag: "urgent", importantTag: "important" }; +const config = { + mode: "eisenhower" as const, + urgentTag: "urgent", importantTag: "important", + inProgressTag: "in-progress", onDeckTag: "on-deck", +}; describe("moveMatrixItem", () => { for (const isTodo of [false, true]) { @@ -66,7 +70,7 @@ describe("moveMatrixItem", () => { { id: "keep", title: "keep-me" }, ], [{ id: "m", title: "MATTERS" }]); await moveMatrixItem(adapter, "card", "topRight", { - urgentTag: "fire", importantTag: "matters", + ...config, urgentTag: "fire", importantTag: "matters", }); expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]); }); @@ -77,6 +81,23 @@ describe("moveMatrixItem", () => { expect(calls).toEqual(["create:urgent", "attach:urgent"]); }); + test.each([ + ["topLeft", "urgent"], ["topRight", "in-progress"], + ["bottomLeft", "on-deck"], ["bottomRight", null], + ] as Array<[MatrixDestination, string | null]>) + ("Skeleton %s normalizes the three workflow tags", async (destination, expected) => { + const { adapter, calls } = fakeAdapter(note(false), [ + { id: "u", title: "urgent" }, { id: "i", title: "in-progress" }, + { id: "o", title: "on-deck" }, + ], [ + { id: "u", title: "urgent" }, { id: "i", title: "in-progress" }, + { id: "o", title: "on-deck" }, + ]); + await moveMatrixItem(adapter, "card", destination, { ...config, mode: "skeleton" }); + const keptId = expected === "urgent" ? "u" : expected === "in-progress" ? "i" : expected === "on-deck" ? "o" : null; + expect(calls).toEqual(["u", "i", "o"].filter((id) => id !== keptId).map((id) => `detach:${id}`)); + }); + test("fresh validation can reject completed or newly filtered cards before writes", async () => { for (const current of [note(true), note(false)]) { current.todo_completed = current.is_todo ? 100 : 0;