Make Skeleton matrix tag-driven and editable for v2.2.0

This commit is contained in:
Victor Wiebe 2026-08-08 14:35:56 -04:00
parent 1f0679568b
commit 2b8b17f4fe
15 changed files with 152 additions and 112 deletions

View File

@ -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. 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 ## 2.1.0 — Unreleased
### Notebook-tree exclusions ### Notebook-tree exclusions

View File

@ -282,14 +282,14 @@ Two modes:
### `mode: skeleton` (default) — the Skeleton Matrix ### `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 | | **Now** | Urgent (`urgent`) | In Progress (`in-progress`) |
| **Not active** | On Deck | Backlog | | **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 ### `mode: eisenhower` — urgent/important axes
@ -302,7 +302,7 @@ On Deck (urgent), and Backlog (neither).
| Option | Values | Default | Description | | Option | Values | Default | Description |
|---|---|---|---| |---|---|---|---|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. | | `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. | | `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). | | `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. | | `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. | | `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-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
| `sort` | `asc`, `desc` | `asc` | Sort direction. | | `sort` | `asc`, `desc` | `asc` | Sort direction. |
| `urgent-tag` | any tag name | `urgent` | 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). | | `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. | | `in-progress-tag` | any tag name | `in-progress` | Skeleton's In Progress destination; shared with kanban by default. |
| `urgent-window` | integer (days) | `3` | Skeleton mode: how close a due date must be to count as "due soon". | | `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. | | `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. | | `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. 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 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 both axis tags; Scheduled keeps only important; On Deck keeps only urgent; and
Backlog removes both. Moves never change completion, dates, content, notebook, Backlog removes both. Moves never change completion, dates, content, notebook,
or unrelated tags, and sorting plus pagination remain authoritative after the or unrelated tags, and sorting plus pagination remain authoritative after the
matrix refreshes. Skeleton matrices are intentionally read-only; `editable: yes` matrix refreshes. In Skeleton mode, destinations keep exactly one of the three
with Skeleton warns and exposes no inert controls. workflow tags (or none for Backlog); due dates are never changed.
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄 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 when no icon is set, and never show completion or recurrence styling. Empty and

4
package-lock.json generated
View File

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

View File

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

View File

@ -27,11 +27,9 @@ export interface MatrixResult {
* - notes and todos filters apply independently. * - notes and todos filters apply independently.
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a * - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
* prioritisation view; completions live in the kanban's Done column. * prioritisation view; completions live in the kanban's Done column.
* - Quadrants from the two axis tags: * - Eisenhower uses its two axis tags.
* urgent + important -> Do Next * - Skeleton uses exclusive workflow destinations with urgent > in-progress
* important only -> Scheduled * > on-deck precedence and Backlog as the untagged default.
* urgent only -> On Deck
* neither -> Backlog (default bucket for untagged)
* *
* The matrix note itself is always excluded. * The matrix note itself is always excluded.
*/ */
@ -40,7 +38,7 @@ export default async function collectMatrix(
matrixNoteId: string, matrixNoteId: string,
matrixFolderId: string, matrixFolderId: string,
config: MatrixConfig, config: MatrixConfig,
now: Date = new Date() _now: Date = new Date()
): Promise<MatrixResult> { ): Promise<MatrixResult> {
const warnings: string[] = []; 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. // Bodies preserve explicit note opt-in and optional to-do card overrides.
// When both types are disabled, metadata alone is sufficient for scan totals. // When both types are disabled, metadata alone is sufficient for scan totals.
const includeBody = config.notes !== "none" || config.todos !== "none"; const includeBody = config.notes !== "none" || config.todos !== "none";
@ -123,13 +118,17 @@ export default async function collectMatrix(
topRow = tags.includes(config.importantTag); topRow = tags.includes(config.importantTag);
leftColumn = tags.includes(config.urgentTag); leftColumn = tags.includes(config.urgentTag);
} else { } else {
// Skeleton. Rows: active (in-progress) / not. // Skeleton is a four-state tag workflow. Precedence makes
// Columns: due soon (urgent tag overrides; else date within // overlapping legacy tags deterministic until the next move.
// the window, overdue included) / not. if (tags.includes(config.urgentTag)) {
topRow = tags.includes(config.inProgressTag); topRow = true; leftColumn = true;
leftColumn = } else if (tags.includes(config.inProgressTag)) {
tags.includes(config.urgentTag) || topRow = true; leftColumn = false;
(card.date !== null && card.date <= soonThreshold); } else if (tags.includes(config.onDeckTag)) {
topRow = false; leftColumn = true;
} else {
topRow = false; leftColumn = false;
}
} }
if (topRow && leftColumn) targetBoard.topLeft.push(card); 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 { function sortColumn(cards: KanbanCard[], config: MatrixConfig): void {
const direction = config.sort === "desc" ? -1 : 1; const direction = config.sort === "desc" ? -1 : 1;

View File

@ -16,11 +16,11 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels {
} }
// skeleton // skeleton
return { return {
columns: ["Due soon", "Not due soon"], columns: ["Priority", "Current / backlog"],
rows: ["Active", "Not active"], rows: ["Now", "Next / later"],
quadrants: { quadrants: {
topLeft: "Do Next", topLeft: "Urgent",
topRight: "Scheduled", topRight: "In Progress",
bottomLeft: "On Deck", bottomLeft: "On Deck",
bottomRight: "Backlog", bottomRight: "Backlog",
}, },

View File

@ -99,7 +99,7 @@ export async function validateAndMoveMatrixCard(
return result("stale", viewInstanceId, "The matrix configuration is no longer valid."); return result("stale", viewInstanceId, "The matrix configuration is no longer valid.");
} }
const config = parseMatrixConfig(parsed); const config = parseMatrixConfig(parsed);
if (!config.editable || config.mode !== "eisenhower") { if (!config.editable) {
return result("error", viewInstanceId, "This matrix is read-only."); return result("error", viewInstanceId, "This matrix is read-only.");
} }

View File

@ -1,13 +1,17 @@
import { ensureNoteTag, removeNoteTag } from "./kanbanMutation"; import { ensureNoteTag, removeNoteTag } from "./kanbanMutation";
import { import {
MatrixDestination, MatrixDestination,
MatrixMode,
MutationAdapter, MutationAdapter,
RawNote, RawNote,
} from "./types"; } from "./types";
export interface MatrixMutationConfig { export interface MatrixMutationConfig {
mode: MatrixMode;
urgentTag: string; urgentTag: string;
importantTag: string; importantTag: string;
inProgressTag: string;
onDeckTag: string;
} }
export interface MatrixMutationOutcome { export interface MatrixMutationOutcome {
@ -36,6 +40,20 @@ export async function moveMatrixItem(
if (validateFreshState) validateFreshState(note, noteTags); if (validateFreshState) validateFreshState(note, noteTags);
const target = TARGETS[destination]; const target = TARGETS[destination];
let changed = false; let changed = false;
if (config.mode === "skeleton") {
const targetTag: Record<MatrixDestination, string | null> = {
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 changed = target.urgent
? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed ? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed

View File

@ -5,6 +5,7 @@ import {
MatrixMode, MatrixMode,
URGENT_TAG, URGENT_TAG,
IMPORTANT_TAG, IMPORTANT_TAG,
ON_DECK_TAG,
IN_PROGRESS_TAG, IN_PROGRESS_TAG,
DONE_TAG, DONE_TAG,
NoteInclusionMode, NoteInclusionMode,
@ -40,9 +41,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
"sort", "sort",
"urgent-tag", "urgent-tag",
"important-tag", "important-tag",
"on-deck-tag",
"in-progress-tag", "in-progress-tag",
"done-tag", "done-tag",
"urgent-window",
"card-detail", "card-detail",
"page-size", "page-size",
"editable", "editable",
@ -163,13 +164,14 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
// axis tags (configurable; lowercased for tag comparison) // axis tags (configurable; lowercased for tag comparison)
const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG); const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG);
const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG); const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG);
const onDeckTag = parseTag(input["on-deck-tag"], ON_DECK_TAG);
if (urgentTag === importantTag) { if (urgentTag === importantTag) {
warnings.push( warnings.push(
`urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate` `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 inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG);
const doneTag = parseTag(input["done-tag"], DONE_TAG); const doneTag = parseTag(input["done-tag"], DONE_TAG);
if (doneTag === inProgressTag) { 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 // card-detail
let cardDetail: MatrixConfig["cardDetail"] = "hover"; let cardDetail: MatrixConfig["cardDetail"] = "hover";
if (input["card-detail"] !== undefined) { 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")`); warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`);
} }
} }
if (editable && mode === "skeleton") { const editableTags = mode === "skeleton"
warnings.push("editable: yes is unavailable for Skeleton matrices (editing disabled)"); ? [urgentTag, inProgressTag, onDeckTag]
editable = false; : [urgentTag, importantTag];
} if (editable && new Set(editableTags).size !== editableTags.length) {
if (editable && urgentTag === importantTag) { warnings.push(`editable ${mode === "skeleton" ? "Skeleton workflow" : "Eisenhower axis"} tags must be distinct (editing disabled)`);
warnings.push("editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)");
editable = false; editable = false;
} }
@ -237,9 +228,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
sort, sort,
urgentTag, urgentTag,
importantTag, importantTag,
onDeckTag,
inProgressTag, inProgressTag,
doneTag, doneTag,
urgentWindow,
cardDetail, cardDetail,
pageSize, pageSize,
editable, editable,

