Release v2.0.0 with editable Eisenhower matrices

This commit is contained in:
Victor Wiebe 2026-07-31 14:48:55 -04:00
parent f4971e1f7b
commit ca0518fbff
24 changed files with 1176 additions and 115 deletions

View File

@ -28,6 +28,28 @@ All notable changes to GTD Calendar are documented here. Versions follow the plu
- Automated validation: 295 tests across 19 suites, TypeScript, webview syntax,
whitespace, prohibited-reference checks, and production JPL inspection passed.
### Eisenhower label migration
- Eisenhower matrices retain urgent/important axes but now use Do Next,
Scheduled, On Deck, and Backlog consistently with Skeleton mode. No saved data
migration is required because quadrant labels are display-only.
### Opt-in editable Eisenhower matrices
- Added `editable: yes | no` to `gtd-matrix`, defaulting to read-only and
applying only to Eisenhower mode with distinct urgent/important tag names.
- Editable matrices reuse the compact pointer handle and disclosure-controlled
keyboard menu for Do Next, Scheduled, On Deck, and Backlog. Skeleton remains
supported and read-only; requesting editing warns without adding inert UI.
- Moves derive minimal urgent/important tag changes from fixed semantic
destinations and fresh host, scope/group, eligibility, completion, mode, and
configuration state. Completion, dates, content, note type, notebook, and
unrelated tags remain unchanged.
- Added same-board target scoping, cancellation, pending/error feedback, live
announcements, focus recovery, canonical refresh, and stale-response guards.
- Automated validation now covers 374 tests across 22 suites. Production build
inspection and manual Joplin acceptance remain pending.
## 1.0.0 — 2026-07-28
### Card underline cleanup

View File

