SLICE14 opt-in kanban drag and drop

This commit is contained in:
Victor Wiebe 2026-07-31 14:09:40 -04:00
parent 7a02834bec
commit c89b90839f
19 changed files with 1974 additions and 45 deletions

View File

@ -2,6 +2,29 @@
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases. All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
## 2.0.0 — Unreleased
### Opt-in editable Kanban
- Added `editable: yes | no` to `gtd-kanban`, defaulting to `no` so every
existing board remains read-only.
- Editable desktop boards support pointer drag handles and keyboard **Move to…**
controls for Backlog, In Progress, and Done.
- Ordinary-note moves persist configured completion/progress tags; native to-do
moves persist native completion plus the configured progress tag. Unrelated
fields, tags, content, dates, note type, and notebook ownership are preserved.
- The webview submits constrained semantic intent. The main plugin process
reparses configuration, refetches host/scope/item/tag state, derives minimal
writes, rejects stale or malformed requests, and refreshes only the initiating
view from canonical state.
- Added pending/error feedback, pointer cancellation and movement thresholds,
duplicate-request guards, request sequencing, live announcements, and focus
restoration. Sorting and pagination remain authoritative after a move.
- Pointer and keyboard workflows still require final manual Joplin acceptance;
v2.0.0 has not been released or published.
- Automated validation: 295 tests across 19 suites, TypeScript, webview syntax,
whitespace, prohibited-reference checks, and production JPL inspection passed.
## 1.0.0 — 2026-07-28 ## 1.0.0 — 2026-07-28
### Card underline cleanup ### Card underline cleanup

142
DRAG_DROP.md Normal file
View File

