diff --git a/CHANGELOG.md b/CHANGELOG.md index eebd423..84445e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 ### Card underline cleanup diff --git a/DRAG_DROP.md b/DRAG_DROP.md new file mode 100644 index 0000000..f774d81 --- /dev/null +++ b/DRAG_DROP.md @@ -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. diff --git a/README.md b/README.md index 31e3b00..12e4f93 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ done-tag: done card-detail: hover page-size: 10 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. | | `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. | +| `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: @@ -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 `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) @@ -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. -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 @@ -365,7 +383,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop ```bash npm install -npm test # 251 unit tests +npm test # 295 unit tests npm run dist # builds publish/*.jpl ``` diff --git a/SLICE14.md b/SLICE14.md new file mode 100644 index 0000000..d30928b --- /dev/null +++ b/SLICE14.md @@ -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 1–7 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. diff --git a/SLIDE15.md b/SLIDE15.md new file mode 100644 index 0000000..016d53b --- /dev/null +++ b/SLIDE15.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 3848d5f..0f74803 100644 --- a/SPEC.md +++ b/SPEC.md @@ -90,6 +90,7 @@ done-tag: done # ordinary-note completion; default: "done" card-detail: hover # hover | always | none (default: hover) page-size: 10 # positive integer (default: 10) 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**. @@ -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 batch per **List more** click. Expansion is independent per column and resets 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) @@ -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`. - **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. +- **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. - **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. @@ -275,7 +295,8 @@ Upstream is a single synchronous markdown-it content script with no data API acc │ │ depending on block type. Builds the │ │ calendar / board / matrix / Gantt DOM from │ │ returned payload. Click → postMessage -│ │ ({type: "openNote", noteId}). +│ │ ({type: "openNote", noteId}); editable +│ │ kanban sends constrained move intent only. └──────────────┬──────────────┘ │ postMessage / onMessage ┌──────────────▼──────────────┐ @@ -300,6 +321,9 @@ Upstream is a single synchronous markdown-it content script with no data API acc │ │ collectGantt.ts) │ │ • handles openNote via │ │ joplin.commands.execute +│ │ • reparses editable kanban config, refetches +│ │ 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 revisiting if a `children`-scope board on a large tree feels slow, especially 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 @@ -474,19 +501,17 @@ remains eligibility-gated. Automated validation passed at 185 tests, the production JPL built successfully, and manual Joplin acceptance passed 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. - -- 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. +### Future authoring — create from view **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. diff --git a/TASKS.md b/TASKS.md index e956e2d..6143340 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 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] 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 8–13 are complete. SLICE14 is the next implementation slice. ## 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] Include both note and to-do cards in grouping tests. - [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 `scope: all` still produce one view with unchanged behavior. @@ -176,16 +180,27 @@ Implementation order: SLICE8, then SLICE9, then SLICE10. grandchildren in Joplin. - [x] Confirm headings, ordering, empty-group handling, no duplication, sorting, 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 -- [ ] Run the complete automated suite after all three slices are integrated. -- [ ] Build the final production `.jpl` and record its path and version. +- [x] Run the complete automated suite after all three slices are integrated. +- [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 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] Record manual Joplin acceptance without conflating it with automated checks. -- [ ] Re-run the repository-wide prohibited-reference audit. -- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the +- [x] Re-run the repository-wide prohibited-reference audit. +- [x] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the 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 1–7 passed; explicit manual Joplin acceptance was + received on 2026-07-31. Slice 15 remains not started. diff --git a/src/Gtd/kanbanMoveHandler.ts b/src/Gtd/kanbanMoveHandler.ts new file mode 100644 index 0000000..267df9c --- /dev/null +++ b/src/Gtd/kanbanMoveHandler.ts @@ -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, + config: ReturnType +): 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 { + const candidate = intent as Partial | null; + const viewInstanceId = validId(candidate?.viewInstanceId) + ? candidate.viewInstanceId + : "unknown"; + const allowedKeys = new Set([ + "type", "view", "hostNoteId", "cardId", "destination", "rawConfig", "viewInstanceId", + ]); + if ( + !candidate || + Array.isArray(candidate) || + Object.keys(candidate).some((key) => !allowedKeys.has(key)) || + candidate.type !== "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."); + } +} diff --git a/src/Gtd/kanbanMutation.ts b/src/Gtd/kanbanMutation.ts new file mode 100644 index 0000000..0e08167 --- /dev/null +++ b/src/Gtd/kanbanMutation.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/src/Gtd/moveProtocol.ts b/src/Gtd/moveProtocol.ts new file mode 100644 index 0000000..0c1f06b --- /dev/null +++ b/src/Gtd/moveProtocol.ts @@ -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); +} diff --git a/src/Gtd/parseKanbanConfig.ts b/src/Gtd/parseKanbanConfig.ts index a3f76f1..44bf045 100644 --- a/src/Gtd/parseKanbanConfig.ts +++ b/src/Gtd/parseKanbanConfig.ts @@ -34,6 +34,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig { "done-tag", "card-detail", "page-size", + "editable", "done-window", ]; 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); + 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 { title, scopeDepth, @@ -199,6 +213,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig { doneTag, cardDetail, pageSize, + editable, doneWindow, warnings, }; diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 9bcc678..34f7d54 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -103,6 +103,22 @@ export interface DataAdapter { getNoteTagTitles(noteId: string): Promise; } +export interface RawTag { + id: string; + title: string; +} + +/** Narrow writable Joplin boundary used only by validated workflow moves. */ +export interface MutationAdapter { + getNote(noteId: string): Promise; + getNoteTags(noteId: string): Promise; + getAllTags(): Promise; + createTag(title: string): Promise; + attachTag(tagId: string, noteId: string): Promise; + detachTag(tagId: string, noteId: string): Promise; + setTodoCompleted(noteId: string, completedTime: number): Promise; +} + /** * 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. @@ -145,11 +161,32 @@ export interface KanbanConfig { cardDetail: "hover" | "always" | "none"; /** Cards initially shown, and revealed per "List more" action. */ pageSize: number; + /** Whether this board explicitly opts in to persisted workflow moves. */ + editable: boolean; /** Days; Infinity means "all". */ doneWindow: number; 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. */ export interface KanbanCard { id: string; diff --git a/src/event-calendar.css b/src/event-calendar.css index c95be62..b0dc1be 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -389,6 +389,7 @@ } .gtd-kanban-card { + position: relative; border: 1px solid rgba(128, 128, 128, 0.4); border-radius: 4px; padding: 0.35em 0.5em; @@ -399,6 +400,78 @@ 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 { border-color: rgba(20, 20, 20, 0.9); } diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 67dbda0..d600244 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -18,7 +18,7 @@ ".gtd-calendar-placeholder:not([data-gtd-processed])" ); - placeholders.forEach(function (el) { + placeholders.forEach(function (el, placeholderIndex) { el.setAttribute("data-gtd-processed", "true"); const contentScriptId = el.getAttribute("data-content-script-id"); @@ -26,6 +26,10 @@ const rawConfig = decodeURIComponent( 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 ( typeof webviewApi === "undefined" || @@ -48,10 +52,19 @@ ? "getGantt" : "getEvents"; + const renderContext = { + contentScriptId: contentScriptId, + rawConfig: rawConfig, + viewInstanceId: viewInstanceId, + requestSequence: 0, + pending: false, + }; + webviewApi .postMessage(contentScriptId, { type: messageType, rawConfig: rawConfig, + viewInstanceId: viewInstanceId, }) .then(function (payload) { if (!payload) { @@ -59,7 +72,7 @@ return; } if (blockType === "kanban") { - renderKanban(el, payload, contentScriptId); + renderKanban(el, payload, contentScriptId, renderContext); } else if (blockType === "matrix") { renderMatrix(el, payload, contentScriptId); } else if (blockType === "gantt") { @@ -465,7 +478,7 @@ // Kanban board (gtd-kanban block) // ------------------------------------------------------------------ - function renderKanban(el, payload, contentScriptId) { + function renderKanban(el, payload, contentScriptId, renderContext) { el.innerHTML = ""; const wrapper = document.createElement("div"); @@ -503,17 +516,36 @@ }; const detail = payload.cardDetail || "hover"; 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) { layout.groups.forEach(function (group) { wrapper.appendChild( - renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) + renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction) ); }); } else { const board = layout.kind === "single" ? layout.board : emptyBoard; wrapper.appendChild( - renderKanbanBoard(board, pageSize, detail, contentScriptId) + renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) ); } @@ -532,9 +564,18 @@ } 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"); section.className = "gtd-notebook-view"; const heading = document.createElement("h4"); @@ -543,37 +584,43 @@ section.appendChild(heading); renderViewWarnings(section, group.warnings); section.appendChild( - renderKanbanBoard(group.board, pageSize, detail, contentScriptId) + renderKanbanBoard(group.board, pageSize, detail, contentScriptId, interaction) ); return section; } - function renderKanbanBoard(board, pageSize, detail, contentScriptId) { + function renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) { const columns = document.createElement("div"); columns.className = "gtd-kanban-columns"; + const boardInteraction = interaction + ? Object.assign({}, interaction, { board: columns }) + : null; columns.appendChild( - renderColumn("Backlog", board.backlog, pageSize, detail, contentScriptId) + renderColumn("Backlog", "backlog", board.backlog, pageSize, detail, contentScriptId, boardInteraction) ); columns.appendChild( renderColumn( "In Progress", + "inProgress", board.inProgress, pageSize, detail, - contentScriptId + contentScriptId, + boardInteraction ) ); columns.appendChild( - renderColumn("Done", board.done, pageSize, detail, contentScriptId) + renderColumn("Done", "done", board.done, pageSize, detail, contentScriptId, boardInteraction) ); return columns; } - function renderColumn(title, cards, pageSize, detail, contentScriptId) { + function renderColumn(title, destination, cards, pageSize, detail, contentScriptId, interaction) { const column = document.createElement("div"); column.className = "gtd-kanban-column"; + column.setAttribute("data-kanban-destination", destination); const header = document.createElement("div"); header.className = "gtd-kanban-column-header"; @@ -581,14 +628,14 @@ column.appendChild(header); column.appendChild( - renderIncrementalCardList(cards, pageSize, detail, contentScriptId) + renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) ); return column; } let nextListMoreTooltipId = 1; - function renderIncrementalCardList(cards, pageSize, detail, contentScriptId) { + function renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) { const list = document.createElement("div"); list.className = "gtd-kanban-cards"; @@ -633,7 +680,7 @@ cards.length ); 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); else list.appendChild(card); } @@ -660,12 +707,75 @@ return list; } - function renderCard(card, detail, contentScriptId) { + function renderCard(card, detail, contentScriptId, interaction, sourceDestination) { const el = document.createElement("div"); el.className = "gtd-kanban-card gtd-calendar-clickable"; if (detail === "hover") el.className += " gtd-kanban-card-hover"; if (card.bgColour) el.style.backgroundColor = card.bgColour; 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"); titleRow.className = "gtd-kanban-card-title"; @@ -698,12 +808,164 @@ } el.addEventListener("click", function () { + if (Date.now() < suppressOpenUntil) { + return; + } 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; } + 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) { const date = isoToLocalDate(dateISO); return date.toLocaleDateString(undefined, { diff --git a/src/index.ts b/src/index.ts index 4acbaf5..e59c98c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,12 +13,19 @@ import collectMatrix from "./Gtd/collectMatrix"; import parseGanttConfig from "./Gtd/parseGanttConfig"; import collectGantt from "./Gtd/collectGantt"; import matrixLabels from "./Gtd/matrixLabels"; +import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler"; import resolveNotebook from "./Gtd/resolveNotebook"; import { emptyKanbanLayout, emptyMatrixLayout, } 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"; @@ -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 { + try { + return await joplin.data.get(["notes", noteId], { + fields: NOTE_FIELDS_WITH_BODY, + }); + } catch (_error) { + return null; + } + }, + + async getNoteTags(noteId: string): Promise { + return fetchAllPages(["notes", noteId, "tags"], { + fields: ["id", "title"], + }); + }, + + async getAllTags(): Promise { + return fetchAllPages(["tags"], { fields: ["id", "title"] }); + }, + + async createTag(title: string): Promise { + return joplin.data.post(["tags"], null, { title }); + }, + + async attachTag(tagId: string, noteId: string): Promise { + await joplin.data.post(["tags", tagId, "notes"], null, { id: noteId }); + }, + + async detachTag(tagId: string, noteId: string): Promise { + await joplin.data.delete(["tags", tagId, "notes", noteId]); + }, + + async setTodoCompleted(noteId: string, completedTime: number): Promise { + await joplin.data.put(["notes", noteId], null, { + todo_completed: completedTime, + }); + }, +}; + /** * 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 @@ -134,6 +182,8 @@ joplin.plugins.register({ return handleGetEvents(message); case "getKanban": return handleGetKanban(message); + case "moveKanbanCard": + return handleMoveKanbanCard(message); case "getMatrix": return handleGetMatrix(message); 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 }) { // 1. Parse the gtd-calendar config. 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 configError: string | null = null; try { @@ -252,6 +327,9 @@ async function handleGetKanban(message: { rawConfig?: string }) { title: config.title, cardDetail: config.cardDetail, pageSize: config.pageSize, + editable: config.editable, + hostNoteId: null, + viewInstanceId: message.viewInstanceId || null, scopeAll: config.scopeAll, configError: configError || "Could not determine which note contains this kanban.", @@ -275,6 +353,9 @@ async function handleGetKanban(message: { rawConfig?: string }) { title: config.title, cardDetail: config.cardDetail, pageSize: config.pageSize, + editable: config.editable, + hostNoteId: sourceNote.id, + viewInstanceId: message.viewInstanceId || null, scopeAll: config.scopeAll, configError, warnings: [ diff --git a/src/tests/Gtd/kanban.test.ts b/src/tests/Gtd/kanban.test.ts index 4144193..cf55544 100644 --- a/src/tests/Gtd/kanban.test.ts +++ b/src/tests/Gtd/kanban.test.ts @@ -9,7 +9,7 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types"; describe("parseKanbanConfig", () => { test("applies SPEC defaults", () => { const c = parseKanbanConfig({}); - expect(c).toMatchObject({ + expect(c).toMatchObject({ title: null, scopeDepth: 0, notes: "all", @@ -20,11 +20,26 @@ describe("parseKanbanConfig", () => { doneTag: "done", cardDetail: "hover", pageSize: 10, + editable: false, doneWindow: 7, }); 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", () => { expect(parseKanbanConfig({ notes: " NONE " }).notes).toBe("none"); for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) { diff --git a/src/tests/Gtd/kanbanMoveHandler.test.ts b/src/tests/Gtd/kanbanMoveHandler.test.ts new file mode 100644 index 0000000..10721cf --- /dev/null +++ b/src/tests/Gtd/kanbanMoveHandler.test.ts @@ -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 { + 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 = {}) { + 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([]); + }); +}); diff --git a/src/tests/Gtd/kanbanMutation.test.ts b/src/tests/Gtd/kanbanMutation.test.ts new file mode 100644 index 0000000..f07a168 --- /dev/null +++ b/src/tests/Gtd/kanbanMutation.test.ts @@ -0,0 +1,138 @@ +import { moveKanbanItem } from "../../Gtd/kanbanMutation"; +import { MutationAdapter, RawNote, RawTag } from "../../Gtd/types"; + +function note(overrides: Partial = {}): 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"]); + }); +}); diff --git a/src/tests/Gtd/moveProtocol.test.ts b/src/tests/Gtd/moveProtocol.test.ts new file mode 100644 index 0000000..3ab588d --- /dev/null +++ b/src/tests/Gtd/moveProtocol.test.ts @@ -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); + } + ); +});