@ -245,7 +245,8 @@ A third view: a 2×2 prioritisation matrix for incomplete to-dos and ordinary no
```gtd-matrix
title: Priorities
todos: all
mode: skeleton
mode: eisenhower
editable: yes
page-size: 8
done-tag: done
```
@ -274,15 +275,18 @@ Built to complement the kanban and calendar, using the tags and dates you alread
**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.
### `mode: eisenhower`the classic
### `mode: eisenhower`urgent/important axes
Quadrants from the urgent/important tags: Do First (both), Schedule (important), Delegate (urgent), Eliminate (neither).
Eisenhower retains urgent/important tag semantics while using the same
constructive quadrant names as Skeleton: Do Next (both), Scheduled (important),
On Deck (urgent), and Backlog (neither).
### Options
| 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. |
| `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. |
@ -300,7 +304,14 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
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.
Matrix cards are compact, clickable, and read-only in this release scope.
On an editable Eisenhower 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.
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
malformed blocks follow the same opt-in and warning behavior as on kanban.
@ -384,7 +395,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop
```bash
npm install
npm test # 298 unit tests
npm test # 374 unit tests
npm run dist # builds publish/*.jpl
```

View File

@ -9,9 +9,9 @@
## Status
**PLANNED; BLOCKED ON SLICE 14.** Begin only after the shared mutation service,
interaction model, automated validation, and manual kanban acceptance in
SLICE 14 are complete.
**COMPLETE.** Phases 17 are complete. Focused and full automation passed, the
production JPL was built and inspected, and the user confirmed the manual Joplin
acceptance tests passed on 2026-07-31.
## Goal
@ -149,89 +149,200 @@ drag, make no write, report stale state, and refresh the matrix.
### Phase 1 — Label change and compatibility tests
- [ ] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
- [ ] Update comments and remove/update fallback literals that contain Do First,
- [x] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
- [x] Update comments and remove/update fallback literals that contain Do First,
Schedule, Delegate, or Eliminate.
- [ ] Update unit and rendering tests for single and grouped layouts.
- [ ] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
- [ ] Confirm the label-only change applies when `editable` is omitted.
- [x] Update unit and rendering tests for single and grouped layouts.
- [x] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
- [x] Confirm the label-only change applies when `editable` is omitted.
#### Phase 1 completion record
- Canonical Eisenhower labels and the defensive legacy-payload webview fallback
now use Do Next, Scheduled, On Deck, and Backlog. Labels remain display-only;
no card data or persisted state changed.
- Updated matrix source comments, current README/SPEC contracts, and the
unreleased CHANGELOG entry. Historical version notes remain historical.
- Added exact label/axis tests for both modes. Eisenhower still uses
Important/Not important rows and Urgent/Not urgent columns; Skeleton still
uses Active/Not active and Due soon/Not due soon.
- Focused validation passed: 3 suites, 78 tests. Full regression validation
passed: 20 suites, 299 tests. TypeScript with `--skipLibCheck`, webview syntax,
and `git diff --check` passed. No JPL or manual test was performed in Phase 1.
### Phase 2 — Matrix configuration and protocol
- [ ] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
- [ ] Parse `editable: yes | no` using the SLICE 14 validation contract.
- [ ] Warn and disable editing for `mode: skeleton`.
- [ ] Warn and disable editable Eisenhower when normalized axis tags are equal.
- [ ] Define constrained matrix move intents and fixed destinations using the
- [x] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
- [x] Parse `editable: yes | no` using the SLICE 14 validation contract.
- [x] Warn and disable editing for `mode: skeleton`.
- [x] Warn and disable editable Eisenhower when normalized axis tags are equal.
- [x] Define constrained matrix move intents and fixed destinations using the
shared host/view identity and typed result model.
- [ ] Add parser/protocol tests for defaults, invalid values, mode interaction,
- [x] Add parser/protocol tests for defaults, invalid values, mode interaction,
equal-axis tags, and backward compatibility.
#### Phase 2 completion record
- Added strict scalar `editable: yes | no` parsing to matrix configuration,
defaulting to read-only. Invalid/non-scalar values warn and use `no`.
- `editable: yes` is effective only for Eisenhower mode with distinct normalized
urgent/important tags. Skeleton and equal-axis configurations warn clearly and
return `editable: false`, preventing inert mutation UI.
- Added fixed positional matrix destinations, a constrained matrix intent, and a
runtime guard. Matrix payloads now echo the per-render `viewInstanceId`, the
canonical host note ID, and effective editable state.
- Focused validation passed: 3 suites, 76 tests. Full regression validation
passed: 20 suites, 310 tests. TypeScript with `--skipLibCheck` and
`git diff --check` passed. No JPL or manual testing occurred in Phase 2.
### Phase 3 — Eisenhower mutation planning
- [ ] Implement the four urgent/important target states through shared tag
- [x] Implement the four urgent/important target states through shared tag
primitives from SLICE 14.
- [ ] Calculate minimal, idempotent tag membership changes from fresh data.
- [ ] Preserve completion, progress, dates, body, note type, notebook, and
- [x] Calculate minimal, idempotent tag membership changes from fresh data.
- [x] Preserve completion, progress, dates, body, note type, notebook, and
unrelated tags.
- [ ] Cover notes and to-dos, custom tag names, casing, duplicate tags, all 12
- [x] Cover notes and to-dos, custom tag names, casing, duplicate tags, all 12
cross-quadrant moves, same-quadrant no-ops, and partial failures.
- [ ] Prove a completed or newly filtered item cannot be mutated.
- [x] Prove a completed or newly filtered item cannot be mutated.
#### Phase 3 completion record
- Exported and reused Slice 14's deterministic case-insensitive find/create,
attach, and detach primitives. Matrix mutation maps each fixed destination to
the exact urgent/important target state and performs only missing changes.
- Covered every source/destination combination for ordinary notes and native
to-dos: 24 cross-state cases and eight same-state no-ops across both item
types. No completion or note-field adapter operation is available to the
matrix service.
- Tests cover custom names, existing case variants, duplicate attached tags,
missing-tag creation, unrelated-tag preservation, fresh-state rejection,
disappeared cards, and partial failures without stale compensation.
- Focused validation passed: 2 suites, 50 tests. Full regression validation
passed: 21 suites, 347 tests. TypeScript with `--skipLibCheck` and
`git diff --check` passed. Packaging and manual acceptance remain pending.
### Phase 4 — Message validation and refresh
- [ ] Add the matrix move message to the main-process handler.
- [ ] Reparse and validate editable Eisenhower configuration and fresh eligibility.
- [ ] Reject arbitrary destinations, tag operations, fields, modes, host notes,
- [x] Add the matrix move message to the main-process handler.
- [x] Reparse and validate editable Eisenhower configuration and fresh eligibility.
- [x] Reject arbitrary destinations, tag operations, fields, modes, host notes,
out-of-scope items, cross-group moves, and stale completions.
- [ ] Return typed results and refresh only the initiating matrix instance.
- [ ] Guard against older responses overwriting newer renders.
- [x] Return typed results and refresh only the initiating matrix instance.
- [x] Guard against older responses overwriting newer renders.
#### Phase 4 completion record
- Registered `moveMatrixCard` and added a testable validation service between
untrusted webview intent and the Phase 3 mutation service.
- Exact message keys, fixed positional destinations, matching selected host,
reparsed effective editable Eisenhower mode, fresh notebook/scope ownership,
item filters, `gtd` opt-in, native completion, and note `done-tag` state are
validated before writes. Skeleton, equal-axis, completed, filtered, host,
out-of-scope, malformed, and expanded requests cannot write.
- User-safe success/stale/error results reuse the Slice 14 result model;
unexpected API details remain in plugin-process logs. The Phase 5 webview will
use echoed `viewInstanceId` and request sequencing for initiating-only refresh.
- Focused validation passed: 3 suites, 79 tests. Full regression validation
passed: 22 suites, 370 tests. TypeScript with `--skipLibCheck` and
`git diff --check` passed. Packaging and manual acceptance remain pending.
### Phase 5 — Pointer and keyboard interaction
- [ ] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
- [ ] Constrain active targets to the same matrix instance and notebook group.
- [ ] Add new-label drag feedback, pending/error states, and cancellation.
- [ ] Add keyboard **Move to…** options for the other three quadrants.
- [ ] Preserve click-to-open, hover details, pagination, scrolling, focus, and
- [x] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
- [x] Constrain active targets to the same matrix instance and notebook group.
- [x] Add new-label drag feedback, pending/error states, and cancellation.
- [x] Add keyboard **Move to…** options for the other three quadrants.
- [x] Preserve click-to-open, hover details, pagination, scrolling, focus, and
screen-reader announcements.
- [ ] Verify read-only Eisenhower and all Skeleton matrices expose no mutation UI.
- [x] Verify read-only Eisenhower and all Skeleton matrices expose no mutation UI.
#### Phase 5 completion record
- Editable Eisenhower cards now reuse Slice 14a's compact control stack: a
pointer drag handle with a disclosure-controlled keyboard menu beneath it.
The menu uses the payload's Do Next, Scheduled, On Deck, and Backlog labels
and disables the card's current quadrant.
- Pointer targets are resolved only within the card's own matrix grid, so a
grouped notebook board cannot accept a drop from another group. Threshold,
capture, Escape cancellation, edge scrolling, click suppression, pending
state, and canonical post-move rendering match the Kanban interaction.
- Matrix moves post only fixed positional destinations with the host and view
identities, then refresh only the initiating matrix through a guarded request
sequence. Live success/error status and focus recovery survive rerendering.
- Controls are created only from the main process's effective `editable` payload;
read-only Eisenhower and all Skeleton matrices add no handles, menus, or focus
stops. Existing card opening, hover detail, sorting, and List more rendering
remain shared and unchanged.
- Focused validation passed: 3 suites, 67 tests. Full regression validation
passed: 22 suites, 374 tests. Webview syntax and `git diff --check` passed.
Packaging and manual Joplin acceptance remain pending.
### Phase 6 — Documentation and complete validation
- [ ] Update README.md examples, option tables, quadrant table, read-only text,
- [x] Update README.md examples, option tables, quadrant table, read-only text,
controls, and limitations.
- [ ] Update SPEC.md configuration, label contract, transition table, protocol,
- [x] Update SPEC.md configuration, label contract, transition table, protocol,
mutation safety, accessibility, and refresh behavior.
- [ ] Update the unreleased v2.0.0 CHANGELOG entry.
- [ ] Decide whether Skeleton remains documented as a supported alternative or
- [x] Update the unreleased v2.0.0 CHANGELOG entry.
- [x] Decide whether Skeleton remains documented as a supported alternative or
is marked as legacy/non-editable; do not remove it in this slice.
- [ ] Run focused parser, labels, collector, mutation, handler, render, grouping,
- [x] Run focused parser, labels, collector, mutation, handler, render, grouping,
pagination, and accessibility tests.
- [ ] Run the complete Jest suite, TypeScript, webview syntax, CSS/build,
- [x] Run the complete Jest suite, TypeScript, webview syntax, CSS/build,
whitespace, and prohibited-reference checks.
- [ ] Build and inspect the production JPL without publishing v2.0.0 yet.
- [x] Build and inspect the production JPL without publishing v2.0.0 yet.
#### Phase 6 completion record
- README, specification, and unreleased changelog now document the opt-in
Eisenhower control, all four persisted tag states, fresh validation, scoped
pointer/keyboard behavior, canonical refresh, accessibility, and unchanged
completion/sorting/pagination semantics. Skeleton remains a supported,
intentionally read-only default mode rather than being marked legacy.
- Focused interaction/mutation/handler validation passed at 3 suites and 67
tests; the complete Jest run passed at 22 suites and 374 tests. TypeScript
(`--skipLibCheck`), webview JavaScript syntax, whitespace, and current-source
prohibited visible-label checks passed.
- `npm run dist` completed. The inspected production artifact is a POSIX-tar JPL
containing the manifest and four expected runtime assets, including bundled
matrix mutation UI and CSS. This pre-version-bump artifact was version 1.0.0,
193,024 bytes, with SHA-256
`a24ca6bf464c2f4e0aa0cc3815d6a4dc7d565c084b35a8a7c962af1443dcb903`.
- The build could not embed Git metadata because the sandbox denied its internal
Git subprocess; this does not affect the plugin assets. No publish, version
bump, or manual Joplin acceptance was performed.
- After Phase 7 user acceptance, the package, lockfile root package, and Joplin
manifest were advanced to 2.0.0. The rebuilt 2.0.0 JPL is 193,024 bytes with
SHA-256 `fcffaecda3ded4ee11d70e21bd86fa8ede7a7d018b98feb857bce0aae2b67503`.
This version change remains uncommitted and unpushed as requested.
### Phase 7 — Manual Joplin acceptance
- [ ] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
- [ ] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
- [x] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
- [x] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
Eliminate, or “Drop this.”
- [ ] Read-only Eisenhower matrices retain click, hover, sorting, and pagination
- [x] Read-only Eisenhower matrices retain click, hover, sorting, and pagination
behavior without mutation affordances.
- [ ] An editable Eisenhower matrix moves an ordinary note through all four
- [x] An editable Eisenhower matrix moves an ordinary note through all four
quadrants and persists the exact urgent/important tag state after reload.
- [ ] An editable Eisenhower matrix moves a native to-do through all four
- [x] An editable Eisenhower matrix moves a native to-do through all four
quadrants without changing native completion or progress state.
- [ ] Custom distinct axis tags work; equal axis tags warn and disable editing.
- [ ] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
- [ ] A normal click opens the note; a drag does not accidentally open it.
- [ ] Keyboard-only **Move to…** reaches every quadrant and restores focus.
- [ ] Stale completion, filtering, scope, group, and API-failure cases converge
- [x] Custom distinct axis tags work; equal axis tags warn and disable editing.
- [x] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
- [x] A normal click opens the note; a drag does not accidentally open it.
- [x] Keyboard-only **Move to…** reaches every quadrant and restores focus.
- [x] Stale completion, filtering, scope, group, and API-failure cases converge
to canonical state without unrelated data changes.
- [ ] Single, grouped, multiple-block, scrolling, and paginated matrices work.
- [ ] Skeleton matrices remain correct and read-only.
- [ ] Record explicit user sign-off separately from automated checks.
- [x] Single, grouped, multiple-block, scrolling, and paginated matrices work.
- [x] Skeleton matrices remain correct and read-only.
- [x] Record explicit user sign-off separately from automated checks.
#### Phase 7 completion record
- The user reported all manual Slice 15 tests passed on 2026-07-31 after using
the editable Eisenhower acceptance block. This sign-off is recorded separately
from the 374-test automated run and production package inspection.
## Out of scope

35
SPEC.md
View File

@ -134,6 +134,7 @@ to-do uses native completion and may activate the Repeating To-Dos plugin.
```
```gtd-matrix
mode: skeleton # skeleton | eisenhower (default: skeleton)
editable: no # yes | no; Eisenhower moves (default: no)
title: Priorities
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
notebook: Work/Ideas # same semantics as gtd-calendar (default: this note's folder)
@ -160,9 +161,20 @@ positional; each mode supplies its own axis and quadrant labels.
**`mode: skeleton`** (default) — rows = active (`in-progress-tag`, the same tag the kanban uses); columns = due soon (resolved card date within `urgent-window` days, overdue included, or the `urgent-tag` as a manual override for dateless items). Quadrants: **Do Next** (active + due soon), **Scheduled** (active, not due soon — includes dateless in-progress work), **On Deck** (not active + due soon — the early-warning quadrant), **Backlog** (neither). Designed to complement the calendar and kanban directly: same in-progress tag, dates drive urgency instead of a second opinion-tag, and nothing is ever condemned as "Eliminate" — a Backlog item is simply low priority. In-progress + `urgent-tag` + no date resolves to Do Next (the urgent tag is an explicit override, so it wins over the absence of a date).
**`mode: eisenhower`** — the classic: rows = `important-tag`; columns = `urgent-tag`. Quadrants: Do First (both), Schedule (important only), Delegate (urgent only), Eliminate (neither).
**`mode: eisenhower`** — rows = `important-tag`; columns = `urgent-tag`.
Quadrants use the constructive shared labels while retaining Eisenhower
semantics: Do Next (both), Scheduled (important only), On Deck (urgent only),
Backlog (neither).
A warning is raised if `urgent-tag` and `important-tag` resolve to the same tag (Eisenhower mode only — the axes would collapse). `page-size` has the same validation and transient behavior as the kanban, applied independently to each quadrant after sorting. Read-only.
`editable` accepts scalar `yes | no` case-insensitively and defaults to `no`.
It is effective only for Eisenhower mode with distinct normalized axis tags.
Skeleton or equal-axis configurations warn, remain read-only, and expose no
mutation controls. An editable move persists only the destination's tag state:
Do Next = both tags, Scheduled = important only, On Deck = urgent only, and
Backlog = neither. The handler reparses the block, validates selected host,
scope/group, item eligibility, completion, mode, axes, and fixed destination
against fresh state before applying minimal tag writes. `page-size` has the same
validation and transient behavior as kanban, independently per quadrant.
### 2.5 The `gtd-gantt` block (dual-role: chart **and** item declaration)
@ -272,6 +284,11 @@ notes never become recurring.
are announced through an atomic live region; focus returns to the moved card
or status fallback after canonical rerender.
- **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter.
- **Editable matrix:** effective Eisenhower `editable: yes` reuses the pointer
handle and compact disclosure menu. Targets are limited to the originating
matrix/notebook group. Pending/error live announcements, Escape cancellation,
focus recovery, and guarded canonical rerender match editable kanban. Skeleton
and read-only Eisenhower matrices add no mutation UI or focus stops.
- **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it.
- **Drilldown:** every tile, card, hover-card row, and chip across all four view types is clickable and opens the source note.
@ -298,7 +315,7 @@ Upstream is a single synchronous markdown-it content script with no data API acc
│ │ calendar / board / matrix / Gantt DOM from
│ │ returned payload. Click → postMessage
│ │ ({type: "openNote", noteId}); editable
│ │ kanban sends constrained move intent only.
│ │ kanban/matrix send constrained move intent.
└──────────────┬──────────────┘
│ postMessage / onMessage
┌──────────────▼──────────────┐
@ -323,7 +340,7 @@ Upstream is a single synchronous markdown-it content script with no data API acc
│ │ collectGantt.ts)
│ │ • handles openNote via
│ │ joplin.commands.execute
│ │ • reparses editable kanban config, refetches
│ │ • reparses editable view config, refetches
│ │ host/card/scope/tags, derives minimal writes,
│ │ then returns a typed move result
└─────────────────────────────┘
@ -356,7 +373,7 @@ SLICE8 batching remains transient webview state.
revisiting if a `children`-scope board on a large tree feels slow, especially
with Persistent Layout keeping several boards rendered at once.
- Refresh model: normal background edits appear when Joplin rerenders the note.
An editable-kanban move immediately refetches and rerenders only its initiating
An editable kanban or matrix move immediately refetches and rerenders only its initiating
`viewInstanceId`; request sequencing rejects stale responses. Other open
dashboards remain stale until their own rerender.
@ -458,10 +475,10 @@ Real-world feedback: Eisenhower's opinion axes (and "Eliminate" in particular) f
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)
- urgent + important → **Do Next**
- important only → **Scheduled**
- urgent only → **On Deck**
- neither → **Backlog**
- **Completed to-dos are excluded entirely** — the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live.
- Incomplete to-dos plus ordinary notes containing a `gtd` block; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as kanban and the same compact clickable cards.
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.

View File

@ -206,4 +206,6 @@ Slices 813 are complete. SLICE14 is the next implementation slice.
- [x] SLICE14 complete. Phases 17 passed; explicit manual Joplin acceptance was
received on 2026-07-31. Slice 15 remains not started.
- [x] SLICE14a complete. Phases 15 passed and explicit manual acceptance was
received on 2026-07-31. Slice 15 remains not started.
received on 2026-07-31.
- [x] SLICE15 complete. Phases 17 passed, production packaging was inspected,
and explicit manual Joplin acceptance was received on 2026-07-31.

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "joplin-plugin-gtd-calendar",
"version": "1.0.0",
"version": "2.0.0",
"scripts": {
"test": "jest",
"predist": "bash scripts/archive-jpl.sh",

View File

@ -28,10 +28,10 @@ export interface MatrixResult {
* - 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 First
* important only -> Schedule
* urgent only -> Delegate
* neither -> Eliminate (default bucket for untagged)
* urgent + important -> Do Next
* important only -> Scheduled
* urgent only -> On Deck
* neither -> Backlog (default bucket for untagged)
*
* The matrix note itself is always excluded.
*/

View File

@ -24,7 +24,7 @@ function matchingTags(tags: RawTag[], title: string): RawTag[] {
.sort((a, b) => a.id.localeCompare(b.id));
}
async function ensureTag(
export async function ensureNoteTag(
adapter: MutationAdapter,
noteId: string,
title: string,
@ -38,7 +38,7 @@ async function ensureTag(
return true;
}
async function removeTag(
export async function removeNoteTag(
adapter: MutationAdapter,
noteId: string,
title: string,
@ -78,7 +78,7 @@ export async function moveKanbanItem(
changed = true;
}
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
if (note.todo_completed > 0) {
@ -87,26 +87,26 @@ export async function moveKanbanItem(
}
if (destination === "inProgress") {
changed =
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) ||
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
}
}
} else if (destination === "done") {
changed = (await ensureTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed = (await ensureNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
} else if (destination === "inProgress") {
changed =
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
} else {
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
}
return { note, changed };

View File

@ -7,10 +7,10 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels {
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Do First",
topRight: "Schedule",
bottomLeft: "Delegate",
bottomRight: "Eliminate",
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
};
}

View File

@ -0,0 +1,131 @@
const YAML = require("yaml");
import extractGtdBlock from "./gtdBlock";
import resolveScopedFolderIds from "./folderScope";
import { moveMatrixItem } from "./matrixMutation";
import { isMatrixDestination } from "./moveProtocol";
import parseMatrixConfig from "./parseMatrixConfig";
import resolveNotebook from "./resolveNotebook";
import {
DataAdapter,
MoveCardResult,
MoveMatrixCardIntent,
MutationAdapter,
RawNote,
RawTag,
} from "./types";
export interface MatrixMoveHost {
id: string;
parentId: string;
}
class StaleMatrixMoveError extends Error {}
function result(
status: "stale" | "error",
viewInstanceId: string,
message: string
): MoveCardResult {
return { status, viewInstanceId, message };
}
function validId(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validateFreshEligibility(
note: RawNote,
tags: RawTag[],
host: MatrixMoveHost,
folderIds: Set<string>,
config: ReturnType<typeof parseMatrixConfig>
): void {
if (note.id === host.id) throw new StaleMatrixMoveError("The dashboard note cannot be moved.");
if (!folderIds.has(note.parent_id)) throw new StaleMatrixMoveError("The card is no longer in scope.");
if (!note.is_todo && config.notes === "none") {
throw new StaleMatrixMoveError("The card is no longer admitted by notes.");
}
const block = extractGtdBlock(note.body);
if (note.is_todo) {
if (config.todos === "none") throw new StaleMatrixMoveError("The card is no longer admitted by todos.");
if (config.todos === "gtd-only" && !block.found) {
throw new StaleMatrixMoveError("The to-do no longer contains a gtd block.");
}
if (note.todo_completed > 0) {
throw new StaleMatrixMoveError("The to-do is now complete and no longer belongs in the matrix.");
}
} else {
if (!block.found) throw new StaleMatrixMoveError("The note no longer contains a gtd block.");
const titles = tags.map((tag) => (tag.title || "").trim().toLowerCase());
if (titles.includes(config.doneTag)) {
throw new StaleMatrixMoveError("The note is now complete and no longer belongs in the matrix.");
}
}
}
export async function validateAndMoveMatrixCard(
intent: unknown,
host: MatrixMoveHost | null,
readAdapter: DataAdapter,
mutationAdapter: MutationAdapter,
logDiagnostic: (error: unknown) => void = () => undefined
): Promise<MoveCardResult> {
const candidate = intent as Partial<MoveMatrixCardIntent> | null;
const viewInstanceId = validId(candidate?.viewInstanceId)
? candidate.viewInstanceId
: "unknown";
const allowedKeys = new Set([
"type", "view", "hostNoteId", "cardId", "destination", "rawConfig", "viewInstanceId",
]);
if (
!candidate || Array.isArray(candidate) ||
Object.keys(candidate).some((key) => !allowedKeys.has(key)) ||
candidate.type !== "moveMatrixCard" || candidate.view !== "matrix" ||
!validId(candidate.hostNoteId) || !validId(candidate.cardId) ||
!validId(candidate.viewInstanceId) || typeof candidate.rawConfig !== "string" ||
!isMatrixDestination(candidate.destination)
) {
return result("error", viewInstanceId, "Invalid matrix move request.");
}
if (!host || candidate.hostNoteId !== host.id) {
return result("stale", viewInstanceId, "The rendered dashboard is no longer current.");
}
let parsed: unknown;
try {
parsed = YAML.parse(candidate.rawConfig || "") || {};
} catch (_parseError) {
return result("stale", viewInstanceId, "The matrix configuration is no longer valid.");
}
const config = parseMatrixConfig(parsed);
if (!config.editable || config.mode !== "eisenhower") {
return result("error", viewInstanceId, "This matrix is read-only.");
}
try {
const folders = await readAdapter.getFolders();
let scanFolderId = host.parentId;
if (!config.scopeAll && config.notebook) {
scanFolderId = resolveNotebook(folders, config.notebook, host.parentId).folderId;
}
const folderIds = new Set(
resolveScopedFolderIds(folders, scanFolderId, config.scopeDepth, config.scopeAll)
);
const outcome = await moveMatrixItem(
mutationAdapter,
candidate.cardId,
candidate.destination,
config,
(note, tags) => validateFreshEligibility(note, tags, host, folderIds, config)
);
return { status: "success", viewInstanceId, changed: outcome.changed };
} catch (moveError) {
if (moveError instanceof StaleMatrixMoveError ||
(moveError instanceof Error && moveError.message === "The card no longer exists.")) {
return result("stale", viewInstanceId, (moveError as Error).message);
}
logDiagnostic(moveError);
return result("error", viewInstanceId, "Could not apply the move. Refresh and try again.");
}
}

48
src/Gtd/matrixMutation.ts Normal file
View File

@ -0,0 +1,48 @@
import { ensureNoteTag, removeNoteTag } from "./kanbanMutation";
import {
MatrixDestination,
MutationAdapter,
RawNote,
} from "./types";
export interface MatrixMutationConfig {
urgentTag: string;
importantTag: string;
}
export interface MatrixMutationOutcome {
note: RawNote;
changed: boolean;
}
const TARGETS: Record<MatrixDestination, { urgent: boolean; important: boolean }> = {
topLeft: { urgent: true, important: true },
topRight: { urgent: false, important: true },
bottomLeft: { urgent: true, important: false },
bottomRight: { urgent: false, important: false },
};
/** Apply the fixed Eisenhower tag state without changing any note fields. */
export async function moveMatrixItem(
adapter: MutationAdapter,
noteId: string,
destination: MatrixDestination,
config: MatrixMutationConfig,
validateFreshState?: (note: RawNote, tags: Array<{ id: string; title: string }>) => void
): Promise<MatrixMutationOutcome> {
const note = await adapter.getNote(noteId);
if (!note) throw new Error("The card no longer exists.");
const noteTags = await adapter.getNoteTags(noteId);
if (validateFreshState) validateFreshState(note, noteTags);
const target = TARGETS[destination];
let changed = false;
changed = target.urgent
? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed
: (await removeNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed;
changed = target.important
? (await ensureNoteTag(adapter, noteId, config.importantTag, noteTags)) || changed
: (await removeNoteTag(adapter, noteId, config.importantTag, noteTags)) || changed;
return { note, changed };
}

View File

@ -1,4 +1,4 @@
import { KanbanDestination } from "./types";
import { KanbanDestination, MatrixDestination } from "./types";
const KANBAN_DESTINATIONS: KanbanDestination[] = [
"backlog",
@ -10,3 +10,14 @@ const KANBAN_DESTINATIONS: KanbanDestination[] = [
export function isKanbanDestination(value: unknown): value is KanbanDestination {
return KANBAN_DESTINATIONS.includes(value as KanbanDestination);
}
const MATRIX_DESTINATIONS: MatrixDestination[] = [
"topLeft",
"topRight",
"bottomLeft",
"bottomRight",
];
export function isMatrixDestination(value: unknown): value is MatrixDestination {
return MATRIX_DESTINATIONS.includes(value as MatrixDestination);
}

View File

@ -41,6 +41,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
"urgent-window",
"card-detail",
"page-size",
"editable",
];
for (const key of Object.keys(input)) {
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
@ -194,6 +195,26 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
const pageSize = parsePageSize(input["page-size"], warnings);
let editable = false;
if (input.editable !== undefined) {
const candidate =
typeof input.editable === "string"
? input.editable.trim().toLowerCase()
: "";
if (candidate === "yes") editable = true;
else if (candidate !== "no") {
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)");
editable = false;
}
return {
mode,
title,
@ -212,6 +233,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
urgentWindow,
cardDetail,
pageSize,
editable,
warnings,
};
}

View File

@ -260,16 +260,34 @@ export interface MatrixConfig {
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. */
editable: boolean;
warnings: string[];
}
export type MatrixDestination =
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight";
export interface MoveMatrixCardIntent {
type: "moveMatrixCard";
view: "matrix";
hostNoteId: string;
cardId: string;
destination: MatrixDestination;
rawConfig: string;
viewInstanceId: string;
}
/**
* The four matrix quadrants, positional (row x column):
* topLeft topRight
* bottomLeft bottomRight
*
* Eisenhower: Do First / Schedule / Delegate / Eliminate.
* Skeleton: Do Next / Scheduled / On Deck / Backlog.
* Both modes display Do Next / Scheduled / On Deck / Backlog; their axes and
* bucketing semantics remain mode-specific.
*/
export interface MatrixBoard {
topLeft: KanbanCard[];

View File

@ -504,13 +504,26 @@
background-color: rgba(50, 120, 220, 0.12);
}
.gtd-matrix-drop-target {
outline: 1px dashed rgba(80, 120, 200, 0.8);
outline-offset: -3px;
}
.gtd-matrix-drop-active {
outline: 3px solid rgba(50, 120, 220, 0.95);
background-color: rgba(50, 120, 220, 0.12);
}
.gtd-kanban-pending .gtd-kanban-drag-handle,
.gtd-kanban-pending .gtd-kanban-move-disclosure {
.gtd-kanban-pending .gtd-kanban-move-disclosure,
.gtd-matrix-pending .gtd-kanban-drag-handle,
.gtd-matrix-pending .gtd-kanban-move-disclosure {
pointer-events: none;
opacity: 0.45;
}
.gtd-kanban-move-status {
.gtd-kanban-move-status,
.gtd-matrix-move-status {
margin: 0.25em 0;
font-size: 0.85em;
}

View File

@ -74,7 +74,7 @@
if (blockType === "kanban") {
renderKanban(el, payload, contentScriptId, renderContext);
} else if (blockType === "matrix") {
renderMatrix(el, payload, contentScriptId);
renderMatrix(el, payload, contentScriptId, renderContext);
} else if (blockType === "gantt") {
renderGantt(el, payload, contentScriptId);
} else {
@ -522,6 +522,7 @@
const pageSize = payload.pageSize || 10;
const interaction = payload.editable && payload.hostNoteId && renderContext
? {
view: "kanban",
context: renderContext,
hostNoteId: payload.hostNoteId,
root: el,
@ -736,7 +737,7 @@
handle.addEventListener("click", function (event) {
event.stopPropagation();
});
installKanbanPointerDrag(
(interaction.view === "matrix" ? installMatrixPointerDrag : installKanbanPointerDrag)(
handle,
el,
card,
@ -745,7 +746,7 @@
function () { suppressOpenUntil = Date.now() + 500; }
);
controls.appendChild(handle);
const disclosure = renderKanbanMoveMenu(
const disclosure = (interaction.view === "matrix" ? renderMatrixMoveMenu : renderKanbanMoveMenu)(
card,
sourceDestination,
interaction,
@ -917,6 +918,99 @@
return disclosure;
}
function renderMatrixMoveMenu(card, source, interaction, controls) {
const context = interaction.context;
const menuId = "gtd-matrix-move-menu-" + nextKanbanMoveMenuId++;
const disclosure = document.createElement("button");
disclosure.type = "button";
disclosure.className = "gtd-kanban-move-disclosure";
disclosure.textContent = "▾";
disclosure.disabled = context.pending;
disclosure.setAttribute("aria-label", "Move “" + card.title + "” to…");
disclosure.setAttribute("aria-expanded", "false");
disclosure.setAttribute("aria-controls", menuId);
disclosure.setAttribute("aria-haspopup", "menu");
const menu = document.createElement("div");
menu.id = menuId;
menu.className = "gtd-kanban-move-menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
const enabledItems = [];
[
["topLeft", interaction.labels.topLeft],
["topRight", interaction.labels.topRight],
["bottomLeft", interaction.labels.bottomLeft],
["bottomRight", interaction.labels.bottomRight],
].forEach(function (entry) {
const item = document.createElement("button");
item.type = "button";
item.className = "gtd-kanban-move-menu-item";
item.setAttribute("role", "menuitem");
item.setAttribute("data-matrix-destination", entry[0]);
item.setAttribute("data-current-destination", entry[0] === source ? "true" : "false");
item.textContent = entry[1];
item.disabled = entry[0] === source || context.pending;
if (!item.disabled) enabledItems.push(item);
item.addEventListener("click", function (event) {
event.stopPropagation();
if (item.disabled || context.pending) return;
close(false);
context.restoreFocusCardId = card.id;
submitMatrixMove(card, entry[0], interaction);
});
menu.appendChild(item);
});
function outsidePointer(event) {
if (!controls.contains(event.target)) close(false);
}
function close(restoreFocus) {
if (menu.hidden) return;
menu.hidden = true;
disclosure.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", outsidePointer);
if (context.openMoveMenu && context.openMoveMenu.menu === menu) context.openMoveMenu = null;
if (restoreFocus && document.contains(disclosure)) disclosure.focus();
}
function open() {
if (context.pending || !menu.hidden) return;
if (context.openMoveMenu) context.openMoveMenu.close(false);
menu.hidden = false;
disclosure.setAttribute("aria-expanded", "true");
context.openMoveMenu = { menu: menu, close: close };
document.addEventListener("pointerdown", outsidePointer);
if (enabledItems.length > 0) enabledItems[0].focus();
}
disclosure.addEventListener("click", function (event) {
event.stopPropagation();
if (menu.hidden) open(); else close(true);
});
disclosure.addEventListener("keydown", function (event) {
event.stopPropagation();
if (event.key === "ArrowDown") { event.preventDefault(); open(); }
});
menu.addEventListener("click", function (event) { event.stopPropagation(); });
menu.addEventListener("keydown", function (event) {
event.stopPropagation();
const index = enabledItems.indexOf(document.activeElement);
if (event.key === "Escape") { event.preventDefault(); close(true); }
else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
enabledItems[(index + delta + enabledItems.length) % enabledItems.length].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
enabledItems[event.key === "Home" ? 0 : enabledItems.length - 1].focus();
} else if (event.key === "Tab") close(false);
});
controls.appendChild(disclosure);
controls.appendChild(menu);
return disclosure;
}
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
let state = null;
const threshold = 6;
@ -990,6 +1084,68 @@
handle.addEventListener("lostpointercapture", cancel);
}
function installMatrixPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
let state = null;
const threshold = 6;
function clearTargets() {
interaction.board.querySelectorAll(".gtd-matrix-drop-target, .gtd-matrix-drop-active")
.forEach(function (quadrant) {
quadrant.classList.remove("gtd-matrix-drop-target", "gtd-matrix-drop-active");
});
}
function cancel(event) {
if (!state) return;
if (event && event.pointerId !== undefined && event.pointerId !== state.pointerId) return;
clearTargets();
cardEl.classList.remove("gtd-kanban-card-dragging");
document.removeEventListener("keydown", onKeyDown);
state = null;
}
function onKeyDown(event) {
if (event.key === "Escape" && state) { suppressOpen(); cancel(); }
}
handle.addEventListener("pointerdown", function (event) {
if (interaction.context.pending || event.button !== 0) return;
state = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragging: false, target: null };
handle.setPointerCapture(event.pointerId);
document.addEventListener("keydown", onKeyDown);
event.stopPropagation();
});
handle.addEventListener("pointermove", function (event) {
if (!state || event.pointerId !== state.pointerId) return;
const distance = Math.hypot(event.clientX - state.x, event.clientY - state.y);
if (!state.dragging && distance < threshold) return;
if (!state.dragging) {
state.dragging = true;
suppressOpen();
cardEl.classList.add("gtd-kanban-card-dragging");
interaction.board.querySelectorAll(".gtd-matrix-quadrant").forEach(function (quadrant) {
if (quadrant.getAttribute("data-matrix-destination") !== source) quadrant.classList.add("gtd-matrix-drop-target");
});
}
interaction.board.querySelectorAll(".gtd-matrix-drop-active").forEach(function (quadrant) {
quadrant.classList.remove("gtd-matrix-drop-active");
});
const hit = document.elementFromPoint(event.clientX, event.clientY);
const target = hit && hit.closest ? hit.closest(".gtd-matrix-quadrant") : null;
state.target = target && interaction.board.contains(target) &&
target.getAttribute("data-matrix-destination") !== source ? target : null;
if (state.target) state.target.classList.add("gtd-matrix-drop-active");
if (event.clientY < 40) window.scrollBy(0, -16);
else if (event.clientY > window.innerHeight - 40) window.scrollBy(0, 16);
event.preventDefault();
});
handle.addEventListener("pointerup", function (event) {
if (!state || event.pointerId !== state.pointerId) return;
const destination = state.target && state.target.getAttribute("data-matrix-destination");
const shouldMove = state.dragging && destination && destination !== source;
cancel(event);
if (shouldMove) submitMatrixMove(card, destination, interaction);
});
handle.addEventListener("pointercancel", cancel);
handle.addEventListener("lostpointercapture", cancel);
}
function submitKanbanMove(card, destination, interaction) {
const context = interaction.context;
if (context.pending) return;
@ -1060,6 +1216,76 @@
});
}
function submitMatrixMove(card, destination, interaction) {
const context = interaction.context;
if (context.pending) return;
context.pending = true;
context.statusMessage = "Moving “" + card.title + "”…";
context.statusError = false;
showMatrixStatus(interaction.root, context.statusMessage, false);
interaction.root.classList.add("gtd-matrix-pending");
if (context.openMoveMenu) context.openMoveMenu.close(false);
interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = true; });
webviewApi.postMessage(context.contentScriptId, {
type: "moveMatrixCard",
view: "matrix",
hostNoteId: interaction.hostNoteId,
cardId: card.id,
destination: destination,
rawConfig: context.rawConfig,
viewInstanceId: context.viewInstanceId,
}).then(function (result) {
if (!result || result.viewInstanceId !== context.viewInstanceId) return;
context.statusMessage = result.status === "success"
? "Moved “" + card.title + "”."
: result.message || "The move could not be completed.";
context.statusError = result.status !== "success";
return refreshMatrix(interaction.root, context);
}).catch(function () {
context.statusMessage = "The move could not be completed. Refresh and try again.";
context.statusError = true;
showMatrixStatus(interaction.root, context.statusMessage, true);
return refreshMatrix(interaction.root, context);
}).finally(function () {
context.pending = false;
interaction.root.classList.remove("gtd-matrix-pending");
interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) {
control.disabled = control.getAttribute("data-current-destination") === "true";
});
});
}
function showMatrixStatus(root, message, isError) {
let status = root.querySelector(".gtd-matrix-move-status");
if (!status) {
status = document.createElement("p");
const wrapper = root.querySelector(".gtd-matrix");
if (wrapper) wrapper.insertBefore(status, wrapper.firstChild);
}
status.className = isError
? "gtd-matrix-move-status gtd-calendar-error"
: "gtd-matrix-move-status";
status.textContent = message;
status.setAttribute("role", "status");
status.setAttribute("aria-live", isError ? "assertive" : "polite");
status.setAttribute("aria-atomic", "true");
}
function refreshMatrix(root, context) {
const sequence = ++context.requestSequence;
return webviewApi.postMessage(context.contentScriptId, {
type: "getMatrix",
rawConfig: context.rawConfig,
viewInstanceId: context.viewInstanceId,
}).then(function (payload) {
if (sequence !== context.requestSequence || !payload ||
payload.viewInstanceId !== context.viewInstanceId) return;
renderMatrix(root, payload, context.contentScriptId, context);
});
}
function formatCardDate(dateISO) {
const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, {
@ -1072,7 +1298,11 @@
// Eisenhower matrix (gtd-matrix block)
// ------------------------------------------------------------------
function renderMatrix(el, payload, contentScriptId) {
function renderMatrix(el, payload, contentScriptId, renderContext) {
if (renderContext && renderContext.openMoveMenu) {
renderContext.openMoveMenu.close(false);
renderContext.openMoveMenu = null;
}
el.innerHTML = "";
const wrapper = document.createElement("div");
@ -1113,14 +1343,35 @@
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Do First",
topRight: "Schedule",
bottomLeft: "Delegate",
bottomRight: "Eliminate",
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
};
const detail = payload.cardDetail || "hover";
const pageSize = payload.pageSize || 10;
const interaction = payload.editable && payload.hostNoteId && renderContext
? {
view: "matrix",
context: renderContext,
hostNoteId: payload.hostNoteId,
root: el,
labels: labels.quadrants,
}
: null;
if (renderContext && renderContext.statusMessage) {
const status = document.createElement("p");
status.className = renderContext.statusError
? "gtd-matrix-move-status gtd-calendar-error"
: "gtd-matrix-move-status";
status.textContent = renderContext.statusMessage;
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
status.setAttribute("aria-atomic", "true");
wrapper.appendChild(status);
}
if (layout.kind === "notebooks" && layout.groups.length > 0) {
layout.groups.forEach(function (group) {
@ -1130,7 +1381,8 @@
labels,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction
)
);
});
@ -1142,7 +1394,8 @@
labels,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction
)
);
}
@ -1162,6 +1415,15 @@
}
el.appendChild(wrapper);
if (renderContext && renderContext.restoreFocusCardId) {
setTimeout(function () {
if (!renderContext.restoreFocusCardId) return;
const fallback = el.querySelector(".gtd-matrix-move-status") || wrapper;
fallback.setAttribute("tabindex", "-1");
fallback.focus();
renderContext.restoreFocusCardId = null;
}, 0);
}
}
function renderNotebookMatrixGroup(
@ -1169,7 +1431,8 @@
labels,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction
) {
const section = document.createElement("section");
section.className = "gtd-notebook-view";
@ -1184,7 +1447,8 @@
labels,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction
)
);
return section;
@ -1195,11 +1459,15 @@
labels,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction
) {
// Axis header row: blank corner + column labels
const grid = document.createElement("div");
grid.className = "gtd-matrix-grid";
const boardInteraction = interaction
? Object.assign({}, interaction, { board: grid })
: null;
grid.appendChild(matrixAxisCell(""));
grid.appendChild(matrixAxisCell(labels.columns[0]));
@ -1213,7 +1481,9 @@
"do-first",
pageSize,
detail,
contentScriptId
contentScriptId,
boardInteraction,
"topLeft"
)
);
grid.appendChild(
@ -1223,7 +1493,9 @@
"schedule",
pageSize,
detail,
contentScriptId
contentScriptId,
boardInteraction,
"topRight"
)
);
@ -1235,7 +1507,9 @@
"delegate",
pageSize,
detail,
contentScriptId
contentScriptId,
boardInteraction,
"bottomLeft"
)
);
grid.appendChild(
@ -1245,7 +1519,9 @@
"eliminate",
pageSize,
detail,
contentScriptId
contentScriptId,
boardInteraction,
"bottomRight"
)
);
@ -1274,10 +1550,13 @@
kind,
pageSize,
detail,
contentScriptId
contentScriptId,
interaction,
destination
) {
const quadrant = document.createElement("div");
quadrant.className = "gtd-matrix-quadrant gtd-matrix-" + kind;
quadrant.setAttribute("data-matrix-destination", destination);
const header = document.createElement("div");
header.className = "gtd-kanban-column-header";
@ -1285,7 +1564,7 @@
quadrant.appendChild(header);
quadrant.appendChild(
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
);
return quadrant;
}

View File

@ -14,6 +14,7 @@ import parseGanttConfig from "./Gtd/parseGanttConfig";
import collectGantt from "./Gtd/collectGantt";
import matrixLabels from "./Gtd/matrixLabels";
import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler";
import { validateAndMoveMatrixCard } from "./Gtd/matrixMoveHandler";
import resolveNotebook from "./Gtd/resolveNotebook";
import {
emptyKanbanLayout,
@ -186,6 +187,8 @@ joplin.plugins.register({
return handleMoveKanbanCard(message);
case "getMatrix":
return handleGetMatrix(message);
case "moveMatrixCard":
return handleMoveMatrixCard(message);
case "getGantt":
return handleGetGantt(message);
case "openNote":
@ -220,6 +223,27 @@ async function handleMoveKanbanCard(message: unknown) {
return result;
}
async function handleMoveMatrixCard(message: unknown) {
let host: { id: string; parentId: string } | null = null;
try {
const note = await joplin.workspace.selectedNote();
if (note) host = { id: note.id, parentId: note.parent_id };
} catch (hostError) {
console.warn("gtd-matrix: could not validate move host", hostError);
}
const moveResult = await validateAndMoveMatrixCard(
message,
host,
joplinAdapter,
joplinMutationAdapter,
(error) => console.warn("gtd-matrix: move failed", error)
);
if (moveResult.status !== "success") {
console.warn("gtd-matrix: move rejected", moveResult.status, moveResult.message);
}
return moveResult;
}
async function handleGetEvents(message: { rawConfig?: string }) {
// 1. Parse the gtd-calendar config.
let rawParsed: any = {};
@ -372,7 +396,10 @@ async function handleGetKanban(message: {
};
}
async function handleGetMatrix(message: { rawConfig?: string }) {
async function handleGetMatrix(message: {
rawConfig?: string;
viewInstanceId?: string;
}) {
let rawParsed: any = {};
let configError: string | null = null;
try {
@ -402,6 +429,9 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
title: config.title,
cardDetail: config.cardDetail,
pageSize: config.pageSize,
editable: config.editable,
hostNoteId: null,
viewInstanceId: message.viewInstanceId || null,
scopeAll: config.scopeAll,
configError:
configError || "Could not determine which note contains this matrix.",
@ -426,6 +456,9 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
title: config.title,
cardDetail: config.cardDetail,
pageSize: config.pageSize,
editable: config.editable,
hostNoteId: sourceNote.id,
viewInstanceId: message.viewInstanceId || null,
scopeAll: config.scopeAll,
configError,
warnings: [

View File

@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
"app_min_version": "2.7",
"version": "1.0.0",
"version": "2.0.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",

View File

@ -1,5 +1,6 @@
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
import collectMatrix from "../../Gtd/collectMatrix";
import matrixLabels from "../../Gtd/matrixLabels";
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// ---------------------------------------------------------------------------
@ -7,6 +8,28 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// ---------------------------------------------------------------------------
describe("parseMatrixConfig", () => {
test("both modes use constructive labels while preserving distinct axes", () => {
expect(matrixLabels("eisenhower")).toEqual({
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
});
expect(matrixLabels("skeleton")).toEqual({
columns: ["Due soon", "Not due soon"],
rows: ["Active", "Not active"],
quadrants: {
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
});
});
test("applies SPEC defaults", () => {
const c = parseMatrixConfig({});
expect(c).toMatchObject({
@ -24,10 +47,39 @@ describe("parseMatrixConfig", () => {
urgentWindow: 3,
cardDetail: "hover",
pageSize: 10,
editable: false,
});
expect(c.warnings).toHaveLength(0);
});
test("editable is strict, Eisenhower-only, and requires distinct axes", () => {
expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true);
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false);
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
const config = parseMatrixConfig({ mode: "eisenhower", editable: value });
expect(config.editable).toBe(false);
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)"
);
const equalAxes = parseMatrixConfig({
mode: "eisenhower",
editable: "yes",
"urgent-tag": "Axis",
"important-tag": "axis",
});
expect(equalAxes.editable).toBe(false);
expect(equalAxes.warnings).toContain(
"editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)"
);
});
test("parses notes independently and rejects invalid or singular forms", () => {
expect(parseMatrixConfig({ notes: " NONE " }).notes).toBe("none");
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
@ -146,7 +198,7 @@ function makeAdapter(
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
describe("collectMatrix — quadrant bucketing", () => {
test("all four quadrants route correctly; untagged lands in Eliminate", async () => {
test("all four Eisenhower quadrants route correctly; untagged lands in Backlog", async () => {
const notes = [
makeNote({
id: "both",

View File

@ -0,0 +1,134 @@
import { validateAndMoveMatrixCard } from "../../Gtd/matrixMoveHandler";
import { DataAdapter, MutationAdapter, RawFolder, RawNote, RawTag } from "../../Gtd/types";
const folders: RawFolder[] = [
{ id: "board", parent_id: "", title: "Board" },
{ id: "child", parent_id: "board", title: "Child" },
{ id: "outside", parent_id: "", title: "Outside" },
];
function card(overrides: Partial<RawNote> = {}): RawNote {
return {
id: "card", title: "Card", parent_id: "board", is_todo: 0,
todo_due: 0, todo_completed: 0, updated_time: 0, body: "```gtd\n```",
...overrides,
};
}
function setup(current: RawNote | null = card(), attached: RawTag[] = []) {
const writes: string[] = [];
const read: DataAdapter = {
getFolders: async () => folders,
getNotesInFolder: async () => [],
getNoteTagTitles: async () => [],
};
const mutation: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => attached.slice(),
getAllTags: async () => [
{ id: "urgent", title: "urgent" },
{ id: "important", title: "important" },
],
createTag: async (title) => ({ id: title, title }),
attachTag: async (tagId) => { writes.push(`attach:${tagId}`); },
detachTag: async (tagId) => { writes.push(`detach:${tagId}`); },
setTodoCompleted: async () => { throw new Error("completion write forbidden"); },
};
return { read, mutation, writes };
}
function intent(overrides: Record<string, unknown> = {}) {
return {
type: "moveMatrixCard", view: "matrix", hostNoteId: "host", cardId: "card",
destination: "topLeft",
rawConfig: "mode: eisenhower\neditable: yes\nnotes: all\ntodos: all",
viewInstanceId: "view-1", ...overrides,
};
}
const host = { id: "host", parentId: "board" };
describe("validateAndMoveMatrixCard", () => {
test("accepts a constrained editable Eisenhower move", async () => {
const { read, mutation, writes } = setup();
await expect(validateAndMoveMatrixCard(intent(), host, read, mutation)).resolves.toEqual({
status: "success", viewInstanceId: "view-1", changed: true,
});
expect(writes).toEqual(["attach:urgent", "attach:important"]);
});
test.each([
{}, intent({ view: "kanban" }), intent({ destination: "done" }),
intent({ cardId: "" }), intent({ rawConfig: { editable: "yes" } }),
intent({ patch: { tags: ["urgent"] } }),
])("rejects malformed or expanded input without writes", async (request) => {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(request, host, read, mutation)).status).toBe("error");
expect(writes).toEqual([]);
});
test.each([
["", "error"],
["mode: eisenhower\neditable: no", "error"],
["mode: skeleton\neditable: yes", "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) => {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(intent({ rawConfig }), host, read, mutation)).status).toBe(status);
expect(writes).toEqual([]);
});
test("requires the selected host identity", async () => {
for (const selected of [null, { id: "other", parentId: "board" }]) {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(intent(), selected, read, mutation)).status).toBe("stale");
expect(writes).toEqual([]);
}
});
test.each([
[card({ id: "host" }), [], "dashboard"],
[card({ parent_id: "outside" }), [], "scope"],
[card({ body: "plain" }), [], "gtd block"],
[card({ is_todo: 1, todo_completed: 99 }), [], "complete"],
[card(), [{ id: "done", title: "DONE" }], "complete"],
] as Array<[RawNote, RawTag[], string]>)("rejects stale item state", async (current, tags, message) => {
const { read, mutation, writes } = setup(current, tags);
const moveResult = await validateAndMoveMatrixCard(intent(), host, read, mutation);
expect(moveResult).toMatchObject({ status: "stale" });
expect((moveResult as any).message).toContain(message);
expect(writes).toEqual([]);
});
test("resolves notebook and children scope from fresh folders", async () => {
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: "mode: eisenhower\neditable: yes\nnotebook: Board\nscope: children",
}), host, read, mutation);
expect(moveResult.status).toBe("success");
expect(writes).toEqual(["attach:urgent", "attach:important"]);
});
test.each([
[card({ is_todo: 0 }), "mode: eisenhower\neditable: yes\nnotes: none\ntodos: all"],
[card({ is_todo: 1, body: "plain" }), "mode: eisenhower\neditable: yes\nnotes: all\ntodos: gtd-only"],
[card({ is_todo: 1 }), "mode: eisenhower\neditable: yes\nnotes: all\ntodos: none"],
])("rechecks filters and opt-in", async (current, rawConfig) => {
const { read, mutation, writes } = setup(current);
expect((await validateAndMoveMatrixCard(intent({ rawConfig }), host, read, mutation)).status).toBe("stale");
expect(writes).toEqual([]);
});
test("returns a safe error and logs API diagnostics", async () => {
const { read, mutation, writes } = setup();
const diagnostics: unknown[] = [];
read.getFolders = async () => { throw new Error("private detail"); };
expect(await validateAndMoveMatrixCard(intent(), host, read, mutation, (e) => diagnostics.push(e))).toEqual({
status: "error", viewInstanceId: "view-1",
message: "Could not apply the move. Refresh and try again.",
});
expect(diagnostics).toHaveLength(1);
expect(writes).toEqual([]);
});
});

View File

@ -0,0 +1,112 @@
import { moveMatrixItem } from "../../Gtd/matrixMutation";
import { MatrixDestination, MutationAdapter, RawNote, RawTag } from "../../Gtd/types";
function note(isTodo: boolean): RawNote {
return {
id: "card", title: "Card", parent_id: "folder", is_todo: isTodo ? 1 : 0,
todo_due: 123, todo_completed: 0, updated_time: 456, body: "```gtd\n```",
};
}
function fakeAdapter(current: RawNote | null, attached: RawTag[], all?: RawTag[]) {
const calls: string[] = [];
const adapter: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => attached.slice(),
getAllTags: async () => (all || [
{ id: "u", title: "urgent" }, { id: "i", title: "important" },
]).slice(),
createTag: async (title) => { calls.push(`create:${title}`); return { id: title, title }; },
attachTag: async (tagId) => { calls.push(`attach:${tagId}`); },
detachTag: async (tagId) => { calls.push(`detach:${tagId}`); },
setTodoCompleted: async () => { throw new Error("matrix must not patch completion"); },
};
return { adapter, calls };
}
const states: Array<{
destination: MatrixDestination;
urgent: boolean;
important: boolean;
}> = [
{ destination: "topLeft", urgent: true, important: true },
{ destination: "topRight", urgent: false, important: true },
{ destination: "bottomLeft", urgent: true, important: false },
{ destination: "bottomRight", urgent: false, important: false },
];
const config = { urgentTag: "urgent", importantTag: "important" };
describe("moveMatrixItem", () => {
for (const isTodo of [false, true]) {
for (const source of states) {
for (const target of states) {
test(`${isTodo ? "to-do" : "note"}: ${source.destination} -> ${target.destination}`, async () => {
const tags: RawTag[] = [];
if (source.urgent) tags.push({ id: "u", title: "urgent" });
if (source.important) tags.push({ id: "i", title: "important" });
const { adapter, calls } = fakeAdapter(note(isTodo), tags);
const result = await moveMatrixItem(adapter, "card", target.destination, config);
const expected: string[] = [];
if (source.urgent !== target.urgent) expected.push(`${target.urgent ? "attach" : "detach"}:u`);
if (source.important !== target.important) expected.push(`${target.important ? "attach" : "detach"}:i`);
expect(calls).toEqual(expected);
expect(result.changed).toBe(source.destination !== target.destination);
expect(result.note).toMatchObject({
is_todo: isTodo ? 1 : 0, todo_due: 123, todo_completed: 0,
});
});
}
}
}
test("uses custom tag names, existing case variants, and deterministic duplicates", async () => {
const { adapter, calls } = fakeAdapter(note(false), [
{ id: "z", title: "FIRE" }, { id: "a", title: "fire" },
{ id: "keep", title: "keep-me" },
], [{ id: "m", title: "MATTERS" }]);
await moveMatrixItem(adapter, "card", "topRight", {
urgentTag: "fire", importantTag: "matters",
});
expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]);
});
test("creates only a missing required tag", async () => {
const { adapter, calls } = fakeAdapter(note(true), [], []);
await moveMatrixItem(adapter, "card", "bottomLeft", config);
expect(calls).toEqual(["create:urgent", "attach:urgent"]);
});
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;
const { adapter, calls } = fakeAdapter(current, []);
await expect(moveMatrixItem(adapter, "card", "topLeft", config, () => {
throw new Error("card is no longer eligible");
})).rejects.toThrow("no longer eligible");
expect(calls).toEqual([]);
}
});
test("surfaces partial failures without compensating writes", async () => {
const { adapter, calls } = fakeAdapter(note(false), [], [
{ id: "u", title: "urgent" }, { id: "i", title: "important" },
]);
adapter.attachTag = async (tagId) => {
calls.push(`attach:${tagId}`);
if (tagId === "i") throw new Error("important attach failed");
};
await expect(moveMatrixItem(adapter, "card", "topLeft", config)).rejects.toThrow(
"important attach failed"
);
expect(calls).toEqual(["attach:u", "attach:i"]);
});
test("fails before writes when the card disappeared", async () => {
const { adapter, calls } = fakeAdapter(null, []);
await expect(moveMatrixItem(adapter, "card", "topLeft", config)).rejects.toThrow(
"no longer exists"
);
expect(calls).toEqual([]);
});
});

View File

@ -1,4 +1,4 @@
import { isKanbanDestination } from "../../Gtd/moveProtocol";
import { isKanbanDestination, isMatrixDestination } from "../../Gtd/moveProtocol";
describe("kanban move protocol", () => {
test.each(["backlog", "inProgress", "done"])(
@ -15,3 +15,15 @@ describe("kanban move protocol", () => {
}
);
});
describe("matrix move protocol", () => {
test.each(["topLeft", "topRight", "bottomLeft", "bottomRight"])(
"accepts fixed destination %s",
(destination) => expect(isMatrixDestination(destination)).toBe(true)
);
test.each(["", "urgent", "done", null, 1, { tags: ["urgent"] }])(
"rejects arbitrary destination %p",
(destination) => expect(isMatrixDestination(destination)).toBe(false)
);
});

View File

@ -37,3 +37,36 @@ describe("Slice 14a compact move-menu rendering contract", () => {
expect(css).not.toContain(".gtd-kanban-move-select");
});
});
describe("Slice 15 editable matrix interaction contract", () => {
test("renders matrix controls only from the effective editable payload", () => {
expect(webview).toContain("payload.editable && payload.hostNoteId && renderContext");
expect(webview).toContain('view: "matrix"');
expect(webview).toContain("renderMatrixMoveMenu");
expect(webview).toContain("installMatrixPointerDrag");
});
test("uses fixed matrix destinations and the supplied display labels", () => {
for (const destination of ["topLeft", "topRight", "bottomLeft", "bottomRight"]) {
expect(webview).toContain(`["${destination}", interaction.labels.${destination}]`);
expect(webview).toContain(`"${destination}"`);
}
expect(webview).toContain('data-matrix-destination');
});
test("posts a scoped matrix move and refreshes only that matrix instance", () => {
expect(webview).toContain('type: "moveMatrixCard"');
expect(webview).toContain('view: "matrix"');
expect(webview).toContain('type: "getMatrix"');
expect(webview).toContain("viewInstanceId: context.viewInstanceId");
expect(webview).toContain("sequence !== context.requestSequence");
});
test("styles pointer targets, pending state, and live status", () => {
expect(css).toContain(".gtd-matrix-drop-target");
expect(css).toContain(".gtd-matrix-drop-active");
expect(css).toContain(".gtd-matrix-pending");
expect(css).toContain(".gtd-matrix-move-status");
expect(webview).toContain('status.setAttribute("role", "status")');
});
});