@ -0,0 +1,142 @@
# Drag-and-drop v2.0.0 — next-session handoff
Use this document as the opening prompt and state record for the next development
session. The planning source of truth is `SLICE14.md`, followed by `SLIDE15.md`.
## Current repository state
- v1.0.0 is released and published to npm.
- Release commit: `7a02834 Release-v1.0.0` on `master`.
- At the time this handoff was written, the repository had no pre-existing
uncommitted changes before these three planning files were added.
- The v1 production artifact is
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`.
- v1 automated validation recorded 251 Jest tests across 16 suites, plus
TypeScript, webview syntax, build, package inspection, whitespace, and
prohibited-reference checks.
- The user manually accepted the v1 kanban/matrix behavior and card styling.
- Do not describe old v1 validation as proof that new v2 mutation behavior works.
## Confirmed v2 decisions
- Drag and drop is technically feasible because cards are DOM elements in the
Joplin desktop rendered-note webview, not an HTML canvas.
- v2.0.0 begins with writable workflow views because persisted mutation,
concurrency, recovery, and accessibility are a material behavior change.
- Every existing block remains read-only by default.
- Mutation requires the exact opt-in `editable: yes`.
- Pointer drag and keyboard **Move to…** are both required.
- The webview sends constrained semantic intent; only the main plugin process
fetches fresh state and derives Joplin data/tag mutations.
- Kanban moves persist note workflow tags or native to-do completion according
to `SLICE14.md`.
- Eisenhower moves persist urgent/important tag combinations according to
`SLIDE15.md`.
- Completion wins over in-progress in normal collection. An intentional kanban
move canonicalizes conflicting workflow tags.
- Eisenhower keeps urgent/important axes but adopts Skeleton quadrant names:
Do Next, Scheduled, On Deck, and Backlog. “Eliminate” must disappear.
- Skeleton remains supported and read-only for v2.0.0; do not remove it.
- No free-form card ordering or cross-notebook dragging is included.
## Required order of work
1. Read every repository `*.md` file before implementation, with special
attention to `SPEC.md`, `TASKS.md`, `SLICE11.md`, `SLICE12.md`, `SLICE13.md`,
`SLICE14.md`, and `SLIDE15.md`.
2. Inspect `git status --short`, the latest commit, all package/manifest versions,
and the current test scripts. Preserve unrelated user changes.
3. Execute `SLICE14.md` phase by phase. Update its checkboxes and Status after
each completed phase and whenever pausing.
4. Keep implementation, focused automation, full automation, JPL packaging, and
manual Joplin acceptance as separate boundaries.
5. Stop for the user's explicit SLICE 14 manual Joplin sign-off before marking
its manual phase complete or beginning SLICE 15, unless the user explicitly
asks to continue without that checkpoint.
6. Execute `SLIDE15.md` only after the SLICE 14 foundation is accepted. Update it
with the same state discipline.
7. After both slices pass automation and explicit manual acceptance, complete
consolidated v2.0.0 documentation/version/release preparation. Do not publish
or push unless the user asks.
## Opening prompt for the next session
Copy the following request into the next session:
> Please read all Markdown files in the repository, then use `DRAG_DROP.md` as
> the handoff and begin `SLICE14.md` Phase 1. Work phase by phase, update the
> slice file after every completed phase and whenever you pause, and keep
> implementation, automated validation, packaging, and manual Joplin acceptance
> separate. Do not begin `SLIDE15.md` until SLICE 14 has passed its required
> acceptance boundary.
## Baseline files to inspect before editing
- `src/index.ts` — content-script message registration and Joplin data adapter.
- `src/Gtd/types.ts` — configs, cards, boards, layouts, and adapter types.
- `src/Gtd/parseKanbanConfig.ts` and `src/Gtd/parseMatrixConfig.ts` — option
parsing and warnings.
- `src/Gtd/collectKanban.ts` and `src/Gtd/collectMatrix.ts` — eligibility,
completion, tags, grouping, sorting, warnings, and counts.
- `src/Gtd/buildKanbanCard.ts` — shared note/to-do card normalization.
- `src/Gtd/matrixLabels.ts` — canonical matrix labels.
- `src/gtd-calendar-webview.js` — DOM rendering, click handling, pagination, and
the future pointer/keyboard interaction layer.
- `src/event-calendar.css` — shared kanban/matrix card and quadrant styling.
- `src/tests/Gtd/kanban.test.ts`, `matrix.test.ts`, parser tests, grouping tests,
field-hint tests, and webview/render checks.
## Implementation cautions
- Current collection uses tag titles only. Writable behavior needs stable tag
identities and narrowly scoped mutation operations without weakening the
read-only collector abstraction.
- Do not trust `isTodo`, tag names, source quadrant, scope, or desired field
patches supplied by the webview. Reparse config and refetch the item/tags.
- Joplin tag and note writes are separate calls. Prefer minimal idempotent writes
and canonical refetch over a stale compensating rollback.
- Multiple blocks can exist in one rendered note. Every request and response
must target one stable view instance and reject stale async responses.
- A click must continue opening a card. Use a pointer movement threshold and an
explicit drag affordance so click and drag are not confused.
- Pagination is derived after sorting. A moved card may legitimately disappear
beyond the visible batch after refresh.
- Grouped views use exact notebook ownership. Cross-group movement is out of
scope and must not accidentally patch `parent_id`.
- Completing a recurring to-do can activate the Repeating To-Dos plugin. Always
refresh canonical state and manually test the integration.
- Matrix completion remains excluded. If an item becomes complete while a move
is pending, reject the stale move and refresh.
- Equal normalized Eisenhower axis tags cannot represent four states. Editable
mode must warn and disable mutation for that block.
## Expected verification and reporting
For each slice, report these separately:
- implemented source/documentation changes;
- focused automated checks and exact counts;
- full automated checks and exact counts;
- JPL build and archive inspection;
- manual Joplin tests still pending;
- explicit user acceptance when received;
- Git commit/push/publish state.
Do not mark a manual checklist item complete from unit tests or code inspection.
Do not mark the slice complete while required manual acceptance remains pending.
## v2.0.0 release boundary
After SLICE 14 and SLICE 15 are accepted:
- update README.md and SPEC.md to describe opt-in editing and both keyboard and
pointer workflows;
- ensure no current documentation still calls all kanban/matrix cards read-only;
- record the Eisenhower label migration and writable-view behavior in
CHANGELOG.md;
- bump `src/manifest.json`, `package.json`, and `package-lock.json` together to
`2.0.0` only at the agreed release phase;
- run the complete validation and build a fresh production JPL;
- inspect the embedded manifest/version and record its SHA-256;
- obtain explicit final manual acceptance;
- commit, push, and publish only when the user requests those external actions.

View File

@ -97,6 +97,7 @@ done-tag: done
card-detail: hover card-detail: hover
page-size: 10 page-size: 10
done-window: 7 done-window: 7
editable: yes
``` ```
```` ````
@ -141,6 +142,7 @@ not filtered by `done-window` because they have no completion timestamp. The
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. | | `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
| `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. | | `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. |
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. | | `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard moves between columns on desktop. Invalid values warn and remain read-only. |
Use the two independent type filters to build focused views: Use the two independent type filters to build focused views:
@ -219,7 +221,20 @@ Grouping is deliberately limited to the exact combination above. Plain
and `scope: all` also remain aggregated; combining any of them with and `scope: all` also remain aggregated; combining any of them with
`group: notebook` shows a warning and disables grouping. `group: notebook` shows a warning and disables grouping.
Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning. The board is **read-only** and never changes tags or completion. (Drag-and-drop is planned.) Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning.
Kanban remains read-only unless the block contains the exact opt-in
`editable: yes`. On an editable desktop board, drag a card by its move handle or
use its keyboard **Move to…** selector. Backlog removes workflow completion and
progress, In Progress applies progress while reopening a to-do/removing a
note's done tag, and Done completes a native to-do or applies the configured
`done-tag` to an ordinary note. Moves never change content, dates, note type,
notebook ownership, unrelated tags, or manual ordering; configured sorting and
`page-size` are reapplied after a canonical refresh. Same-column, cancelled,
outside, stale, and failed moves do not intentionally write. Completing a
recurring native to-do uses Joplin's real completion field, so the Repeating
To-Dos plugin may advance it; the board then refreshes from canonical state.
Editing is supported in the desktop rendered-note webview only.
## The Matrix (Skeleton & Eisenhower) ## The Matrix (Skeleton & Eisenhower)
@ -284,7 +299,10 @@ 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.
Cards behave like kanban cards: compact, clickable, and read-only. Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄 when no icon is set, and never show completion or recurrence styling. Empty and malformed blocks follow the same opt-in and warning behavior as on kanban. Matrix cards are compact, clickable, and read-only in this release scope.
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
when no icon is set, and never show completion or recurrence styling. Empty and
malformed blocks follow the same opt-in and warning behavior as on kanban.
## Gantt chart ## Gantt chart
@ -365,7 +383,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop
```bash ```bash
npm install npm install
npm test # 251 unit tests npm test # 295 unit tests
npm run dist # builds publish/*.jpl npm run dist # builds publish/*.jpl
``` ```

405
SLICE14.md Normal file
View File

@ -0,0 +1,405 @@
# SLICE 14 — Opt-in kanban drag and drop (v2.0 foundation)
> **State-saving rule:** update this file after every completed task and whenever
> work pauses. Keep implementation, automated validation, production packaging,
> and manual Joplin acceptance as separate status boundaries.
## Status
**COMPLETE.** Phases 17 are complete. Focused and full automation passed, the
production JPL was built and inspected, and the user confirmed every Phase 7
manual Joplin acceptance test passed on 2026-07-31.
## Goal
Allow users to move kanban cards between Backlog, In Progress, and Done from the
rendered-note view. Moves persist to Joplin, so refreshes and other dashboards
derive the same workflow state.
````
```gtd-kanban
title: Editable projects and tasks
editable: yes
notes: all
todos: all
```
````
Existing blocks remain read-only when `editable` is omitted.
## Confirmed product contract
- Desktop rendered-note webview only; cards are DOM elements, not canvas pixels.
- `editable: yes` enables mutation; default `no` preserves every v1 block.
- Parse `yes | no` case-insensitively. Invalid, empty, or non-scalar values warn
and fall back to `no`.
- Editable boards provide pointer drag and a keyboard-operable **Move to…**
control. Dragging is never the only interaction.
- Successful moves persist through Joplin's data API, then refresh the affected
board from canonical data.
- Failed/stale moves show an inline error and converge to canonical state.
- Moves change workflow state, not free-form ordering; configured sorting remains
authoritative.
- Cards stay in their current notebook; cross-group moves are out of scope.
## State transitions
An intentional move canonicalizes configured workflow markers. Normal read-only
collection still treats Done as higher priority than In Progress.
### Ordinary opted-in notes
| Destination | Persisted change |
|---|---|
| Backlog | Remove `done-tag` and `in-progress-tag`. |
| In Progress | Add `in-progress-tag`; remove `done-tag`. |
| Done | Add `done-tag`; remove `in-progress-tag`. |
Notes remain ordinary notes. Their `gtd` block, content, dates, notebook, and
unrelated tags remain unchanged.
### Native Joplin to-dos
| Destination | Persisted change |
|---|---|
| Backlog | Set `todo_completed` to `0`; remove `in-progress-tag`. |
| In Progress | Set `todo_completed` to `0`; add `in-progress-tag`. |
| Done | Set `todo_completed` to current epoch milliseconds; remove `in-progress-tag`. |
`done-tag` remains note-only. Moving an already completed to-do to Done is a
no-op rather than rewriting its completion history.
### Required cases
| Item | Source | Destination | Result |
|---|---|---|---|
| Note | Backlog | In Progress | Add progress, remove done. |
| Note | Backlog/In Progress | Done | Add done, remove progress. |
| Note | Done | In Progress | Remove done, add progress. |
| Note | Done/In Progress | Backlog | Remove both. |
| To-do | Backlog | In Progress | Reopen if needed, add progress. |
| To-do | Backlog/In Progress | Done | Complete natively, remove progress. |
| To-do | Done | In Progress | Reopen, add progress. |
| To-do | Done/In Progress | Backlog | Reopen, remove progress. |
| Either | Any | Same column | No mutation or refresh. |
## Repeating to-dos
Native completion may trigger Repeating To-Dos to advance or replace an item.
Use Joplin's real `todo_completed` field; do not simulate recurrence. Always
refetch after completion and accept canonical state if the item changes during
the round trip. Explicitly test completing and reopening recurring to-dos.
## Architecture and trust boundary
```text
pointer drop or keyboard Move to…
-> constrained webview intent
-> main process reparses config and requires editable:yes
-> mutation service fetches current note and tag identities
-> validate eligibility, scope, group, type, and destination
-> apply minimal idempotent completion/tag operations
-> recollect and rerender the initiating board from canonical data
```
The webview sends intent only:
```ts
type KanbanDestination = "backlog" | "inProgress" | "done";
interface MoveKanbanCardIntent {
view: "kanban";
hostNoteId: string;
cardId: string;
destination: KanbanDestination;
rawConfig: string;
viewInstanceId: string;
}
```
Never accept arbitrary REST paths, patches, tag IDs, or tag-operation lists from
the webview. Reparse configuration and independently derive every write.
Add narrowly scoped, testable operations for:
- fetching one current note, including type, completion, parent, and body when
eligibility must be checked;
- listing tags with both ID and title;
- finding/creating a configured tag by normalized title;
- idempotently attaching/detaching a tag;
- patching only `todo_completed` on a native to-do.
Tag matching stays case-insensitive. Reuse an existing case variant. If duplicate
same-title tags exist, do not create another; select deterministically and test it.
### Fresh-state validation
Before writing, verify that the host note exists, the card is not the host, the
destination is fixed, the block still has `editable: yes`, the item remains
admitted by `notes`/`todos`, ordinary notes retain a found `gtd` block, and the
item remains in resolved scope. Grouped boards must retain exact notebook
ownership. Stale conditions make no write and trigger a canonical refresh.
### Multi-step safety
Joplin note/tag operations are separate and may not be transactional. Compute
changes first, skip already-correct writes, order changes sensibly, and refetch
after success or failure. Do not compensate with stale values that might
overwrite a concurrent user edit.
## Webview interaction
Prefer pointer events for consistent desktop behavior and click/drag separation.
- Show a drag affordance only on editable cards.
- Start after a movement threshold; ordinary clicks still open notes.
- Use pointer capture and visible dragged/valid-target/active-target states.
- Support long, scrolling, grouped, and paginated boards.
- Escape, cancellation, lost capture, outside drop, and same-column drop do not
write.
- Scope targets to the nearest board and `viewInstanceId`, never another block.
- Disable duplicate submissions and show pending/error feedback.
- Refresh canonically; sorting may place a moved card beyond the visible page.
Keyboard/accessibility requirements:
- provide a focusable **Move to…** control with fixed destinations;
- disable the current destination;
- preserve keyboard open-note behavior and restore focus after rerender;
- announce pending, success, failure, and destination through `aria-live`;
- give controls and targets useful accessible names.
## Rendering and refresh
- Carry stable `hostNoteId` and per-render `viewInstanceId` context.
- Multiple blocks retain independent request/pagination state.
- Refresh only the initiating instance and reuse collectors for sorting, totals,
warnings, grouping, and cards.
- Optimistic preview is allowed; canonical payload remains authoritative.
- Preserve expansion where practical or document/test consistent reset behavior.
- Reject stale async responses with a per-instance request sequence.
## Implementation plan
### Phase 1 — Configuration and protocol
- [x] Add `editable: boolean` to `KanbanConfig`, default `false`.
- [x] Parse/validate `editable: yes | no` and add complete parser tests.
- [x] Define destinations, constrained intents, success, stale, and error types.
- [x] Add stable host/view identity to payload/context.
- [x] Prove existing blocks retain identical read-only behavior.
### Phase 1 completion record
- Added strict scalar `editable: yes | no` parsing. Omitted and `no` remain
read-only; invalid, empty, non-string, array, and object values warn and use
`no`.
- Added fixed Kanban destinations, a constrained semantic move intent, typed
success/stale/error results, and a runtime destination guard. The protocol
contains no arbitrary paths, patches, tag IDs, or tag-operation lists.
- Every rendered placeholder now owns a stable `viewInstanceId` for its render.
Kanban payloads echo that identity and the canonically resolved host note ID,
including a null host identity on missing-source responses.
- Focused automation passed: 2 suites, 62 tests. TypeScript with
`--skipLibCheck`, webview JavaScript syntax, and `git diff --check` passed.
- Full regression automation passed: 17 suites, 261 tests. No production JPL
was built and no manual Joplin acceptance was performed in this phase.
### Phase 2 — Testable mutation service
- [x] Add fresh note/tag reads, find-or-create, membership changes, and native
completion patches behind a narrow adapter.
- [x] Implement every note and to-do transition above.
- [x] Make changes idempotent and preserve unrelated data.
- [x] Test transitions, conflicting tags, custom names/case, duplicate tags,
no-ops, partial failures, and stale/concurrent reads.
### Phase 2 completion record
- Added a writable adapter separate from the existing read-only collector
abstraction. Its surface is limited to fetching one note and tag identities,
listing/creating tags, attaching/detaching memberships, and patching only
`todo_completed`.
- Added canonical note and native-to-do transitions for all three destinations.
Operations are minimal and idempotent, tag matching is case-insensitive,
existing case variants are reused deterministically, duplicate attached tags
are removed deterministically, and unrelated fields/tags are untouched.
- Partial write failures propagate without stale compensating writes so the
later handler can refetch canonical state. Missing-card reads fail before any
write.
- Focused mutation automation passed: 1 suite, 13 tests. Full regression
automation passed: 18 suites, 274 tests. TypeScript with `--skipLibCheck` and
`git diff --check` passed. No package or manual test was performed.
### Phase 3 — Message validation
- [x] Register the kanban move message in the existing handler.
- [x] Reparse config and require `editable: yes` in the main process.
- [x] Reject malformed intent, arbitrary destinations, missing/host/filtered/
out-of-scope items, lost opt-in, and cross-group movement without writes.
- [x] Return typed user-safe results and keep diagnostic detail in logs.
- [x] Prove webview input cannot select arbitrary paths, fields, tags, or writes.
### Phase 3 completion record
- Registered `moveKanbanCard` and added a testable validation service between
the untrusted webview message and the Phase 2 mutation adapter.
- The handler requires an exact constrained message shape, fixed destination,
non-empty host/card/view identities, matching currently selected host, valid
reparsed YAML, and explicit `editable: yes`. Unknown fields are rejected, so
paths, patches, tag operations, and arbitrary write instructions cannot be
smuggled alongside a valid destination.
- Fresh folders and the freshly fetched card are used to revalidate notebook
targeting, scope, exact current parent ownership, host exclusion, item type
filters, and ordinary-note/to-do `gtd` eligibility immediately before writes.
- Expected stale conditions and failures return typed user-safe results.
Unexpected API detail is retained only in plugin-process diagnostic logging.
- Focused validation passed: 3 suites, 43 tests. Full regression validation
passed: 19 suites, 295 tests. TypeScript with `--skipLibCheck`, webview syntax,
and `git diff --check` passed. Packaging and manual acceptance remain pending.
### Phase 4 — Pointer drag and drop
- [x] Add editable-only affordances and complete pointer lifecycle handling.
- [x] Implement thresholds, capture, target detection, scrolling, cancellation,
Escape, lost capture, pending state, and failure UI.
- [x] Preserve clicks, details, styling, pagination, and navigation.
- [x] Prevent cross-block/group drops and duplicate submissions.
- [x] Refresh canonically and ignore stale responses.
### Phase 4 completion record
- Added an explicit editable-only drag handle to each Kanban card. Pointer drag
begins only after six pixels of movement; ordinary card clicks retain the
existing open-note path, while a completed/cancelled drag suppresses the
synthetic click that could otherwise open a note accidentally.
- Pointer capture, active-card styling, valid/active target styling, viewport
edge scrolling, Escape, pointer cancellation, lost capture, outside drops,
and same-column no-ops are implemented. Drop targets are restricted to the
originating board element, preventing movement across notebook groups or
stacked blocks.
- Per-view pending state prevents duplicate submissions and temporarily disables
handles. Inline pending/error feedback is shown without changing the card
arrays optimistically.
- Every accepted/rejected move triggers a canonical `getKanban` refresh for the
initiating `viewInstanceId`. Per-instance request sequencing and echoed
identity prevent older or foreign responses from replacing the current view.
- Full regression automation passed: 19 suites, 295 tests. TypeScript with
`--skipLibCheck`, webview syntax, and `git diff --check` passed. The repository
has no browser DOM harness, so pointer visuals and behavior remain explicit
Phase 7 manual acceptance items. No production JPL was built in this phase.
### Phase 5 — Keyboard and accessibility
- [x] Add keyboard-operable **Move to…** controls.
- [x] Preserve open-note keys and predictable focus.
- [x] Add accessible names, instructions, states, and live announcements.
- [x] Test keyboard-only movement among all columns.
### Phase 5 completion record
- Every editable card now exposes a native keyboard-operable **Move to…**
selector with fixed Backlog, In Progress, and Done options. The current
destination and placeholder are disabled; choosing another destination uses
the same constrained message and canonical refresh as pointer movement.
- Editable cards are keyboard-focusable links with accessible open-note names
and Enter/Space activation. The pointer-only handle is removed from the tab
and accessibility order so it does not create an inert keyboard stop.
- Move controls carry card-specific accessible names and visible focus styles.
Pending, success, stale, and error status messages use atomic live regions;
controls are disabled while a request is active.
- Canonical rerender restores focus to the moved card's selector when it remains
visible. If sorting or pagination removes the card from the rendered batch,
focus moves to the status region instead of being lost.
- Read-only boards retain their previous markup/focus behavior and expose no
drag handle, move selector, or mutation status UI.
- Full regression automation passed: 19 suites, 295 tests. TypeScript with
`--skipLibCheck`, webview syntax, and `git diff --check` passed. With no DOM
harness in this repository, end-to-end keyboard and screen-reader behavior
remains a Phase 7 manual acceptance boundary. No JPL was built in this phase.
### Phase 6 — Documentation, regression, and packaging
- [x] Document opt-in editing, transitions, sorting, recurrence caveats, keyboard
controls, and desktop scope in README.md and SPEC.md.
- [x] Revise text that says all cards are read-only or drag is merely planned.
- [x] Add an unreleased v2.0.0 CHANGELOG entry without releasing.
- [x] Verify calendars, matrices, Gantt, read-only boards, filters, groups,
warnings, totals, navigation, and styling do not regress.
- [x] Run focused tests, full Jest, TypeScript, webview syntax, whitespace,
prohibited-reference checks, and production JPL build/inspection.
### Phase 6 completion record
- README documents the exact `editable: yes` opt-in, pointer and keyboard
controls, note/to-do transitions, sorting/pagination authority, recurrence,
cancellation/failure behavior, preservation guarantees, and desktop scope.
- SPEC documents parsing, transition tables, constrained protocol and trust
boundary, mutation safety, request sequencing, canonical refresh, focus/live
announcements, and the remaining multi-dashboard refresh limitation. The old
planned-authoring roadmap is replaced with the implemented release-pending
contract. Historical v1 read-only text and current matrix/Gantt read-only text
remain intentionally scoped and accurate.
- CHANGELOG contains an unreleased 2.0.0 Kanban entry. Package and manifest
versions intentionally remain 1.0.0 until the consolidated v2 release phase;
nothing was published or pushed.
- Focused automation passed: 4 suites, 96 tests. Full automation passed: 19
suites, 295 tests. TypeScript with `--skipLibCheck`, webview JavaScript syntax,
`git diff --check`, stale-wording review, and packaged-output prohibited-path/
planning-reference audits passed.
- `npm run dist` passed. The fresh production artifact is
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl` (177,664 bytes,
embedded manifest version 1.0.0, SHA-256
`749db5d334457ba4818a075c9d0df4f47bda53a478f0dc9904cbab5f8d0d4e33`).
Its five expected runtime files and Kanban mutation, pointer, keyboard,
accessibility, and request-sequencing markers were inspected.
- No manual Joplin acceptance was performed; every Phase 7 item remains open.
### Phase 7 — Manual Joplin acceptance
- [x] Omitted/`no`/invalid `editable` states remain read-only as documented.
- [x] Pointer moves notes through all columns and persists exact configured tags.
- [x] Pointer moves to-dos through all columns and persists native completion and
progress state.
- [x] Conflicting note tags are canonicalized only by an intentional move.
- [x] Custom workflow tags preserve unrelated/similarly named tags.
- [x] Same-column, cancelled, outside, Escape, and interrupted drags do not write.
- [x] Click still opens; drag does not accidentally open.
- [x] Keyboard-only movement works, restores focus, and announces results.
- [x] Stale state/API failure cannot leave false UI or corrupt unrelated data.
- [x] Sorting, totals, pagination, colours, glyphs, details, and Done styling stay
correct after refresh.
- [x] Single, grouped, multiple-block, and long scrolling boards work.
- [x] Completing/reopening a recurring to-do works with Repeating To-Dos.
- [x] Record explicit user sign-off separately from automation.
### Phase 7 acceptance record
- **PASSED — USER SIGN-OFF RECEIVED 2026-07-31.** The user confirmed all Phase 7
manual tests passed against the inspected Phase 6 production JPL.
- Accepted coverage includes read-only opt-in boundaries, ordinary-note and
native-to-do transitions, conflicting/custom/unrelated tags, pointer
cancellation and click separation, keyboard movement and focus/live feedback,
stale/API-failure convergence, sorting/pagination/styling, grouped and stacked
boards, long scrolling, and Repeating To-Dos integration.
- This manual acceptance is recorded separately from Phase 6 automation and
packaging. Slice 15 has not begun.
## Out of scope
- Free-form ordering or persisted rank.
- Moving between notebooks/groups or changing `parent_id`.
- Editing card fields or arbitrary tags.
- Calendar, Gantt, or matrix drag (SLICE 15 covers Eisenhower).
- Skeleton drag, mobile support, or recurrence reimplementation.
- Publishing v2.0.0 before both slices and release acceptance are complete.
## Acceptance criteria
- Only an explicitly editable kanban can mutate data.
- Every destination produces the exact canonical state above.
- Main-process validation uses fresh Joplin data and constrained intent.
- Pointer and keyboard workflows are complete and accessible.
- Failures converge to canonical state without overwriting unrelated changes.
- All read-only and v1.0.0 behavior remains compatible.
- Automation, packaging, and manual acceptance are recorded separately.

