Release v2.0.0 with editable Eisenhower matrices
This commit is contained in:
parent
f4971e1f7b
commit
ca0518fbff
22
CHANGELOG.md
22
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,
|
- Automated validation: 295 tests across 19 suites, TypeScript, webview syntax,
|
||||||
whitespace, prohibited-reference checks, and production JPL inspection passed.
|
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
|
## 1.0.0 — 2026-07-28
|
||||||
|
|
||||||
### Card underline cleanup
|
### Card underline cleanup
|
||||||
|
|||||||
21
README.md
21
README.md
@ -245,7 +245,8 @@ A third view: a 2×2 prioritisation matrix for incomplete to-dos and ordinary no
|
|||||||
```gtd-matrix
|
```gtd-matrix
|
||||||
title: Priorities
|
title: Priorities
|
||||||
todos: all
|
todos: all
|
||||||
mode: skeleton
|
mode: eisenhower
|
||||||
|
editable: yes
|
||||||
page-size: 8
|
page-size: 8
|
||||||
done-tag: done
|
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.
|
**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
|
### Options
|
||||||
|
|
||||||
| Option | Values | Default | Description |
|
| Option | Values | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
||||||
|
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves for Eisenhower matrices on desktop. Skeleton remains read-only; invalid or ambiguous configurations warn and disable editing. |
|
||||||
| `title` | text | — | Heading above the matrix. |
|
| `title` | text | — | Heading above the matrix. |
|
||||||
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
|
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
|
||||||
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
||||||
@ -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.
|
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 📄
|
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
|
||||||
when no icon is set, and never show completion or recurrence styling. Empty and
|
when no icon is set, and never show completion or recurrence styling. Empty and
|
||||||
malformed blocks follow the same opt-in and warning behavior as on kanban.
|
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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # 298 unit tests
|
npm test # 374 unit tests
|
||||||
npm run dist # builds publish/*.jpl
|
npm run dist # builds publish/*.jpl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
211
SLIDE15.md
211
SLIDE15.md
@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**PLANNED; BLOCKED ON SLICE 14.** Begin only after the shared mutation service,
|
**COMPLETE.** Phases 1–7 are complete. Focused and full automation passed, the
|
||||||
interaction model, automated validation, and manual kanban acceptance in
|
production JPL was built and inspected, and the user confirmed the manual Joplin
|
||||||
SLICE 14 are complete.
|
acceptance tests passed on 2026-07-31.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@ -149,89 +149,200 @@ drag, make no write, report stale state, and refresh the matrix.
|
|||||||
|
|
||||||
### Phase 1 — Label change and compatibility tests
|
### Phase 1 — Label change and compatibility tests
|
||||||
|
|
||||||
- [ ] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
|
- [x] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
|
||||||
- [ ] Update comments and remove/update fallback literals that contain Do First,
|
- [x] Update comments and remove/update fallback literals that contain Do First,
|
||||||
Schedule, Delegate, or Eliminate.
|
Schedule, Delegate, or Eliminate.
|
||||||
- [ ] Update unit and rendering tests for single and grouped layouts.
|
- [x] Update unit and rendering tests for single and grouped layouts.
|
||||||
- [ ] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
|
- [x] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
|
||||||
- [ ] Confirm the label-only change applies when `editable` is omitted.
|
- [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
|
### Phase 2 — Matrix configuration and protocol
|
||||||
|
|
||||||
- [ ] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
|
- [x] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
|
||||||
- [ ] Parse `editable: yes | no` using the SLICE 14 validation contract.
|
- [x] Parse `editable: yes | no` using the SLICE 14 validation contract.
|
||||||
- [ ] Warn and disable editing for `mode: skeleton`.
|
- [x] Warn and disable editing for `mode: skeleton`.
|
||||||
- [ ] Warn and disable editable Eisenhower when normalized axis tags are equal.
|
- [x] Warn and disable editable Eisenhower when normalized axis tags are equal.
|
||||||
- [ ] Define constrained matrix move intents and fixed destinations using the
|
- [x] Define constrained matrix move intents and fixed destinations using the
|
||||||
shared host/view identity and typed result model.
|
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.
|
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
|
### 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.
|
primitives from SLICE 14.
|
||||||
- [ ] Calculate minimal, idempotent tag membership changes from fresh data.
|
- [x] Calculate minimal, idempotent tag membership changes from fresh data.
|
||||||
- [ ] Preserve completion, progress, dates, body, note type, notebook, and
|
- [x] Preserve completion, progress, dates, body, note type, notebook, and
|
||||||
unrelated tags.
|
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.
|
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
|
### Phase 4 — Message validation and refresh
|
||||||
|
|
||||||
- [ ] Add the matrix move message to the main-process handler.
|
- [x] Add the matrix move message to the main-process handler.
|
||||||
- [ ] Reparse and validate editable Eisenhower configuration and fresh eligibility.
|
- [x] Reparse and validate editable Eisenhower configuration and fresh eligibility.
|
||||||
- [ ] Reject arbitrary destinations, tag operations, fields, modes, host notes,
|
- [x] Reject arbitrary destinations, tag operations, fields, modes, host notes,
|
||||||
out-of-scope items, cross-group moves, and stale completions.
|
out-of-scope items, cross-group moves, and stale completions.
|
||||||
- [ ] Return typed results and refresh only the initiating matrix instance.
|
- [x] Return typed results and refresh only the initiating matrix instance.
|
||||||
- [ ] Guard against older responses overwriting newer renders.
|
- [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
|
### Phase 5 — Pointer and keyboard interaction
|
||||||
|
|
||||||
- [ ] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
|
- [x] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
|
||||||
- [ ] Constrain active targets to the same matrix instance and notebook group.
|
- [x] Constrain active targets to the same matrix instance and notebook group.
|
||||||
- [ ] Add new-label drag feedback, pending/error states, and cancellation.
|
- [x] Add new-label drag feedback, pending/error states, and cancellation.
|
||||||
- [ ] Add keyboard **Move to…** options for the other three quadrants.
|
- [x] Add keyboard **Move to…** options for the other three quadrants.
|
||||||
- [ ] Preserve click-to-open, hover details, pagination, scrolling, focus, and
|
- [x] Preserve click-to-open, hover details, pagination, scrolling, focus, and
|
||||||
screen-reader announcements.
|
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
|
### 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.
|
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.
|
mutation safety, accessibility, and refresh behavior.
|
||||||
- [ ] Update the unreleased v2.0.0 CHANGELOG entry.
|
- [x] Update the unreleased v2.0.0 CHANGELOG entry.
|
||||||
- [ ] Decide whether Skeleton remains documented as a supported alternative or
|
- [x] Decide whether Skeleton remains documented as a supported alternative or
|
||||||
is marked as legacy/non-editable; do not remove it in this slice.
|
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.
|
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.
|
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
|
### Phase 7 — Manual Joplin acceptance
|
||||||
|
|
||||||
- [ ] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
|
- [x] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
|
||||||
- [ ] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
|
- [x] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
|
||||||
Eliminate, or “Drop this.”
|
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.
|
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.
|
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.
|
quadrants without changing native completion or progress state.
|
||||||
- [ ] Custom distinct axis tags work; equal axis tags warn and disable editing.
|
- [x] Custom distinct axis tags work; equal axis tags warn and disable editing.
|
||||||
- [ ] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
|
- [x] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
|
||||||
- [ ] A normal click opens the note; a drag does not accidentally open it.
|
- [x] A normal click opens the note; a drag does not accidentally open it.
|
||||||
- [ ] Keyboard-only **Move to…** reaches every quadrant and restores focus.
|
- [x] Keyboard-only **Move to…** reaches every quadrant and restores focus.
|
||||||
- [ ] Stale completion, filtering, scope, group, and API-failure cases converge
|
- [x] Stale completion, filtering, scope, group, and API-failure cases converge
|
||||||
to canonical state without unrelated data changes.
|
to canonical state without unrelated data changes.
|
||||||
- [ ] Single, grouped, multiple-block, scrolling, and paginated matrices work.
|
- [x] Single, grouped, multiple-block, scrolling, and paginated matrices work.
|
||||||
- [ ] Skeleton matrices remain correct and read-only.
|
- [x] Skeleton matrices remain correct and read-only.
|
||||||
- [ ] Record explicit user sign-off separately from automated checks.
|
- [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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
35
SPEC.md
35
SPEC.md
@ -134,6 +134,7 @@ to-do uses native completion and may activate the Repeating To-Dos plugin.
|
|||||||
```
|
```
|
||||||
```gtd-matrix
|
```gtd-matrix
|
||||||
mode: skeleton # skeleton | eisenhower (default: skeleton)
|
mode: skeleton # skeleton | eisenhower (default: skeleton)
|
||||||
|
editable: no # yes | no; Eisenhower moves (default: no)
|
||||||
title: Priorities
|
title: Priorities
|
||||||
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
|
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)
|
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: 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)
|
### 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
|
are announced through an atomic live region; focus returns to the moved card
|
||||||
or status fallback after canonical rerender.
|
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.
|
- **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.
|
- **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.
|
- **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
|
│ │ calendar / board / matrix / Gantt DOM from
|
||||||
│ │ returned payload. Click → postMessage
|
│ │ returned payload. Click → postMessage
|
||||||
│ │ ({type: "openNote", noteId}); editable
|
│ │ ({type: "openNote", noteId}); editable
|
||||||
│ │ kanban sends constrained move intent only.
|
│ │ kanban/matrix send constrained move intent.
|
||||||
└──────────────┬──────────────┘
|
└──────────────┬──────────────┘
|
||||||
│ postMessage / onMessage
|
│ postMessage / onMessage
|
||||||
┌──────────────▼──────────────┐
|
┌──────────────▼──────────────┐
|
||||||
@ -323,7 +340,7 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
|||||||
│ │ collectGantt.ts)
|
│ │ collectGantt.ts)
|
||||||
│ │ • handles openNote via
|
│ │ • handles openNote via
|
||||||
│ │ joplin.commands.execute
|
│ │ joplin.commands.execute
|
||||||
│ │ • reparses editable kanban config, refetches
|
│ │ • reparses editable view config, refetches
|
||||||
│ │ host/card/scope/tags, derives minimal writes,
|
│ │ host/card/scope/tags, derives minimal writes,
|
||||||
│ │ then returns a typed move result
|
│ │ 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
|
revisiting if a `children`-scope board on a large tree feels slow, especially
|
||||||
with Persistent Layout keeping several boards rendered at once.
|
with Persistent Layout keeping several boards rendered at once.
|
||||||
- Refresh model: normal background edits appear when Joplin rerenders the note.
|
- 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
|
`viewInstanceId`; request sequencing rejects stale responses. Other open
|
||||||
dashboards remain stale until their own rerender.
|
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.
|
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`):
|
- **Quadrants** from two configurable tags (`urgent-tag:` default `urgent`; `important-tag:` default `important`):
|
||||||
- urgent + important → **Do First**
|
- urgent + important → **Do Next**
|
||||||
- important only → **Schedule**
|
- important only → **Scheduled**
|
||||||
- urgent only → **Delegate**
|
- urgent only → **On Deck**
|
||||||
- neither → **Eliminate** (the default bucket for untagged to-dos — a deliberately pointed default)
|
- 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.
|
- **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.
|
- 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.
|
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
|
||||||
|
|||||||
4
TASKS.md
4
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
|
- [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was
|
||||||
received on 2026-07-31. Slice 15 remains not started.
|
received on 2026-07-31. Slice 15 remains not started.
|
||||||
- [x] SLICE14a complete. Phases 1–5 passed and explicit manual acceptance was
|
- [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.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"predist": "bash scripts/archive-jpl.sh",
|
"predist": "bash scripts/archive-jpl.sh",
|
||||||
|
|||||||
@ -28,10 +28,10 @@ export interface MatrixResult {
|
|||||||
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
|
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
|
||||||
* prioritisation view; completions live in the kanban's Done column.
|
* prioritisation view; completions live in the kanban's Done column.
|
||||||
* - Quadrants from the two axis tags:
|
* - Quadrants from the two axis tags:
|
||||||
* urgent + important -> Do First
|
* urgent + important -> Do Next
|
||||||
* important only -> Schedule
|
* important only -> Scheduled
|
||||||
* urgent only -> Delegate
|
* urgent only -> On Deck
|
||||||
* neither -> Eliminate (default bucket for untagged)
|
* neither -> Backlog (default bucket for untagged)
|
||||||
*
|
*
|
||||||
* The matrix note itself is always excluded.
|
* The matrix note itself is always excluded.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function matchingTags(tags: RawTag[], title: string): RawTag[] {
|
|||||||
.sort((a, b) => a.id.localeCompare(b.id));
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTag(
|
export async function ensureNoteTag(
|
||||||
adapter: MutationAdapter,
|
adapter: MutationAdapter,
|
||||||
noteId: string,
|
noteId: string,
|
||||||
title: string,
|
title: string,
|
||||||
@ -38,7 +38,7 @@ async function ensureTag(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTag(
|
export async function removeNoteTag(
|
||||||
adapter: MutationAdapter,
|
adapter: MutationAdapter,
|
||||||
noteId: string,
|
noteId: string,
|
||||||
title: string,
|
title: string,
|
||||||
@ -78,7 +78,7 @@ export async function moveKanbanItem(
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
changed =
|
changed =
|
||||||
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
changed;
|
changed;
|
||||||
} else {
|
} else {
|
||||||
if (note.todo_completed > 0) {
|
if (note.todo_completed > 0) {
|
||||||
@ -87,26 +87,26 @@ export async function moveKanbanItem(
|
|||||||
}
|
}
|
||||||
if (destination === "inProgress") {
|
if (destination === "inProgress") {
|
||||||
changed =
|
changed =
|
||||||
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
changed;
|
changed;
|
||||||
} else {
|
} else {
|
||||||
changed =
|
changed =
|
||||||
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
changed;
|
changed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (destination === "done") {
|
} else if (destination === "done") {
|
||||||
changed = (await ensureTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
changed = (await ensureNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
changed =
|
changed =
|
||||||
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
} else if (destination === "inProgress") {
|
} else if (destination === "inProgress") {
|
||||||
changed =
|
changed =
|
||||||
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
} else {
|
} else {
|
||||||
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
changed =
|
changed =
|
||||||
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { note, changed };
|
return { note, changed };
|
||||||
|
|||||||
@ -7,10 +7,10 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels {
|
|||||||
columns: ["Urgent", "Not urgent"],
|
columns: ["Urgent", "Not urgent"],
|
||||||
rows: ["Important", "Not important"],
|
rows: ["Important", "Not important"],
|
||||||
quadrants: {
|
quadrants: {
|
||||||
topLeft: "Do First",
|
topLeft: "Do Next",
|
||||||
topRight: "Schedule",
|
topRight: "Scheduled",
|
||||||
bottomLeft: "Delegate",
|
bottomLeft: "On Deck",
|
||||||
bottomRight: "Eliminate",
|
bottomRight: "Backlog",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/Gtd/matrixMoveHandler.ts
Normal file
131
src/Gtd/matrixMoveHandler.ts
Normal 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
48
src/Gtd/matrixMutation.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { KanbanDestination } from "./types";
|
import { KanbanDestination, MatrixDestination } from "./types";
|
||||||
|
|
||||||
const KANBAN_DESTINATIONS: KanbanDestination[] = [
|
const KANBAN_DESTINATIONS: KanbanDestination[] = [
|
||||||
"backlog",
|
"backlog",
|
||||||
@ -10,3 +10,14 @@ const KANBAN_DESTINATIONS: KanbanDestination[] = [
|
|||||||
export function isKanbanDestination(value: unknown): value is KanbanDestination {
|
export function isKanbanDestination(value: unknown): value is KanbanDestination {
|
||||||
return KANBAN_DESTINATIONS.includes(value as 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);
|
||||||
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
"urgent-window",
|
"urgent-window",
|
||||||
"card-detail",
|
"card-detail",
|
||||||
"page-size",
|
"page-size",
|
||||||
|
"editable",
|
||||||
];
|
];
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
|
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);
|
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 {
|
return {
|
||||||
mode,
|
mode,
|
||||||
title,
|
title,
|
||||||
@ -212,6 +233,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
urgentWindow,
|
urgentWindow,
|
||||||
cardDetail,
|
cardDetail,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
editable,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -260,16 +260,34 @@ export interface MatrixConfig {
|
|||||||
cardDetail: "hover" | "always" | "none";
|
cardDetail: "hover" | "always" | "none";
|
||||||
/** Cards initially shown, and revealed per "List more" action. */
|
/** Cards initially shown, and revealed per "List more" action. */
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
/** Effective editable state; only valid for distinct-axis Eisenhower mode. */
|
||||||
|
editable: boolean;
|
||||||
warnings: string[];
|
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):
|
* The four matrix quadrants, positional (row x column):
|
||||||
* topLeft topRight
|
* topLeft topRight
|
||||||
* bottomLeft bottomRight
|
* bottomLeft bottomRight
|
||||||
*
|
*
|
||||||
* Eisenhower: Do First / Schedule / Delegate / Eliminate.
|
* Both modes display Do Next / Scheduled / On Deck / Backlog; their axes and
|
||||||
* Skeleton: Do Next / Scheduled / On Deck / Backlog.
|
* bucketing semantics remain mode-specific.
|
||||||
*/
|
*/
|
||||||
export interface MatrixBoard {
|
export interface MatrixBoard {
|
||||||
topLeft: KanbanCard[];
|
topLeft: KanbanCard[];
|
||||||
|
|||||||
@ -504,13 +504,26 @@
|
|||||||
background-color: rgba(50, 120, 220, 0.12);
|
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-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;
|
pointer-events: none;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gtd-kanban-move-status {
|
.gtd-kanban-move-status,
|
||||||
|
.gtd-matrix-move-status {
|
||||||
margin: 0.25em 0;
|
margin: 0.25em 0;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,7 +74,7 @@
|
|||||||
if (blockType === "kanban") {
|
if (blockType === "kanban") {
|
||||||
renderKanban(el, payload, contentScriptId, renderContext);
|
renderKanban(el, payload, contentScriptId, renderContext);
|
||||||
} else if (blockType === "matrix") {
|
} else if (blockType === "matrix") {
|
||||||
renderMatrix(el, payload, contentScriptId);
|
renderMatrix(el, payload, contentScriptId, renderContext);
|
||||||
} else if (blockType === "gantt") {
|
} else if (blockType === "gantt") {
|
||||||
renderGantt(el, payload, contentScriptId);
|
renderGantt(el, payload, contentScriptId);
|
||||||
} else {
|
} else {
|
||||||
@ -522,6 +522,7 @@
|
|||||||
const pageSize = payload.pageSize || 10;
|
const pageSize = payload.pageSize || 10;
|
||||||
const interaction = payload.editable && payload.hostNoteId && renderContext
|
const interaction = payload.editable && payload.hostNoteId && renderContext
|
||||||
? {
|
? {
|
||||||
|
view: "kanban",
|
||||||
context: renderContext,
|
context: renderContext,
|
||||||
hostNoteId: payload.hostNoteId,
|
hostNoteId: payload.hostNoteId,
|
||||||
root: el,
|
root: el,
|
||||||
@ -736,7 +737,7 @@
|
|||||||
handle.addEventListener("click", function (event) {
|
handle.addEventListener("click", function (event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
});
|
});
|
||||||
installKanbanPointerDrag(
|
(interaction.view === "matrix" ? installMatrixPointerDrag : installKanbanPointerDrag)(
|
||||||
handle,
|
handle,
|
||||||
el,
|
el,
|
||||||
card,
|
card,
|
||||||
@ -745,7 +746,7 @@
|
|||||||
function () { suppressOpenUntil = Date.now() + 500; }
|
function () { suppressOpenUntil = Date.now() + 500; }
|
||||||
);
|
);
|
||||||
controls.appendChild(handle);
|
controls.appendChild(handle);
|
||||||
const disclosure = renderKanbanMoveMenu(
|
const disclosure = (interaction.view === "matrix" ? renderMatrixMoveMenu : renderKanbanMoveMenu)(
|
||||||
card,
|
card,
|
||||||
sourceDestination,
|
sourceDestination,
|
||||||
interaction,
|
interaction,
|
||||||
@ -917,6 +918,99 @@
|
|||||||
return disclosure;
|
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) {
|
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
|
||||||
let state = null;
|
let state = null;
|
||||||
const threshold = 6;
|
const threshold = 6;
|
||||||
@ -990,6 +1084,68 @@
|
|||||||
handle.addEventListener("lostpointercapture", cancel);
|
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) {
|
function submitKanbanMove(card, destination, interaction) {
|
||||||
const context = interaction.context;
|
const context = interaction.context;
|
||||||
if (context.pending) return;
|
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) {
|
function formatCardDate(dateISO) {
|
||||||
const date = isoToLocalDate(dateISO);
|
const date = isoToLocalDate(dateISO);
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
@ -1072,7 +1298,11 @@
|
|||||||
// Eisenhower matrix (gtd-matrix block)
|
// 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 = "";
|
el.innerHTML = "";
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@ -1113,14 +1343,35 @@
|
|||||||
columns: ["Urgent", "Not urgent"],
|
columns: ["Urgent", "Not urgent"],
|
||||||
rows: ["Important", "Not important"],
|
rows: ["Important", "Not important"],
|
||||||
quadrants: {
|
quadrants: {
|
||||||
topLeft: "Do First",
|
topLeft: "Do Next",
|
||||||
topRight: "Schedule",
|
topRight: "Scheduled",
|
||||||
bottomLeft: "Delegate",
|
bottomLeft: "On Deck",
|
||||||
bottomRight: "Eliminate",
|
bottomRight: "Backlog",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const detail = payload.cardDetail || "hover";
|
const detail = payload.cardDetail || "hover";
|
||||||
const pageSize = payload.pageSize || 10;
|
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) {
|
if (layout.kind === "notebooks" && layout.groups.length > 0) {
|
||||||
layout.groups.forEach(function (group) {
|
layout.groups.forEach(function (group) {
|
||||||
@ -1130,7 +1381,8 @@
|
|||||||
labels,
|
labels,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -1142,7 +1394,8 @@
|
|||||||
labels,
|
labels,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1162,6 +1415,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.appendChild(wrapper);
|
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(
|
function renderNotebookMatrixGroup(
|
||||||
@ -1169,7 +1431,8 @@
|
|||||||
labels,
|
labels,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction
|
||||||
) {
|
) {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "gtd-notebook-view";
|
section.className = "gtd-notebook-view";
|
||||||
@ -1184,7 +1447,8 @@
|
|||||||
labels,
|
labels,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return section;
|
return section;
|
||||||
@ -1195,11 +1459,15 @@
|
|||||||
labels,
|
labels,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction
|
||||||
) {
|
) {
|
||||||
// Axis header row: blank corner + column labels
|
// Axis header row: blank corner + column labels
|
||||||
const grid = document.createElement("div");
|
const grid = document.createElement("div");
|
||||||
grid.className = "gtd-matrix-grid";
|
grid.className = "gtd-matrix-grid";
|
||||||
|
const boardInteraction = interaction
|
||||||
|
? Object.assign({}, interaction, { board: grid })
|
||||||
|
: null;
|
||||||
|
|
||||||
grid.appendChild(matrixAxisCell(""));
|
grid.appendChild(matrixAxisCell(""));
|
||||||
grid.appendChild(matrixAxisCell(labels.columns[0]));
|
grid.appendChild(matrixAxisCell(labels.columns[0]));
|
||||||
@ -1213,7 +1481,9 @@
|
|||||||
"do-first",
|
"do-first",
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
boardInteraction,
|
||||||
|
"topLeft"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
grid.appendChild(
|
grid.appendChild(
|
||||||
@ -1223,7 +1493,9 @@
|
|||||||
"schedule",
|
"schedule",
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
boardInteraction,
|
||||||
|
"topRight"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1235,7 +1507,9 @@
|
|||||||
"delegate",
|
"delegate",
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
boardInteraction,
|
||||||
|
"bottomLeft"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
grid.appendChild(
|
grid.appendChild(
|
||||||
@ -1245,7 +1519,9 @@
|
|||||||
"eliminate",
|
"eliminate",
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
boardInteraction,
|
||||||
|
"bottomRight"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1274,10 +1550,13 @@
|
|||||||
kind,
|
kind,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
interaction,
|
||||||
|
destination
|
||||||
) {
|
) {
|
||||||
const quadrant = document.createElement("div");
|
const quadrant = document.createElement("div");
|
||||||
quadrant.className = "gtd-matrix-quadrant gtd-matrix-" + kind;
|
quadrant.className = "gtd-matrix-quadrant gtd-matrix-" + kind;
|
||||||
|
quadrant.setAttribute("data-matrix-destination", destination);
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "gtd-kanban-column-header";
|
header.className = "gtd-kanban-column-header";
|
||||||
@ -1285,7 +1564,7 @@
|
|||||||
quadrant.appendChild(header);
|
quadrant.appendChild(header);
|
||||||
|
|
||||||
quadrant.appendChild(
|
quadrant.appendChild(
|
||||||
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
|
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
|
||||||
);
|
);
|
||||||
return quadrant;
|
return quadrant;
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/index.ts
35
src/index.ts
@ -14,6 +14,7 @@ import parseGanttConfig from "./Gtd/parseGanttConfig";
|
|||||||
import collectGantt from "./Gtd/collectGantt";
|
import collectGantt from "./Gtd/collectGantt";
|
||||||
import matrixLabels from "./Gtd/matrixLabels";
|
import matrixLabels from "./Gtd/matrixLabels";
|
||||||
import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler";
|
import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler";
|
||||||
|
import { validateAndMoveMatrixCard } from "./Gtd/matrixMoveHandler";
|
||||||
import resolveNotebook from "./Gtd/resolveNotebook";
|
import resolveNotebook from "./Gtd/resolveNotebook";
|
||||||
import {
|
import {
|
||||||
emptyKanbanLayout,
|
emptyKanbanLayout,
|
||||||
@ -186,6 +187,8 @@ joplin.plugins.register({
|
|||||||
return handleMoveKanbanCard(message);
|
return handleMoveKanbanCard(message);
|
||||||
case "getMatrix":
|
case "getMatrix":
|
||||||
return handleGetMatrix(message);
|
return handleGetMatrix(message);
|
||||||
|
case "moveMatrixCard":
|
||||||
|
return handleMoveMatrixCard(message);
|
||||||
case "getGantt":
|
case "getGantt":
|
||||||
return handleGetGantt(message);
|
return handleGetGantt(message);
|
||||||
case "openNote":
|
case "openNote":
|
||||||
@ -220,6 +223,27 @@ async function handleMoveKanbanCard(message: unknown) {
|
|||||||
return result;
|
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 }) {
|
async function handleGetEvents(message: { rawConfig?: string }) {
|
||||||
// 1. Parse the gtd-calendar config.
|
// 1. Parse the gtd-calendar config.
|
||||||
let rawParsed: any = {};
|
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 rawParsed: any = {};
|
||||||
let configError: string | null = null;
|
let configError: string | null = null;
|
||||||
try {
|
try {
|
||||||
@ -402,6 +429,9 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
|
|||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
|
editable: config.editable,
|
||||||
|
hostNoteId: null,
|
||||||
|
viewInstanceId: message.viewInstanceId || null,
|
||||||
scopeAll: config.scopeAll,
|
scopeAll: config.scopeAll,
|
||||||
configError:
|
configError:
|
||||||
configError || "Could not determine which note contains this matrix.",
|
configError || "Could not determine which note contains this matrix.",
|
||||||
@ -426,6 +456,9 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
|
|||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
|
editable: config.editable,
|
||||||
|
hostNoteId: sourceNote.id,
|
||||||
|
viewInstanceId: message.viewInstanceId || null,
|
||||||
scopeAll: config.scopeAll,
|
scopeAll: config.scopeAll,
|
||||||
configError,
|
configError,
|
||||||
warnings: [
|
warnings: [
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
||||||
"app_min_version": "2.7",
|
"app_min_version": "2.7",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"name": "GTD Calendar",
|
"name": "GTD Calendar",
|
||||||
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
||||||
"author": "Victor Wiebe",
|
"author": "Victor Wiebe",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
||||||
import collectMatrix from "../../Gtd/collectMatrix";
|
import collectMatrix from "../../Gtd/collectMatrix";
|
||||||
|
import matrixLabels from "../../Gtd/matrixLabels";
|
||||||
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -7,6 +8,28 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("parseMatrixConfig", () => {
|
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", () => {
|
test("applies SPEC defaults", () => {
|
||||||
const c = parseMatrixConfig({});
|
const c = parseMatrixConfig({});
|
||||||
expect(c).toMatchObject({
|
expect(c).toMatchObject({
|
||||||
@ -24,10 +47,39 @@ describe("parseMatrixConfig", () => {
|
|||||||
urgentWindow: 3,
|
urgentWindow: 3,
|
||||||
cardDetail: "hover",
|
cardDetail: "hover",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
editable: false,
|
||||||
});
|
});
|
||||||
expect(c.warnings).toHaveLength(0);
|
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", () => {
|
test("parses notes independently and rejects invalid or singular forms", () => {
|
||||||
expect(parseMatrixConfig({ notes: " NONE " }).notes).toBe("none");
|
expect(parseMatrixConfig({ notes: " NONE " }).notes).toBe("none");
|
||||||
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
||||||
@ -146,7 +198,7 @@ function makeAdapter(
|
|||||||
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
|
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
|
||||||
|
|
||||||
describe("collectMatrix — quadrant bucketing", () => {
|
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 = [
|
const notes = [
|
||||||
makeNote({
|
makeNote({
|
||||||
id: "both",
|
id: "both",
|
||||||
|
|||||||
134
src/tests/Gtd/matrixMoveHandler.test.ts
Normal file
134
src/tests/Gtd/matrixMoveHandler.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/tests/Gtd/matrixMutation.test.ts
Normal file
112
src/tests/Gtd/matrixMutation.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { isKanbanDestination } from "../../Gtd/moveProtocol";
|
import { isKanbanDestination, isMatrixDestination } from "../../Gtd/moveProtocol";
|
||||||
|
|
||||||
describe("kanban move protocol", () => {
|
describe("kanban move protocol", () => {
|
||||||
test.each(["backlog", "inProgress", "done"])(
|
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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -37,3 +37,36 @@ describe("Slice 14a compact move-menu rendering contract", () => {
|
|||||||
expect(css).not.toContain(".gtd-kanban-move-select");
|
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")');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user