View File

@ -235,6 +235,7 @@ export type KanbanLayout =
*/ */
export const URGENT_TAG = "urgent"; export const URGENT_TAG = "urgent";
export const IMPORTANT_TAG = "important"; export const IMPORTANT_TAG = "important";
export const ON_DECK_TAG = "on-deck";
export type MatrixMode = "skeleton" | "eisenhower"; export type MatrixMode = "skeleton" | "eisenhower";
@ -257,16 +258,16 @@ export interface MatrixConfig {
sort: "asc" | "desc"; sort: "asc" | "desc";
urgentTag: string; urgentTag: string;
importantTag: string; importantTag: string;
/** Skeleton mode: tag marking work queued to start next. */
onDeckTag: string;
/** Skeleton mode: tag marking active work (shared with the kanban). */ /** Skeleton mode: tag marking active work (shared with the kanban). */
inProgressTag: string; inProgressTag: string;
/** Tag that marks an opted-in ordinary note complete. */ /** Tag that marks an opted-in ordinary note complete. */
doneTag: string; doneTag: string;
/** Skeleton mode: days ahead within which a due date counts as "due soon". */
urgentWindow: number;
cardDetail: "hover" | "always" | "none"; cardDetail: "hover" | "always" | "none";
/** Cards initially shown, and revealed per "List more" action. */ /** Cards initially shown, and revealed per "List more" action. */
pageSize: number; pageSize: number;
/** Effective editable state; only valid for distinct-axis Eisenhower mode. */ /** Effective editable state; requires distinct workflow/axis tags. */
editable: boolean; editable: boolean;
warnings: string[]; warnings: string[];
} }
@ -292,8 +293,8 @@ export interface MoveMatrixCardIntent {
* topLeft topRight * topLeft topRight
* bottomLeft bottomRight * bottomLeft bottomRight
* *
* Both modes display Do Next / Scheduled / On Deck / Backlog; their axes and * The modes share positional destinations while their labels and tag semantics
* bucketing semantics remain mode-specific. * remain mode-specific.
*/ */
export interface MatrixBoard { export interface MatrixBoard {
topLeft: KanbanCard[]; topLeft: KanbanCard[];

View File

@ -1340,11 +1340,11 @@
board: payload.board || emptyBoard, board: payload.board || emptyBoard,
}; };
const labels = payload.labels || { const labels = payload.labels || {
columns: ["Urgent", "Not urgent"], columns: ["Priority", "Current / backlog"],
rows: ["Important", "Not important"], rows: ["Now", "Next / later"],
quadrants: { quadrants: {
topLeft: "Do Next", topLeft: "Urgent",
topRight: "Scheduled", topRight: "In Progress",
bottomLeft: "On Deck", bottomLeft: "On Deck",
bottomRight: "Backlog", bottomRight: "Backlog",
}, },

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": "2.1.0", "version": "2.2.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

@ -20,11 +20,11 @@ describe("parseMatrixConfig", () => {
}, },
}); });
expect(matrixLabels("skeleton")).toEqual({ expect(matrixLabels("skeleton")).toEqual({
columns: ["Due soon", "Not due soon"], columns: ["Priority", "Current / backlog"],
rows: ["Active", "Not active"], rows: ["Now", "Next / later"],
quadrants: { quadrants: {
topLeft: "Do Next", topLeft: "Urgent",
topRight: "Scheduled", topRight: "In Progress",
bottomLeft: "On Deck", bottomLeft: "On Deck",
bottomRight: "Backlog", bottomRight: "Backlog",
}, },
@ -42,9 +42,9 @@ describe("parseMatrixConfig", () => {
sort: "asc", sort: "asc",
urgentTag: "urgent", urgentTag: "urgent",
importantTag: "important", importantTag: "important",
onDeckTag: "on-deck",
inProgressTag: "in-progress", inProgressTag: "in-progress",
doneTag: "done", doneTag: "done",
urgentWindow: 3,
cardDetail: "hover", cardDetail: "hover",
pageSize: 10, pageSize: 10,
editable: false, editable: false,
@ -52,7 +52,7 @@ describe("parseMatrixConfig", () => {
expect(c.warnings).toHaveLength(0); 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: " YES " }).editable).toBe(true);
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false); 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")`); expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`);
} }
const skeleton = parseMatrixConfig({ editable: "yes" }); expect(parseMatrixConfig({ editable: "yes" }).editable).toBe(true);
expect(skeleton.editable).toBe(false);
expect(skeleton.warnings).toContain(
"editable: yes is unavailable for Skeleton matrices (editing disabled)"
);
const equalAxes = parseMatrixConfig({ const equalAxes = parseMatrixConfig({
mode: "eisenhower", mode: "eisenhower",
@ -76,8 +72,10 @@ describe("parseMatrixConfig", () => {
}); });
expect(equalAxes.editable).toBe(false); expect(equalAxes.editable).toBe(false);
expect(equalAxes.warnings).toContain( 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", () => { 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(); 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 = [ const notes = [
// active + due within window -> Do Next // active + due within window -> Do Next
makeNote({ makeNote({
@ -591,8 +589,9 @@ describe("collectMatrix — Skeleton mode", () => {
]; ];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { makeAdapter(folders, notes, {
donext: ["in-progress"], donext: ["urgent"],
sched: ["in-progress"], sched: ["in-progress"],
ondeck: ["on-deck"],
}), }),
"matrix-note", "matrix-note",
"board", "board",
@ -605,10 +604,10 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]); 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 notes = [makeNote({ id: "hot", is_todo: 0 })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }), makeAdapter(folders, notes, { hot: ["on-deck", "in-progress", "urgent"] }),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({ todos: "all" }), parseMatrixConfig({ todos: "all" }),
@ -617,7 +616,7 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]); 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 notes = [makeNote({ id: "flag", is_todo: 0 })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { flag: ["urgent"] }), makeAdapter(folders, notes, { flag: ["urgent"] }),
@ -626,10 +625,10 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({ todos: "all" }), parseMatrixConfig({ todos: "all" }),
NOW 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 notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes), makeAdapter(folders, notes),
@ -638,10 +637,10 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({ todos: "all" }), parseMatrixConfig({ todos: "all" }),
NOW 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 notes = [makeNote({ id: "wip" })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { wip: ["in-progress"] }), makeAdapter(folders, notes, { wip: ["in-progress"] }),
@ -653,7 +652,7 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]); 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 = [ const notes = [
makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days
makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4 makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4
@ -662,14 +661,13 @@ describe("collectMatrix — Skeleton mode", () => {
makeAdapter(folders, notes), makeAdapter(folders, notes),
"matrix-note", "matrix-note",
"board", "board",
parseMatrixConfig({ todos: "all", "urgent-window": 3 }), parseMatrixConfig({ todos: "all" }),
NOW NOW
); );
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]); expect(result.board.bottomRight.map((c) => c.id)).toEqual(["edge", "past-edge"]);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["past-edge"]);
}); });
test("gtd date override affects due-soon just like a due date", async () => { test("gtd date override does not affect Skeleton routing", async () => {
const body = "```gtd\ndate: 2026-06-16\n```"; const body = "```gtd\ndate: 2026-06-16\n```";
const notes = [makeNote({ id: "g", body: body })]; const notes = [makeNote({ id: "g", body: body })];
const result = await collectMatrix( const result = await collectMatrix(
@ -679,7 +677,7 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({}), parseMatrixConfig({}),
NOW 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: "bl", parent_id: "child", is_todo: 0 }),
makeNote({ id: "br", parent_id: "child", is_todo: 0 }), makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
]; ];
const tags = { const tags = mode === "skeleton" ? {
"root-left": ["urgent"], "root-left": ["on-deck"], tl: ["urgent"],
tl: ["urgent", "important", "in-progress"], tr: ["in-progress"], bl: ["on-deck"],
tr: ["important", "in-progress"], } : {
bl: ["urgent"], "root-left": ["urgent"], tl: ["urgent", "important"],
tr: ["important"], bl: ["urgent"],
}; };
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(tree, notes, tags), makeAdapter(tree, notes, tags),

View File

@ -70,7 +70,7 @@ describe("validateAndMoveMatrixCard", () => {
test.each([ test.each([
["", "error"], ["", "error"],
["mode: eisenhower\neditable: no", "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"], ["mode: eisenhower\neditable: yes\nurgent-tag: same\nimportant-tag: SAME", "error"],
["editable: [yes", "stale"], ["editable: [yes", "stale"],
] as const)("rejects invalid or non-editable config", async (rawConfig, status) => { ] as const)("rejects invalid or non-editable config", async (rawConfig, status) => {
@ -79,6 +79,16 @@ describe("validateAndMoveMatrixCard", () => {
expect(writes).toEqual([]); 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 () => { test("requires the selected host identity", async () => {
for (const selected of [null, { id: "other", parentId: "board" }]) { for (const selected of [null, { id: "other", parentId: "board" }]) {
const { read, mutation, writes } = setup(); const { read, mutation, writes } = setup();

View File

@ -35,7 +35,11 @@ const states: Array<{
{ destination: "bottomRight", urgent: false, important: false }, { 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", () => { describe("moveMatrixItem", () => {
for (const isTodo of [false, true]) { for (const isTodo of [false, true]) {
@ -66,7 +70,7 @@ describe("moveMatrixItem", () => {
{ id: "keep", title: "keep-me" }, { id: "keep", title: "keep-me" },
], [{ id: "m", title: "MATTERS" }]); ], [{ id: "m", title: "MATTERS" }]);
await moveMatrixItem(adapter, "card", "topRight", { await moveMatrixItem(adapter, "card", "topRight", {
urgentTag: "fire", importantTag: "matters", ...config, urgentTag: "fire", importantTag: "matters",
}); });
expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]); expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]);
}); });
@ -77,6 +81,23 @@ describe("moveMatrixItem", () => {
expect(calls).toEqual(["create:urgent", "attach:urgent"]); 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 () => { test("fresh validation can reject completed or newly filtered cards before writes", async () => {
for (const current of [note(true), note(false)]) { for (const current of [note(true), note(false)]) {
current.todo_completed = current.is_todo ? 100 : 0; current.todo_completed = current.is_todo ? 100 : 0;