259
SLIDE15.md Normal file
View File

@ -0,0 +1,259 @@
# SLICE 15 — Eisenhower drag and drop with Skeleton quadrant names
> **Filename note:** this file is named `SLIDE15.md` as requested. Its feature
> sequence and heading identify it as SLICE 15.
>
> **State-saving rule:** update this file after every completed task and whenever
> work pauses. Keep implementation, automated validation, production packaging,
> and manual Joplin acceptance as separate status boundaries.
## Status
**PLANNED; BLOCKED ON SLICE 14.** Begin only after the shared mutation service,
interaction model, automated validation, and manual kanban acceptance in
SLICE 14 are complete.
## Goal
Make Eisenhower matrices editable by moving cards between quadrants. Each move
persists the urgent/important tag combination represented by the destination.
At the same time, replace the classic action labels with the more constructive
Skeleton labels while retaining Eisenhower's urgent/important axes:
| | **Urgent** | **Not urgent** |
|---|---|---|
| **Important** | **Do Next** | **Scheduled** |
| **Not important** | **On Deck** | **Backlog** |
This means the current **Eliminate** quadrant becomes **Backlog**. The underlying
quadrant remains “not urgent and not important”; only its displayed name changes.
## Confirmed product contract
- The label change applies to every Eisenhower matrix, editable or read-only.
- Eisenhower's axes remain `urgent-tag` and `important-tag`; no date-derived
urgency or in-progress semantics are introduced.
- All four Eisenhower quadrant names adopt Skeleton's names:
`Do Next`, `Scheduled`, `On Deck`, and `Backlog`.
- Skeleton mode remains available and unchanged in v2.0.0, but does not receive
drag and drop in this slice.
- `editable: yes` enables Eisenhower mutation and defaults to `no`.
- Setting `editable: yes` with `mode: skeleton` warns that editing is unavailable
and renders a read-only Skeleton matrix. It must not expose inert handles.
- Both ordinary opted-in notes and incomplete native to-dos can be moved.
- Completed to-dos and notes carrying `done-tag` remain excluded; dragging does
not change completion state.
- A move changes only the configured urgent/important tag memberships. It does
not edit dates, in-progress state, completion, notebook, or unrelated tags.
- Pointer drag and keyboard **Move to…** use the shared SLICE 14 behavior.
- Sorting remains controlled by `sort` and `sort-type`; drag does not establish
manual ordering within a quadrant.
- Moves remain within the current notebook group.
## Eisenhower transition rules
| Destination | Urgent tag | Important tag | Persisted change |
|---|---:|---:|---|
| Do Next (top-left) | yes | yes | Add both configured tags. |
| Scheduled (top-right) | no | yes | Remove urgent; add important. |
| On Deck (bottom-left) | yes | no | Add urgent; remove important. |
| Backlog (bottom-right) | no | no | Remove both configured tags. |
The rules are identical for ordinary notes and native to-dos. Joplin note type,
native completion, `done-tag`, `in-progress-tag`, `gtd` block, dates, and all
unrelated tags remain unchanged.
If `urgent-tag` and `important-tag` normalize to the same title, the two axes
cannot represent four persistent states. Existing parser behavior must be
audited. For an editable Eisenhower matrix, treat this configuration as
non-editable with a clear warning rather than applying ambiguous mutations.
Read-only collection may retain its existing precedence for compatibility.
## Label migration
Update the canonical `matrixLabels("eisenhower")` result:
| Position | v1 label | v2 label |
|---|---|---|
| top-left | Do First | Do Next |
| top-right | Schedule | Scheduled |
| bottom-left | Delegate | On Deck |
| bottom-right | Eliminate | Backlog |
Remove duplicated fallback label literals in the webview or update them from
the same shared contract so an absent/legacy payload cannot silently restore
the old wording. Update code comments, tests, README.md, SPEC.md, and accessible
labels. No saved data migration is needed because quadrant labels are not
persisted.
## Architecture
SLICE 15 extends, rather than duplicates, the SLICE 14 pathway:
```text
matrix pointer drop or keyboard Move to…
|
v
{ view: "matrix", hostNoteId, cardId, destination, rawConfig, viewInstanceId }
|
v
shared message validation
- config reparses as mode:eisenhower + editable:yes
- card remains admitted, incomplete, in scope, and in the same group
|
v
shared tag mutation primitives apply urgent/important target state
|
v
canonical matrix recollection, sorting, totals, pagination, and rerender
```
Use a matrix-specific semantic destination union (`topLeft | topRight |
bottomLeft | bottomRight`, or equivalent named states). Do not let the webview
submit arbitrary tag titles or add/remove lists. The main process derives tag
operations from freshly parsed configuration and the fixed destination table.
### Fresh-state validation
Before writing, confirm all SLICE 14 host, scope, type, grouping, and `gtd`
eligibility checks, plus:
- mode is still `eisenhower`;
- `editable` is still enabled;
- urgent and important tag names remain distinct after normalization;
- the item is still incomplete under native or note `done-tag` semantics;
- the destination is one of the four fixed quadrants.
If the item became complete or the block changed mode/configuration during a
drag, make no write, report stale state, and refresh the matrix.
## Interaction and rendering rules
- Reuse SLICE 14 pointer thresholds, capture, cancellation, pending/error states,
request sequencing, focus recovery, live announcements, and canonical refresh.
- Highlight only quadrants in the same matrix instance and notebook group.
- Use the new quadrant labels everywhere, including **Move to…** options and
announcements.
- Do not infer a source quadrant in the main process from stale DOM data; fetch
current tags and calculate a minimal target-state change.
- Same-quadrant moves are no-ops.
- A canonical refresh may sort the card to a different position or beyond the
currently visible `page-size`; counts and **List more** remain authoritative.
- Read-only Eisenhower matrices receive the label change without drag handles,
move menus, mutation requests, or new focus stops.
- Skeleton matrices receive the label-independent shared rendering regressions
only and remain read-only.
## Implementation plan
### Phase 1 — Label change and compatibility tests
- [ ] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
- [ ] Update comments and remove/update fallback literals that contain Do First,
Schedule, Delegate, or Eliminate.
- [ ] Update unit and rendering tests for single and grouped layouts.
- [ ] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
- [ ] Confirm the label-only change applies when `editable` is omitted.
### Phase 2 — Matrix configuration and protocol
- [ ] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
- [ ] Parse `editable: yes | no` using the SLICE 14 validation contract.
- [ ] Warn and disable editing for `mode: skeleton`.
- [ ] Warn and disable editable Eisenhower when normalized axis tags are equal.
- [ ] Define constrained matrix move intents and fixed destinations using the
shared host/view identity and typed result model.
- [ ] Add parser/protocol tests for defaults, invalid values, mode interaction,
equal-axis tags, and backward compatibility.
### Phase 3 — Eisenhower mutation planning
- [ ] Implement the four urgent/important target states through shared tag
primitives from SLICE 14.
- [ ] Calculate minimal, idempotent tag membership changes from fresh data.
- [ ] Preserve completion, progress, dates, body, note type, notebook, and
unrelated tags.
- [ ] Cover notes and to-dos, custom tag names, casing, duplicate tags, all 12
cross-quadrant moves, same-quadrant no-ops, and partial failures.
- [ ] Prove a completed or newly filtered item cannot be mutated.
### Phase 4 — Message validation and refresh
- [ ] Add the matrix move message to the main-process handler.
- [ ] Reparse and validate editable Eisenhower configuration and fresh eligibility.
- [ ] Reject arbitrary destinations, tag operations, fields, modes, host notes,
out-of-scope items, cross-group moves, and stale completions.
- [ ] Return typed results and refresh only the initiating matrix instance.
- [ ] Guard against older responses overwriting newer renders.
### Phase 5 — Pointer and keyboard interaction
- [ ] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
- [ ] Constrain active targets to the same matrix instance and notebook group.
- [ ] Add new-label drag feedback, pending/error states, and cancellation.
- [ ] Add keyboard **Move to…** options for the other three quadrants.
- [ ] Preserve click-to-open, hover details, pagination, scrolling, focus, and
screen-reader announcements.
- [ ] Verify read-only Eisenhower and all Skeleton matrices expose no mutation UI.
### Phase 6 — Documentation and complete validation
- [ ] Update README.md examples, option tables, quadrant table, read-only text,
controls, and limitations.
- [ ] Update SPEC.md configuration, label contract, transition table, protocol,
mutation safety, accessibility, and refresh behavior.
- [ ] Update the unreleased v2.0.0 CHANGELOG entry.
- [ ] Decide whether Skeleton remains documented as a supported alternative or
is marked as legacy/non-editable; do not remove it in this slice.
- [ ] Run focused parser, labels, collector, mutation, handler, render, grouping,
pagination, and accessibility tests.
- [ ] Run the complete Jest suite, TypeScript, webview syntax, CSS/build,
whitespace, and prohibited-reference checks.
- [ ] Build and inspect the production JPL without publishing v2.0.0 yet.
### Phase 7 — Manual Joplin acceptance
- [ ] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
- [ ] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
Eliminate, or “Drop this.”
- [ ] Read-only Eisenhower matrices retain click, hover, sorting, and pagination
behavior without mutation affordances.
- [ ] An editable Eisenhower matrix moves an ordinary note through all four
quadrants and persists the exact urgent/important tag state after reload.
- [ ] An editable Eisenhower matrix moves a native to-do through all four
quadrants without changing native completion or progress state.
- [ ] Custom distinct axis tags work; equal axis tags warn and disable editing.
- [ ] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
- [ ] A normal click opens the note; a drag does not accidentally open it.
- [ ] Keyboard-only **Move to…** reaches every quadrant and restores focus.
- [ ] Stale completion, filtering, scope, group, and API-failure cases converge
to canonical state without unrelated data changes.
- [ ] Single, grouped, multiple-block, scrolling, and paginated matrices work.
- [ ] Skeleton matrices remain correct and read-only.
- [ ] Record explicit user sign-off separately from automated checks.
## Out of scope
- Skeleton drag and drop.
- Removing Skeleton mode or migrating Skeleton blocks to Eisenhower.
- Date-derived urgency changes.
- Changing completion or in-progress state from an Eisenhower move.
- Manual ordering within a quadrant.
- Dragging between notebook groups or changing notebook ownership.
- Editing arbitrary tags or card fields.
- Calendar, Gantt, or mobile drag and drop.
- Publishing v2.0.0 before consolidated release acceptance.
## Acceptance criteria
- Eisenhower retains urgent/important semantics but consistently uses Skeleton's
four constructive quadrant names.
- Mutation occurs only for an explicitly editable, valid Eisenhower block.
- Every destination persists exactly the configured urgent/important state and
preserves completion, progress, content, notebook, and unrelated tags.
- Pointer and keyboard interaction share SLICE 14's validated safety and
accessibility foundation.
- Skeleton mode remains available, correct, and read-only.
- Automated validation, production packaging, and explicit manual Joplin
acceptance are completed and recorded separately.

