From ca0518fbffa1c22e5f3cf7fcd43cab2c425c88a9 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 31 Jul 2026 14:48:55 -0400 Subject: [PATCH] Release v2.0.0 with editable Eisenhower matrices --- CHANGELOG.md | 22 ++ README.md | 21 +- SLIDE15.md | 211 +++++++++++---- SPEC.md | 35 ++- TASKS.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/Gtd/collectMatrix.ts | 8 +- src/Gtd/kanbanMutation.ts | 22 +- src/Gtd/matrixLabels.ts | 8 +- src/Gtd/matrixMoveHandler.ts | 131 ++++++++++ src/Gtd/matrixMutation.ts | 48 ++++ src/Gtd/moveProtocol.ts | 13 +- src/Gtd/parseMatrixConfig.ts | 22 ++ src/Gtd/types.ts | 22 +- src/event-calendar.css | 17 +- src/gtd-calendar-webview.js | 317 +++++++++++++++++++++-- src/index.ts | 35 ++- src/manifest.json | 2 +- src/tests/Gtd/matrix.test.ts | 54 +++- src/tests/Gtd/matrixMoveHandler.test.ts | 134 ++++++++++ src/tests/Gtd/matrixMutation.test.ts | 112 ++++++++ src/tests/Gtd/moveProtocol.test.ts | 14 +- src/tests/Gtd/webviewInteraction.test.ts | 33 +++ 24 files changed, 1176 insertions(+), 115 deletions(-) create mode 100644 src/Gtd/matrixMoveHandler.ts create mode 100644 src/Gtd/matrixMutation.ts create mode 100644 src/tests/Gtd/matrixMoveHandler.test.ts create mode 100644 src/tests/Gtd/matrixMutation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e44d31..caf6dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7682a87..acc36d5 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/SLIDE15.md b/SLIDE15.md index 016d53b..ee77f3a 100644 --- a/SLIDE15.md +++ b/SLIDE15.md @@ -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 1–7 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 diff --git a/SPEC.md b/SPEC.md index f592847..09133f5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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. diff --git a/TASKS.md b/TASKS.md index baec2d6..d76dea5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -206,4 +206,6 @@ Slices 8–13 are complete. SLICE14 is the next implementation slice. - [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was received on 2026-07-31. Slice 15 remains not started. - [x] SLICE14a complete. Phases 1–5 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 1–7 passed, production packaging was inspected, + and explicit manual Joplin acceptance was received on 2026-07-31. diff --git a/package-lock.json b/package-lock.json index 1e8684e..058e1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f29b94d..8fd1d4d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Gtd/collectMatrix.ts b/src/Gtd/collectMatrix.ts index 1a1f38f..4828e85 100644 --- a/src/Gtd/collectMatrix.ts +++ b/src/Gtd/collectMatrix.ts @@ -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. */ diff --git a/src/Gtd/kanbanMutation.ts b/src/Gtd/kanbanMutation.ts index 0e08167..d1c1502 100644 --- a/src/Gtd/kanbanMutation.ts +++ b/src/Gtd/kanbanMutation.ts @@ -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 }; diff --git a/src/Gtd/matrixLabels.ts b/src/Gtd/matrixLabels.ts index bf7aea2..56ca8b7 100644 --- a/src/Gtd/matrixLabels.ts +++ b/src/Gtd/matrixLabels.ts @@ -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", }, }; } diff --git a/src/Gtd/matrixMoveHandler.ts b/src/Gtd/matrixMoveHandler.ts new file mode 100644 index 0000000..7529aa3 --- /dev/null +++ b/src/Gtd/matrixMoveHandler.ts @@ -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, + config: ReturnType +): 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 { + const candidate = intent as Partial | 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."); + } +} diff --git a/src/Gtd/matrixMutation.ts b/src/Gtd/matrixMutation.ts new file mode 100644 index 0000000..506f86b --- /dev/null +++ b/src/Gtd/matrixMutation.ts @@ -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 = { + 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 { + 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 }; +} diff --git a/src/Gtd/moveProtocol.ts b/src/Gtd/moveProtocol.ts index 0c1f06b..58e0cd3 100644 --- a/src/Gtd/moveProtocol.ts +++ b/src/Gtd/moveProtocol.ts @@ -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); +} diff --git a/src/Gtd/parseMatrixConfig.ts b/src/Gtd/parseMatrixConfig.ts index 9f131d2..1e209bb 100644 --- a/src/Gtd/parseMatrixConfig.ts +++ b/src/Gtd/parseMatrixConfig.ts @@ -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, }; } diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 34f7d54..c1d78ab 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -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[]; diff --git a/src/event-calendar.css b/src/event-calendar.css index b42ef4d..5dc99ed 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -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; } diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index dbcde20..6291ad3 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -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; } diff --git a/src/index.ts b/src/index.ts index e59c98c..5bd71eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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: [ diff --git a/src/manifest.json b/src/manifest.json index 1cb96c6..631d82d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.victorwiebe.joplin.plugin.gtd-calendar", "app_min_version": "2.7", - "version": "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", diff --git a/src/tests/Gtd/matrix.test.ts b/src/tests/Gtd/matrix.test.ts index cf15ca8..8ffe498 100644 --- a/src/tests/Gtd/matrix.test.ts +++ b/src/tests/Gtd/matrix.test.ts @@ -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", diff --git a/src/tests/Gtd/matrixMoveHandler.test.ts b/src/tests/Gtd/matrixMoveHandler.test.ts new file mode 100644 index 0000000..ced1cf3 --- /dev/null +++ b/src/tests/Gtd/matrixMoveHandler.test.ts @@ -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 { + 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 = {}) { + 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([]); + }); +}); diff --git a/src/tests/Gtd/matrixMutation.test.ts b/src/tests/Gtd/matrixMutation.test.ts new file mode 100644 index 0000000..8e2b495 --- /dev/null +++ b/src/tests/Gtd/matrixMutation.test.ts @@ -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([]); + }); +}); diff --git a/src/tests/Gtd/moveProtocol.test.ts b/src/tests/Gtd/moveProtocol.test.ts index 3ab588d..8f943c5 100644 --- a/src/tests/Gtd/moveProtocol.test.ts +++ b/src/tests/Gtd/moveProtocol.test.ts @@ -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) + ); +}); diff --git a/src/tests/Gtd/webviewInteraction.test.ts b/src/tests/Gtd/webviewInteraction.test.ts index a1fe0a5..eff2c54 100644 --- a/src/tests/Gtd/webviewInteraction.test.ts +++ b/src/tests/Gtd/webviewInteraction.test.ts @@ -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")'); + }); +});