53
SPEC.md
View File

@ -90,6 +90,7 @@ done-tag: done # ordinary-note completion; default: "done"
card-detail: hover # hover | always | none (default: hover) card-detail: hover # hover | always | none (default: hover)
page-size: 10 # positive integer (default: 10) page-size: 10 # positive integer (default: 10)
done-window: 7 # days, or "all" (default: 7) done-window: 7 # days, or "all" (default: 7)
editable: no # yes | no; persisted moves (default: no)
``` ```
Three columns are always shown: **Backlog**, **In Progress**, and **Done**. Three columns are always shown: **Backlog**, **In Progress**, and **Done**.
@ -111,7 +112,21 @@ Under `due-date`, dateless cards sort after dated cards regardless of direction.
10. Each column initially renders at most that many cards and reveals another 10. Each column initially renders at most that many cards and reveals another
batch per **List more** click. Expansion is independent per column and resets batch per **List more** click. Expansion is independent per column and resets
when the rendered note reloads; headings and statistics retain complete totals. when the rendered note reloads; headings and statistics retain complete totals.
Read-only in the current version.
`editable` accepts scalar `yes | no` case-insensitively and defaults to `no`.
Invalid, empty, or non-scalar values warn and remain read-only. With `yes`, the
desktop rendered-note view exposes a pointer handle and keyboard **Move to…**
selector. Destinations are fixed semantic states, never arbitrary patches:
| Destination | Ordinary opted-in note | Native Joplin to-do |
|---|---|---|
| Backlog | remove `done-tag` and `in-progress-tag` | set `todo_completed: 0`; remove `in-progress-tag` |
| In Progress | add `in-progress-tag`; remove `done-tag` | set `todo_completed: 0`; add `in-progress-tag` |
| Done | add `done-tag`; remove `in-progress-tag` | set `todo_completed` to current epoch ms; remove `in-progress-tag` |
Same-state moves are idempotent. Content, dates, note type, notebook,
unrelated tags, and free-form ordering are preserved. Completing a recurring
to-do uses native completion and may activate the Repeating To-Dos plugin.
### 2.4 The `gtd-matrix` block (lives in the matrix note) ### 2.4 The `gtd-matrix` block (lives in the matrix note)
@ -249,6 +264,11 @@ notes never become recurring.
- **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`. - **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`.
- **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`). - **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`).
- **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches. - **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches.
- **Editable kanban:** only `editable: yes` adds a pointer handle and keyboard
**Move to…** selector. Pointer movement has a threshold and is scoped to the
originating board/notebook group. Pending, success, stale, and failure states
are announced through an atomic live region; focus returns to the moved card
or status fallback after canonical rerender.
- **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter. - **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.
- **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.
@ -275,7 +295,8 @@ Upstream is a single synchronous markdown-it content script with no data API acc
│ │ depending on block type. Builds the │ │ depending on block type. Builds the
│ │ calendar / board / matrix / Gantt DOM from │ │ calendar / board / matrix / Gantt DOM from
│ │ returned payload. Click → postMessage │ │ returned payload. Click → postMessage
│ │ ({type: "openNote", noteId}). │ │ ({type: "openNote", noteId}); editable
│ │ kanban sends constrained move intent only.
└──────────────┬──────────────┘ └──────────────┬──────────────┘
│ postMessage / onMessage │ postMessage / onMessage
┌──────────────▼──────────────┐ ┌──────────────▼──────────────┐
@ -300,6 +321,9 @@ 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
│ │ host/card/scope/tags, derives minimal writes,
│ │ then returns a typed move result
└─────────────────────────────┘ └─────────────────────────────┘
``` ```
@ -329,7 +353,10 @@ SLICE8 batching remains transient webview state.
real-world scales (largest tested: ~285 notes, single folder scope), but worth real-world scales (largest tested: ~285 notes, single folder scope), but worth
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: re-render occurs on note switch (Joplin behavior); no live refresh on background data changes. Fine for a read-only plugin; becomes a sharper question once the authoring axis (§9) lands, where a write on one rendered board should ideally refresh others. - Refresh model: normal background edits appear when Joplin rerenders the note.
An editable-kanban move immediately refetches and rerenders only its initiating
`viewInstanceId`; request sequencing rejects stale responses. Other open
dashboards remain stale until their own rerender.
### Platform caveats ### Platform caveats
@ -474,19 +501,17 @@ remains eligibility-gated. Automated validation passed at 185 tests, the
production JPL built successfully, and manual Joplin acceptance passed production JPL built successfully, and manual Joplin acceptance passed
2026-07-27. 2026-07-27.
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic ### v2.0.0 — Opt-in kanban authoring — IMPLEMENTED, RELEASE PENDING
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault: Kanban write-back is implemented behind `editable: yes`; existing blocks remain
read-only. Pointer and keyboard controls submit constrained intent. The main
process reparses configuration, validates the selected host and fresh scope/item
eligibility, derives minimal tag/completion writes, and refreshes the initiating
view from canonical data. Failures do not use stale rollback values. Cross-group
movement and free-form ordering remain excluded. Release and manual Joplin
acceptance are tracked in `SLICE14.md`.
**Drag-and-drop kanban (write-back).** The first feature making the plugin a **controller** of data rather than a viewer — warrants its own design pass. ### Future authoring — create from view
- Dragging a card writes back to Joplin via the existing webview→plugin channel:
- → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate).
- → *Done*: set `todo_completed`.
- → *Backlog*: remove the tag / clear completion.
- **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes.
- Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging.
- **Multi-board refresh:** with Persistent Layout keeping several dashboards rendered, a write from one board leaves others stale until re-render — the refresh question is sharper in the fractal-dashboard architecture than for single-board use.
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer. **Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.

View File

@ -1,4 +1,4 @@
# TASKS — SLICE8 through SLICE10 # TASKS — SLICE8 through SLICE14
Use this file as the cross-slice implementation checklist. Update it whenever a Use this file as the cross-slice implementation checklist. Update it whenever a
task is completed or its status changes. Keep automated validation results and task is completed or its status changes. Keep automated validation results and
@ -8,9 +8,13 @@ manual Joplin acceptance recorded separately.
- [x] SLICE8 complete — incremental card limits - [x] SLICE8 complete — incremental card limits
- [x] SLICE9 complete — opted-in note cards - [x] SLICE9 complete — opted-in note cards
- [ ] SLICE10 complete — child-notebook boards and matrices - [x] SLICE10 complete — child-notebook boards and matrices
- [x] SLICE11 complete — ordinary-note completion with a `done` tag
- [x] SLICE12 complete — independent project-note and task-to-do filters
- [x] SLICE13 complete — underline removal for kanban and matrix cards
- [x] SLICE14 complete — opt-in kanban drag and drop
Implementation order: SLICE8, then SLICE9, then SLICE10. Slices 813 are complete. SLICE14 is the next implementation slice.
## SLICE8 — Incremental card limits for kanban and matrix views ## SLICE8 — Incremental card limits for kanban and matrix views
@ -159,7 +163,7 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
- [x] Test the same invariant in Skeleton and Eisenhower matrices. - [x] Test the same invariant in Skeleton and Eisenhower matrices.
- [x] Include both note and to-do cards in grouping tests. - [x] Include both note and to-do cards in grouping tests.
- [x] Test aggregate statistics and warnings for double-counting regressions. - [x] Test aggregate statistics and warnings for double-counting regressions.
- [ ] Test independent batching and "List more" state across generated views. - [x] Test independent batching and "List more" state across generated views.
- [x] Test that ungrouped `children`, numeric scope, `this-folder`, and - [x] Test that ungrouped `children`, numeric scope, `this-folder`, and
`scope: all` still produce one view with unchanged behavior. `scope: all` still produce one view with unchanged behavior.
@ -176,16 +180,27 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
grandchildren in Joplin. grandchildren in Joplin.
- [x] Confirm headings, ordering, empty-group handling, no duplication, sorting, - [x] Confirm headings, ordering, empty-group handling, no duplication, sorting,
styling, navigation, warnings, and independent expansion. styling, navigation, warnings, and independent expansion.
- [ ] Mark SLICE10 complete in `SLICE10.md` and this file. - [x] Mark SLICE10 complete in `SLICE10.md` and this file.
## Final cross-slice verification ## Final cross-slice verification
- [ ] Run the complete automated suite after all three slices are integrated. - [x] Run the complete automated suite after all three slices are integrated.
- [ ] Build the final production `.jpl` and record its path and version. - [x] Build the final production `.jpl` and record its path and version.
- [x] Verify a dashboard containing kanban and both matrix modes together. - [x] Verify a dashboard containing kanban and both matrix modes together.
- [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks. - [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks.
- [x] Confirm reload resets every expanded list to its configured initial size. - [x] Confirm reload resets every expanded list to its configured initial size.
- [x] Record manual Joplin acceptance without conflating it with automated checks. - [x] Record manual Joplin acceptance without conflating it with automated checks.
- [ ] Re-run the repository-wide prohibited-reference audit. - [x] Re-run the repository-wide prohibited-reference audit.
- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE810 reflect the - [x] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE810 reflect the
final implementation state. final implementation state.
## Subsequent slice status
- [x] SLICE11 complete. Implementation, focused and full automation, production
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
- [x] SLICE12 complete. Implementation, focused and full automation, production
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
- [x] SLICE13 complete. Implementation, automation, production package
inspection, and manual Joplin acceptance were completed on 2026-07-28.
- [x] SLICE14 complete. Phases 17 passed; explicit manual Joplin acceptance was
received on 2026-07-31. Slice 15 remains not started.

View File

@ -0,0 +1,132 @@
const YAML = require("yaml");
import extractGtdBlock from "./gtdBlock";
import resolveScopedFolderIds from "./folderScope";
import { moveKanbanItem } from "./kanbanMutation";
import { isKanbanDestination } from "./moveProtocol";
import parseKanbanConfig from "./parseKanbanConfig";
import resolveNotebook from "./resolveNotebook";
import {
DataAdapter,
MoveCardResult,
MoveKanbanCardIntent,
MutationAdapter,
RawFolder,
RawNote,
} from "./types";
export interface KanbanMoveHost {
id: string;
parentId: string;
}
function stale(viewInstanceId: string, message: string): MoveCardResult {
return { status: "stale", viewInstanceId, message };
}
function error(viewInstanceId: string, message: string): MoveCardResult {
return { status: "error", viewInstanceId, message };
}
function validId(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
class StaleMoveError extends Error {}
function validateFreshEligibility(
note: RawNote,
host: KanbanMoveHost,
folderIds: Set<string>,
config: ReturnType<typeof parseKanbanConfig>
): void {
if (note.id === host.id) throw new StaleMoveError("The dashboard note cannot be moved.");
if (!folderIds.has(note.parent_id)) throw new StaleMoveError("The card is no longer in scope.");
if (!note.is_todo && config.notes === "none") {
throw new StaleMoveError("The card is no longer admitted by notes.");
}
const block = extractGtdBlock(note.body);
if (note.is_todo) {
if (config.todos === "none") throw new StaleMoveError("The card is no longer admitted by todos.");
if (config.todos === "gtd-only" && !block.found) {
throw new StaleMoveError("The to-do no longer contains a gtd block.");
}
} else if (!block.found) {
throw new StaleMoveError("The note no longer contains a gtd block.");
}
}
/** Validate untrusted webview intent against fresh host, scope, and item state. */
export async function validateAndMoveKanbanCard(
intent: unknown,
host: KanbanMoveHost | null,
readAdapter: DataAdapter,
mutationAdapter: MutationAdapter,
now: () => number = Date.now,
logDiagnostic: (error: unknown) => void = () => undefined
): Promise<MoveCardResult> {
const candidate = intent as Partial<MoveKanbanCardIntent> | 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 !== "moveKanbanCard" ||
candidate.view !== "kanban" ||
!validId(candidate.hostNoteId) ||
!validId(candidate.cardId) ||
!validId(candidate.viewInstanceId) ||
typeof candidate.rawConfig !== "string" ||
!isKanbanDestination(candidate.destination)
) {
return error(viewInstanceId, "Invalid kanban move request.");
}
if (!host || candidate.hostNoteId !== host.id) {
return stale(viewInstanceId, "The rendered dashboard is no longer current.");
}
let parsed: unknown;
try {
parsed = YAML.parse(candidate.rawConfig || "") || {};
} catch (_parseError) {
return stale(viewInstanceId, "The kanban configuration is no longer valid.");
}
const config = parseKanbanConfig(parsed);
if (!config.editable) {
return error(viewInstanceId, "This kanban 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 moveKanbanItem(
mutationAdapter,
candidate.cardId,
candidate.destination,
config,
now,
(note) => validateFreshEligibility(note, host, folderIds, config)
);
return { status: "success", viewInstanceId, changed: outcome.changed };
} catch (moveError) {
if (moveError instanceof StaleMoveError) {
return stale(viewInstanceId, moveError.message);
}
if (moveError instanceof Error && moveError.message === "The card no longer exists.") {
return stale(viewInstanceId, moveError.message);
}
logDiagnostic(moveError);
return error(viewInstanceId, "Could not apply the move. Refresh and try again.");
}
}

113
src/Gtd/kanbanMutation.ts Normal file
View File

@ -0,0 +1,113 @@
import {
KanbanDestination,
MutationAdapter,
RawNote,
RawTag,
} from "./types";
export interface KanbanMutationConfig {
inProgressTag: string;
doneTag: string;
}
export interface KanbanMutationOutcome {
note: RawNote;
changed: boolean;
}
const normalized = (title: string) => title.trim().toLowerCase();
function matchingTags(tags: RawTag[], title: string): RawTag[] {
const target = normalized(title);
return tags
.filter((tag) => normalized(tag.title || "") === target)
.sort((a, b) => a.id.localeCompare(b.id));
}
async function ensureTag(
adapter: MutationAdapter,
noteId: string,
title: string,
noteTags: RawTag[]
): Promise<boolean> {
if (matchingTags(noteTags, title).length > 0) return false;
const existing = matchingTags(await adapter.getAllTags(), title)[0];
const tag = existing || (await adapter.createTag(title));
await adapter.attachTag(tag.id, noteId);
noteTags.push(tag);
return true;
}
async function removeTag(
adapter: MutationAdapter,
noteId: string,
title: string,
noteTags: RawTag[]
): Promise<boolean> {
const matches = matchingTags(noteTags, title);
for (const tag of matches) await adapter.detachTag(tag.id, noteId);
if (matches.length > 0) {
const removed = new Set(matches.map((tag) => tag.id));
noteTags.splice(0, noteTags.length, ...noteTags.filter((tag) => !removed.has(tag.id)));
}
return matches.length > 0;
}
/**
* Apply one canonical kanban workflow state from freshly fetched note/tag data.
* Eligibility and scope are deliberately the caller's responsibility (Phase 3).
*/
export async function moveKanbanItem(
adapter: MutationAdapter,
noteId: string,
destination: KanbanDestination,
config: KanbanMutationConfig,
now: () => number = Date.now,
validateFreshNote?: (note: RawNote) => void
): Promise<KanbanMutationOutcome> {
const note = await adapter.getNote(noteId);
if (!note) throw new Error("The card no longer exists.");
if (validateFreshNote) validateFreshNote(note);
const noteTags = await adapter.getNoteTags(noteId);
let changed = false;
if (note.is_todo) {
if (destination === "done") {
if (note.todo_completed <= 0) {
await adapter.setTodoCompleted(noteId, now());
changed = true;
}
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
if (note.todo_completed > 0) {
await adapter.setTodoCompleted(noteId, 0);
changed = true;
}
if (destination === "inProgress") {
changed =
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
}
}
} else if (destination === "done") {
changed = (await ensureTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
} else if (destination === "inProgress") {
changed =
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
} else {
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
}
return { note, changed };
}

12
src/Gtd/moveProtocol.ts Normal file
View File

@ -0,0 +1,12 @@
import { KanbanDestination } from "./types";
const KANBAN_DESTINATIONS: KanbanDestination[] = [
"backlog",
"inProgress",
"done",
];
/** Runtime guard for the only kanban workflow states the plugin may persist. */
export function isKanbanDestination(value: unknown): value is KanbanDestination {
return KANBAN_DESTINATIONS.includes(value as KanbanDestination);
}

View File

@ -34,6 +34,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
"done-tag", "done-tag",
"card-detail", "card-detail",
"page-size", "page-size",
"editable",
"done-window", "done-window",
]; ];
for (const key of Object.keys(input)) { for (const key of Object.keys(input)) {
@ -185,6 +186,19 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
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")`);
}
}
return { return {
title, title,
scopeDepth, scopeDepth,
@ -199,6 +213,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
doneTag, doneTag,
cardDetail, cardDetail,
pageSize, pageSize,
editable,
doneWindow, doneWindow,
warnings, warnings,
}; };

View File

@ -103,6 +103,22 @@ export interface DataAdapter {
getNoteTagTitles(noteId: string): Promise<string[]>; getNoteTagTitles(noteId: string): Promise<string[]>;
} }
export interface RawTag {
id: string;
title: string;
}
/** Narrow writable Joplin boundary used only by validated workflow moves. */
export interface MutationAdapter {
getNote(noteId: string): Promise<RawNote | null>;
getNoteTags(noteId: string): Promise<RawTag[]>;
getAllTags(): Promise<RawTag[]>;
createTag(title: string): Promise<RawTag>;
attachTag(tagId: string, noteId: string): Promise<void>;
detachTag(tagId: string, noteId: string): Promise<void>;
setTodoCompleted(noteId: string, completedTime: number): Promise<void>;
}
/** /**
* Tag maintained by the "Repeating To-Dos" plugin family as a query index * Tag maintained by the "Repeating To-Dos" plugin family as a query index
* for recurring to-dos. We treat its presence as the recurrence signal. * for recurring to-dos. We treat its presence as the recurrence signal.
@ -145,11 +161,32 @@ export interface KanbanConfig {
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;
/** Whether this board explicitly opts in to persisted workflow moves. */
editable: boolean;
/** Days; Infinity means "all". */ /** Days; Infinity means "all". */
doneWindow: number; doneWindow: number;
warnings: string[]; warnings: string[];
} }
/** Fixed semantic destinations accepted from an editable kanban webview. */
export type KanbanDestination = "backlog" | "inProgress" | "done";
/** Constrained intent sent by the webview; the main process derives all writes. */
export interface MoveKanbanCardIntent {
type: "moveKanbanCard";
view: "kanban";
hostNoteId: string;
cardId: string;
destination: KanbanDestination;
rawConfig: string;
viewInstanceId: string;
}
export type MoveCardResult =
| { status: "success"; viewInstanceId: string; changed: boolean }
| { status: "stale"; viewInstanceId: string; message: string }
| { status: "error"; viewInstanceId: string; message: string };
/** A note or to-do rendered as a kanban/matrix card. */ /** A note or to-do rendered as a kanban/matrix card. */
export interface KanbanCard { export interface KanbanCard {
id: string; id: string;

View File

@ -389,6 +389,7 @@
} }
.gtd-kanban-card { .gtd-kanban-card {
position: relative;
border: 1px solid rgba(128, 128, 128, 0.4); border: 1px solid rgba(128, 128, 128, 0.4);
border-radius: 4px; border-radius: 4px;
padding: 0.35em 0.5em; padding: 0.35em 0.5em;
@ -399,6 +400,78 @@
font-size: 0.85em; font-size: 0.85em;
} }
.gtd-kanban-drag-handle {
position: absolute;
top: 0.2em;
right: 0.25em;
width: 1.65em;
height: 1.65em;
padding: 0;
border: 1px solid rgba(128, 128, 128, 0.45);
border-radius: 3px;
background: rgba(128, 128, 128, 0.12);
color: inherit;
cursor: grab;
touch-action: none;
line-height: 1;
}
.gtd-kanban-drag-handle:active {
cursor: grabbing;
}
.gtd-kanban-card-editable {
padding-right: 2.4em;
}
.gtd-kanban-card-editable:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.gtd-kanban-move-select {
display: block;
max-width: 100%;
margin-top: 0.35em;
padding: 0.15em 0.25em;
border: 1px solid rgba(128, 128, 128, 0.5);
border-radius: 3px;
background-color: var(--joplin-background-color, white);
color: var(--joplin-color, black);
font: inherit;
font-size: 0.86em;
}
.gtd-kanban-move-select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.gtd-kanban-card-dragging {
opacity: 0.55;
outline: 2px solid currentColor;
}
.gtd-kanban-drop-target {
outline: 1px dashed rgba(80, 120, 200, 0.8);
outline-offset: -3px;
}
.gtd-kanban-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 {
pointer-events: none;
opacity: 0.45;
}
.gtd-kanban-move-status {
margin: 0.25em 0;
font-size: 0.85em;
}
.gtd-kanban-card:hover { .gtd-kanban-card:hover {
border-color: rgba(20, 20, 20, 0.9); border-color: rgba(20, 20, 20, 0.9);
} }

View File

@ -18,7 +18,7 @@
".gtd-calendar-placeholder:not([data-gtd-processed])" ".gtd-calendar-placeholder:not([data-gtd-processed])"
); );
placeholders.forEach(function (el) { placeholders.forEach(function (el, placeholderIndex) {
el.setAttribute("data-gtd-processed", "true"); el.setAttribute("data-gtd-processed", "true");
const contentScriptId = el.getAttribute("data-content-script-id"); const contentScriptId = el.getAttribute("data-content-script-id");
@ -26,6 +26,10 @@
const rawConfig = decodeURIComponent( const rawConfig = decodeURIComponent(
el.getAttribute("data-config") || "" el.getAttribute("data-config") || ""
); );
const viewInstanceId =
"gtd-view-" + Date.now() + "-" + placeholderIndex + "-" +
Math.random().toString(36).slice(2);
el.setAttribute("data-view-instance-id", viewInstanceId);
if ( if (
typeof webviewApi === "undefined" || typeof webviewApi === "undefined" ||
@ -48,10 +52,19 @@
? "getGantt" ? "getGantt"
: "getEvents"; : "getEvents";
const renderContext = {
contentScriptId: contentScriptId,
rawConfig: rawConfig,
viewInstanceId: viewInstanceId,
requestSequence: 0,
pending: false,
};
webviewApi webviewApi
.postMessage(contentScriptId, { .postMessage(contentScriptId, {
type: messageType, type: messageType,
rawConfig: rawConfig, rawConfig: rawConfig,
viewInstanceId: viewInstanceId,
}) })
.then(function (payload) { .then(function (payload) {
if (!payload) { if (!payload) {
@ -59,7 +72,7 @@
return; return;
} }
if (blockType === "kanban") { if (blockType === "kanban") {
renderKanban(el, payload, contentScriptId); renderKanban(el, payload, contentScriptId, renderContext);
} else if (blockType === "matrix") { } else if (blockType === "matrix") {
renderMatrix(el, payload, contentScriptId); renderMatrix(el, payload, contentScriptId);
} else if (blockType === "gantt") { } else if (blockType === "gantt") {
@ -465,7 +478,7 @@
// Kanban board (gtd-kanban block) // Kanban board (gtd-kanban block)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function renderKanban(el, payload, contentScriptId) { function renderKanban(el, payload, contentScriptId, renderContext) {
el.innerHTML = ""; el.innerHTML = "";
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
@ -503,17 +516,36 @@
}; };
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
? {
context: renderContext,
hostNoteId: payload.hostNoteId,
root: el,
}
: null;
if (renderContext && renderContext.statusMessage) {
const status = document.createElement("p");
status.className = renderContext.statusError
? "gtd-kanban-move-status gtd-calendar-error"
: "gtd-kanban-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) {
wrapper.appendChild( wrapper.appendChild(
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction)
); );
}); });
} else { } else {
const board = layout.kind === "single" ? layout.board : emptyBoard; const board = layout.kind === "single" ? layout.board : emptyBoard;
wrapper.appendChild( wrapper.appendChild(
renderKanbanBoard(board, pageSize, detail, contentScriptId) renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction)
); );
} }
@ -532,9 +564,18 @@
} }
el.appendChild(wrapper); el.appendChild(wrapper);
if (renderContext && renderContext.restoreFocusCardId) {
setTimeout(function () {
if (!renderContext.restoreFocusCardId) return;
const fallback = el.querySelector(".gtd-kanban-move-status") || wrapper;
fallback.setAttribute("tabindex", "-1");
fallback.focus();
renderContext.restoreFocusCardId = null;
}, 0);
}
} }
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) { function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction) {
const section = document.createElement("section"); const section = document.createElement("section");
section.className = "gtd-notebook-view"; section.className = "gtd-notebook-view";
const heading = document.createElement("h4"); const heading = document.createElement("h4");
@ -543,37 +584,43 @@
section.appendChild(heading); section.appendChild(heading);
renderViewWarnings(section, group.warnings); renderViewWarnings(section, group.warnings);
section.appendChild( section.appendChild(
renderKanbanBoard(group.board, pageSize, detail, contentScriptId) renderKanbanBoard(group.board, pageSize, detail, contentScriptId, interaction)
); );
return section; return section;
} }
function renderKanbanBoard(board, pageSize, detail, contentScriptId) { function renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) {
const columns = document.createElement("div"); const columns = document.createElement("div");
columns.className = "gtd-kanban-columns"; columns.className = "gtd-kanban-columns";
const boardInteraction = interaction
? Object.assign({}, interaction, { board: columns })
: null;
columns.appendChild( columns.appendChild(
renderColumn("Backlog", board.backlog, pageSize, detail, contentScriptId) renderColumn("Backlog", "backlog", board.backlog, pageSize, detail, contentScriptId, boardInteraction)
); );
columns.appendChild( columns.appendChild(
renderColumn( renderColumn(
"In Progress", "In Progress",
"inProgress",
board.inProgress, board.inProgress,
pageSize, pageSize,
detail, detail,
contentScriptId contentScriptId,
boardInteraction
) )
); );
columns.appendChild( columns.appendChild(
renderColumn("Done", board.done, pageSize, detail, contentScriptId) renderColumn("Done", "done", board.done, pageSize, detail, contentScriptId, boardInteraction)
); );
return columns; return columns;
} }
function renderColumn(title, cards, pageSize, detail, contentScriptId) { function renderColumn(title, destination, cards, pageSize, detail, contentScriptId, interaction) {
const column = document.createElement("div"); const column = document.createElement("div");
column.className = "gtd-kanban-column"; column.className = "gtd-kanban-column";
column.setAttribute("data-kanban-destination", destination);
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "gtd-kanban-column-header"; header.className = "gtd-kanban-column-header";
@ -581,14 +628,14 @@
column.appendChild(header); column.appendChild(header);
column.appendChild( column.appendChild(
renderIncrementalCardList(cards, pageSize, detail, contentScriptId) renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
); );
return column; return column;
} }
let nextListMoreTooltipId = 1; let nextListMoreTooltipId = 1;
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId) { function renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) {
const list = document.createElement("div"); const list = document.createElement("div");
list.className = "gtd-kanban-cards"; list.className = "gtd-kanban-cards";
@ -633,7 +680,7 @@
cards.length cards.length
); );
for (let index = visibleCount; index < nextVisibleCount; index += 1) { for (let index = visibleCount; index < nextVisibleCount; index += 1) {
const card = renderCard(cards[index], detail, contentScriptId); const card = renderCard(cards[index], detail, contentScriptId, interaction, destination);
if (moreControl) list.insertBefore(card, moreControl); if (moreControl) list.insertBefore(card, moreControl);
else list.appendChild(card); else list.appendChild(card);
} }
@ -660,12 +707,75 @@
return list; return list;
} }
function renderCard(card, detail, contentScriptId) { function renderCard(card, detail, contentScriptId, interaction, sourceDestination) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "gtd-kanban-card gtd-calendar-clickable"; el.className = "gtd-kanban-card gtd-calendar-clickable";
if (detail === "hover") el.className += " gtd-kanban-card-hover"; if (detail === "hover") el.className += " gtd-kanban-card-hover";
if (card.bgColour) el.style.backgroundColor = card.bgColour; if (card.bgColour) el.style.backgroundColor = card.bgColour;
if (card.fgColour) el.style.color = card.fgColour; if (card.fgColour) el.style.color = card.fgColour;
let suppressOpenUntil = 0;
if (interaction) {
el.classList.add("gtd-kanban-card-editable");
el.setAttribute("tabindex", "0");
el.setAttribute("role", "link");
el.setAttribute("aria-label", "Open " + card.title);
const handle = document.createElement("button");
handle.type = "button";
handle.className = "gtd-kanban-drag-handle";
handle.textContent = "↕";
handle.setAttribute("aria-hidden", "true");
handle.setAttribute("tabindex", "-1");
handle.addEventListener("click", function (event) {
event.stopPropagation();
});
installKanbanPointerDrag(
handle,
el,
card,
sourceDestination,
interaction,
function () { suppressOpenUntil = Date.now() + 500; }
);
el.appendChild(handle);
const move = document.createElement("select");
move.className = "gtd-kanban-move-select";
move.setAttribute("aria-label", "Move " + card.title + " to");
move.disabled = interaction.context.pending;
[
["", "Move to…"],
["backlog", "Backlog"],
["inProgress", "In Progress"],
["done", "Done"],
].forEach(function (entry, index) {
const option = document.createElement("option");
option.value = entry[0];
option.textContent = entry[1];
if (index === 0) {
option.selected = true;
option.disabled = true;
}
if (entry[0] === sourceDestination) option.disabled = true;
move.appendChild(option);
});
move.addEventListener("click", function (event) { event.stopPropagation(); });
move.addEventListener("keydown", function (event) { event.stopPropagation(); });
move.addEventListener("change", function (event) {
event.stopPropagation();
if (!move.value || interaction.context.pending) return;
interaction.context.restoreFocusCardId = card.id;
submitKanbanMove(card, move.value, interaction);
});
el.appendChild(move);
if (interaction.context.restoreFocusCardId === card.id) {
setTimeout(function () {
if (!document.contains(move)) return;
move.focus();
interaction.context.restoreFocusCardId = null;
}, 0);
}
}
const titleRow = document.createElement("div"); const titleRow = document.createElement("div");
titleRow.className = "gtd-kanban-card-title"; titleRow.className = "gtd-kanban-card-title";
@ -698,12 +808,164 @@
} }
el.addEventListener("click", function () { el.addEventListener("click", function () {
if (Date.now() < suppressOpenUntil) {
return;
}
openNote(contentScriptId, card.id); openNote(contentScriptId, card.id);
}); });
if (interaction) {
el.addEventListener("keydown", function (event) {
if (event.target !== el) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openNote(contentScriptId, card.id);
}
});
}
return el; return el;
} }
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
let state = null;
const threshold = 6;
function clearTargets() {
interaction.board.querySelectorAll(".gtd-kanban-drop-target, .gtd-kanban-drop-active")
.forEach(function (column) {
column.classList.remove("gtd-kanban-drop-target", "gtd-kanban-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-kanban-column").forEach(function (column) {
if (column.getAttribute("data-kanban-destination") !== source) {
column.classList.add("gtd-kanban-drop-target");
}
});
}
interaction.board.querySelectorAll(".gtd-kanban-drop-active").forEach(function (column) {
column.classList.remove("gtd-kanban-drop-active");
});
const hit = document.elementFromPoint(event.clientX, event.clientY);
const target = hit && hit.closest ? hit.closest(".gtd-kanban-column") : null;
state.target = target && interaction.board.contains(target) &&
target.getAttribute("data-kanban-destination") !== source ? target : null;
if (state.target) state.target.classList.add("gtd-kanban-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-kanban-destination");
const shouldMove = state.dragging && destination && destination !== source;
cancel(event);
if (shouldMove) submitKanbanMove(card, destination, interaction);
});
handle.addEventListener("pointercancel", cancel);
handle.addEventListener("lostpointercapture", cancel);
}
function submitKanbanMove(card, destination, interaction) {
const context = interaction.context;
if (context.pending) return;
context.pending = true;
context.statusMessage = "Moving “" + card.title + "”…";
context.statusError = false;
showKanbanStatus(interaction.root, context.statusMessage, false);
interaction.root.classList.add("gtd-kanban-pending");
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = true; });
webviewApi.postMessage(context.contentScriptId, {
type: "moveKanbanCard",
view: "kanban",
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 refreshKanban(interaction.root, context);
}).catch(function () {
context.statusMessage = "The move could not be completed. Refresh and try again.";
context.statusError = true;
showKanbanStatus(interaction.root, context.statusMessage, true);
return refreshKanban(interaction.root, context);
}).finally(function () {
context.pending = false;
interaction.root.classList.remove("gtd-kanban-pending");
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = false; });
});
}
function showKanbanStatus(root, message, isError) {
let status = root.querySelector(".gtd-kanban-move-status");
if (!status) {
status = document.createElement("p");
const wrapper = root.querySelector(".gtd-kanban");
if (wrapper) wrapper.insertBefore(status, wrapper.firstChild);
}
status.className = isError
? "gtd-kanban-move-status gtd-calendar-error"
: "gtd-kanban-move-status";
status.textContent = message;
status.setAttribute("role", "status");
status.setAttribute("aria-live", isError ? "assertive" : "polite");
status.setAttribute("aria-atomic", "true");
}
function refreshKanban(root, context) {
const sequence = ++context.requestSequence;
return webviewApi.postMessage(context.contentScriptId, {
type: "getKanban",
rawConfig: context.rawConfig,
viewInstanceId: context.viewInstanceId,
}).then(function (payload) {
if (sequence !== context.requestSequence || !payload ||
payload.viewInstanceId !== context.viewInstanceId) return;
renderKanban(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, {

View File

@ -13,12 +13,19 @@ import collectMatrix from "./Gtd/collectMatrix";
import parseGanttConfig from "./Gtd/parseGanttConfig"; 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 resolveNotebook from "./Gtd/resolveNotebook"; import resolveNotebook from "./Gtd/resolveNotebook";
import { import {
emptyKanbanLayout, emptyKanbanLayout,
emptyMatrixLayout, emptyMatrixLayout,
} from "./Gtd/layoutPayload"; } from "./Gtd/layoutPayload";
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; import {
DataAdapter,
MutationAdapter,
RawFolder,
RawNote,
RawTag,
} from "./Gtd/types";
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
@ -61,6 +68,47 @@ const joplinAdapter: DataAdapter = {
}, },
}; };
/** Writable operations are kept separate from the read-only collector adapter. */
const joplinMutationAdapter: MutationAdapter = {
async getNote(noteId: string): Promise<RawNote | null> {
try {
return await joplin.data.get(["notes", noteId], {
fields: NOTE_FIELDS_WITH_BODY,
});
} catch (_error) {
return null;
}
},
async getNoteTags(noteId: string): Promise<RawTag[]> {
return fetchAllPages<RawTag>(["notes", noteId, "tags"], {
fields: ["id", "title"],
});
},
async getAllTags(): Promise<RawTag[]> {
return fetchAllPages<RawTag>(["tags"], { fields: ["id", "title"] });
},
async createTag(title: string): Promise<RawTag> {
return joplin.data.post(["tags"], null, { title });
},
async attachTag(tagId: string, noteId: string): Promise<void> {
await joplin.data.post(["tags", tagId, "notes"], null, { id: noteId });
},
async detachTag(tagId: string, noteId: string): Promise<void> {
await joplin.data.delete(["tags", tagId, "notes", noteId]);
},
async setTodoCompleted(noteId: string, completedTime: number): Promise<void> {
await joplin.data.put(["notes", noteId], null, {
todo_completed: completedTime,
});
},
};
/** /**
* Advisory threshold: under `scope: all`, if more notes than this were scanned * Advisory threshold: under `scope: all`, if more notes than this were scanned
* we surface a soft warning so a huge profile-wide scan is visible. Advisory * we surface a soft warning so a huge profile-wide scan is visible. Advisory
@ -134,6 +182,8 @@ joplin.plugins.register({
return handleGetEvents(message); return handleGetEvents(message);
case "getKanban": case "getKanban":
return handleGetKanban(message); return handleGetKanban(message);
case "moveKanbanCard":
return handleMoveKanbanCard(message);
case "getMatrix": case "getMatrix":
return handleGetMatrix(message); return handleGetMatrix(message);
case "getGantt": case "getGantt":
@ -148,6 +198,28 @@ joplin.plugins.register({
}, },
}); });
async function handleMoveKanbanCard(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-kanban: could not validate move host", hostError);
}
const result = await validateAndMoveKanbanCard(
message,
host,
joplinAdapter,
joplinMutationAdapter,
Date.now,
(error) => console.warn("gtd-kanban: move failed", error)
);
if (result.status !== "success") {
console.warn("gtd-kanban: move rejected", result.status, result.message);
}
return result;
}
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 = {};
@ -222,7 +294,10 @@ async function handleGetEvents(message: { rawConfig?: string }) {
}; };
} }
async function handleGetKanban(message: { rawConfig?: string }) { async function handleGetKanban(message: {
rawConfig?: string;
viewInstanceId?: string;
}) {
let rawParsed: any = {}; let rawParsed: any = {};
let configError: string | null = null; let configError: string | null = null;
try { try {
@ -252,6 +327,9 @@ async function handleGetKanban(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 kanban.", configError || "Could not determine which note contains this kanban.",
@ -275,6 +353,9 @@ async function handleGetKanban(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: [

View File

@ -20,11 +20,26 @@ describe("parseKanbanConfig", () => {
doneTag: "done", doneTag: "done",
cardDetail: "hover", cardDetail: "hover",
pageSize: 10, pageSize: 10,
editable: false,
doneWindow: 7, doneWindow: 7,
}); });
expect(c.warnings).toHaveLength(0); expect(c.warnings).toHaveLength(0);
}); });
test("editable requires a yes/no scalar and defaults to read-only", () => {
expect(parseKanbanConfig({}).editable).toBe(false);
expect(parseKanbanConfig({ editable: " YES " }).editable).toBe(true);
expect(parseKanbanConfig({ editable: "No" }).editable).toBe(false);
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
const config = parseKanbanConfig({ editable: value });
expect(config.editable).toBe(false);
expect(config.warnings).toEqual([
`Invalid editable "${String(value)}" (using "no")`,
]);
}
});
test("parses notes independently and rejects invalid or singular forms", () => { test("parses notes independently and rejects invalid or singular forms", () => {
expect(parseKanbanConfig({ notes: " NONE " }).notes).toBe("none"); expect(parseKanbanConfig({ 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" }]) {

View File

@ -0,0 +1,147 @@
import { validateAndMoveKanbanCard } from "../../Gtd/kanbanMoveHandler";
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()) {
const writes: string[] = [];
const read: DataAdapter = {
getFolders: async () => folders,
getNotesInFolder: async () => [],
getNoteTagTitles: async () => [],
};
const mutation: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => [],
getAllTags: async () => [{ id: "progress", title: "in-progress" }],
createTag: async (title) => ({ id: title, title }),
attachTag: async (tagId) => { writes.push(`attach:${tagId}`); },
detachTag: async (tagId) => { writes.push(`detach:${tagId}`); },
setTodoCompleted: async (_noteId, value) => { writes.push(`complete:${value}`); },
};
return { read, mutation, writes };
}
function intent(overrides: Record<string, unknown> = {}) {
return {
type: "moveKanbanCard",
view: "kanban",
hostNoteId: "host",
cardId: "card",
destination: "inProgress",
rawConfig: "editable: yes\nnotes: all\ntodos: all",
viewInstanceId: "view-1",
...overrides,
};
}
const host = { id: "host", parentId: "board" };
describe("validateAndMoveKanbanCard", () => {
test("accepts a constrained editable move", async () => {
const { read, mutation, writes } = setup();
await expect(validateAndMoveKanbanCard(intent(), host, read, mutation)).resolves.toEqual({
status: "success", viewInstanceId: "view-1", changed: true,
});
expect(writes).toEqual(["attach:progress"]);
});
test.each([
{},
intent({ view: "matrix" }),
intent({ destination: "urgent" }),
intent({ cardId: "" }),
intent({ rawConfig: { editable: "yes" } }),
intent({ patch: { todo_completed: 1 } }),
intent({ patch: { todo_completed: 1 }, destination: "arbitrary" }),
])("rejects malformed or unconstrained input without writes", async (request) => {
const { read, mutation, writes } = setup();
const result = await validateAndMoveKanbanCard(request, host, read, mutation);
expect(result.status).toBe("error");
expect(writes).toEqual([]);
});
test("requires the currently selected host and matching host identity", async () => {
for (const selected of [null, { id: "other", parentId: "board" }]) {
const { read, mutation, writes } = setup();
const result = await validateAndMoveKanbanCard(intent(), selected, read, mutation);
expect(result.status).toBe("stale");
expect(writes).toEqual([]);
}
});
test.each([
["", "error"],
["editable: no", "error"],
["editable: maybe", "error"],
["editable: [yes", "stale"],
] as const)("rejects non-editable or invalid config %p", async (rawConfig, status) => {
const { read, mutation, writes } = setup();
const result = await validateAndMoveKanbanCard(intent({ rawConfig }), host, read, mutation);
expect(result.status).toBe(status);
expect(writes).toEqual([]);
});
test.each([
[card({ id: "host" }), "dashboard"],
[card({ parent_id: "outside" }), "scope"],
[card({ body: "plain" }), "gtd block"],
])("rejects stale note state before writes", async (current, message) => {
const { read, mutation, writes } = setup(current);
const result = await validateAndMoveKanbanCard(intent(), host, read, mutation);
expect(result).toMatchObject({ status: "stale" });
expect((result as any).message).toContain(message);
expect(writes).toEqual([]);
});
test("resolves children scope and notebook targeting from fresh folders", async () => {
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
const result = await validateAndMoveKanbanCard(
intent({ rawConfig: "editable: yes\nnotebook: Board\nscope: children" }),
host, read, mutation
);
expect(result.status).toBe("success");
expect(writes).toEqual(["attach:progress"]);
});
test.each([
[card({ is_todo: 0 }), "editable: yes\nnotes: none\ntodos: all"],
[card({ is_todo: 1, body: "plain" }), "editable: yes\nnotes: all\ntodos: gtd-only"],
[card({ is_todo: 1 }), "editable: yes\nnotes: all\ntodos: none"],
])("rechecks type filters and opt-in on fresh data", async (current, rawConfig) => {
const { read, mutation, writes } = setup(current);
const result = await validateAndMoveKanbanCard(intent({ rawConfig }), host, read, mutation);
expect(result.status).toBe("stale");
expect(writes).toEqual([]);
});
test("returns a safe stale result for API failures", async () => {
const { read, mutation, writes } = setup();
read.getFolders = async () => { throw new Error("private diagnostic"); };
const result = await validateAndMoveKanbanCard(intent(), host, read, mutation);
expect(result).toEqual({
status: "error",
viewInstanceId: "view-1",
message: "Could not apply the move. Refresh and try again.",
});
expect(writes).toEqual([]);
});
});

View File

@ -0,0 +1,138 @@
import { moveKanbanItem } from "../../Gtd/kanbanMutation";
import { MutationAdapter, RawNote, RawTag } from "../../Gtd/types";
function note(overrides: Partial<RawNote> = {}): RawNote {
return {
id: "card",
title: "Card",
parent_id: "folder",
is_todo: 0,
todo_due: 123,
todo_completed: 0,
updated_time: 456,
body: "```gtd\n```",
...overrides,
};
}
function fakeAdapter(current: RawNote | null, attached: RawTag[] = [], all: RawTag[] = []) {
const calls: string[] = [];
let created = 0;
const adapter: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => attached.slice(),
getAllTags: async () => all.slice(),
createTag: async (title) => {
calls.push(`create:${title}`);
return { id: `created-${++created}`, title };
},
attachTag: async (tagId, noteId) => {
calls.push(`attach:${tagId}:${noteId}`);
},
detachTag: async (tagId, noteId) => {
calls.push(`detach:${tagId}:${noteId}`);
},
setTodoCompleted: async (noteId, value) => {
calls.push(`complete:${noteId}:${value}`);
},
};
return { adapter, calls };
}
const config = { inProgressTag: "in-progress", doneTag: "done" };
describe("moveKanbanItem", () => {
test.each([
["backlog", ["done", "in-progress"], ["detach:d:card", "detach:p:card"]],
["inProgress", ["done"], ["attach:p:card", "detach:d:card"]],
["done", ["in-progress"], ["attach:d:card", "detach:p:card"]],
] as const)("canonicalizes an ordinary note to %s", async (destination, titles, expected) => {
const tags = titles.map((title) => ({ id: title === "done" ? "d" : "p", title }));
const { adapter, calls } = fakeAdapter(note(), tags, [
{ id: "p", title: "In-Progress" },
{ id: "d", title: "Done" },
]);
const result = await moveKanbanItem(adapter, "card", destination, config);
expect(calls).toEqual(expected);
expect(result.changed).toBe(true);
});
test.each([
["backlog", 99, ["complete:card:0", "detach:p:card"]],
["inProgress", 99, ["complete:card:0"]],
["done", 0, ["complete:card:777", "detach:p:card"]],
] as const)("canonicalizes a native to-do to %s", async (destination, completed, expected) => {
const { adapter, calls } = fakeAdapter(
note({ is_todo: 1, todo_completed: completed }),
[{ id: "p", title: "in-progress" }]
);
await moveKanbanItem(adapter, "card", destination, config, () => 777);
expect(calls).toEqual(expected);
});
test("uses an existing case variant deterministically and never creates a duplicate", async () => {
const { adapter, calls } = fakeAdapter(note(), [], [
{ id: "z", title: "DONE" },
{ id: "a", title: "Done" },
]);
await moveKanbanItem(adapter, "card", "done", config);
expect(calls).toEqual(["attach:a:card"]);
});
test("creates a missing custom tag and preserves unrelated note fields", async () => {
const current = note({ title: "Keep", parent_id: "keep-folder", todo_due: 42 });
const { adapter, calls } = fakeAdapter(current);
const result = await moveKanbanItem(adapter, "card", "inProgress", {
inProgressTag: "doing",
doneTag: "finished",
});
expect(calls).toEqual(["create:doing", "attach:created-1:card"]);
expect(result.note).toBe(current);
});
test("removes every attached duplicate of an unwanted tag", async () => {
const { adapter, calls } = fakeAdapter(note(), [
{ id: "z", title: "DONE" },
{ id: "a", title: "done" },
{ id: "other", title: "other" },
]);
await moveKanbanItem(adapter, "card", "backlog", config);
expect(calls).toEqual(["detach:a:card", "detach:z:card"]);
});
test("already canonical states are idempotent", async () => {
const { adapter, calls } = fakeAdapter(note(), [{ id: "p", title: "in-progress" }]);
const result = await moveKanbanItem(adapter, "card", "inProgress", config);
expect(calls).toEqual([]);
expect(result.changed).toBe(false);
});
test("does not use done-tag to complete native to-dos", async () => {
const { adapter, calls } = fakeAdapter(note({ is_todo: 1 }), [
{ id: "d", title: "done" },
]);
await moveKanbanItem(adapter, "card", "done", config, () => 12);
expect(calls).toEqual(["complete:card:12"]);
});
test("fails before writes when the card disappeared", async () => {
const { adapter, calls } = fakeAdapter(null);
await expect(moveKanbanItem(adapter, "card", "done", config)).rejects.toThrow(
"no longer exists"
);
expect(calls).toEqual([]);
});
test("surfaces partial write failures without stale compensation", async () => {
const { adapter, calls } = fakeAdapter(note(), [
{ id: "p", title: "in-progress" },
], [{ id: "d", title: "done" }]);
adapter.detachTag = async () => {
throw new Error("detach failed");
};
await expect(moveKanbanItem(adapter, "card", "done", config)).rejects.toThrow(
"detach failed"
);
expect(calls).toEqual(["attach:d:card"]);
});
});

View File

@ -0,0 +1,17 @@
import { isKanbanDestination } from "../../Gtd/moveProtocol";
describe("kanban move protocol", () => {
test.each(["backlog", "inProgress", "done"])(
"accepts fixed destination %s",
(destination) => {
expect(isKanbanDestination(destination)).toBe(true);
}
);
test.each(["", "in-progress", "matrix", null, 1, { tag: "done" }])(
"rejects arbitrary destination %p",
(destination) => {
expect(isKanbanDestination(destination)).toBe(false);
}
);
});