SLICE14 opt-in kanban drag and drop
This commit is contained in:
parent
7a02834bec
commit
c89b90839f
23
CHANGELOG.md
23
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.
|
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
|
||||||
|
|
||||||
|
## 2.0.0 — Unreleased
|
||||||
|
|
||||||
|
### Opt-in editable Kanban
|
||||||
|
|
||||||
|
- Added `editable: yes | no` to `gtd-kanban`, defaulting to `no` so every
|
||||||
|
existing board remains read-only.
|
||||||
|
- Editable desktop boards support pointer drag handles and keyboard **Move to…**
|
||||||
|
controls for Backlog, In Progress, and Done.
|
||||||
|
- Ordinary-note moves persist configured completion/progress tags; native to-do
|
||||||
|
moves persist native completion plus the configured progress tag. Unrelated
|
||||||
|
fields, tags, content, dates, note type, and notebook ownership are preserved.
|
||||||
|
- The webview submits constrained semantic intent. The main plugin process
|
||||||
|
reparses configuration, refetches host/scope/item/tag state, derives minimal
|
||||||
|
writes, rejects stale or malformed requests, and refreshes only the initiating
|
||||||
|
view from canonical state.
|
||||||
|
- Added pending/error feedback, pointer cancellation and movement thresholds,
|
||||||
|
duplicate-request guards, request sequencing, live announcements, and focus
|
||||||
|
restoration. Sorting and pagination remain authoritative after a move.
|
||||||
|
- Pointer and keyboard workflows still require final manual Joplin acceptance;
|
||||||
|
v2.0.0 has not been released or published.
|
||||||
|
- Automated validation: 295 tests across 19 suites, TypeScript, webview syntax,
|
||||||
|
whitespace, prohibited-reference checks, and production JPL inspection passed.
|
||||||
|
|
||||||
## 1.0.0 — 2026-07-28
|
## 1.0.0 — 2026-07-28
|
||||||
|
|
||||||
### Card underline cleanup
|
### Card underline cleanup
|
||||||
|
|||||||
142
DRAG_DROP.md
Normal file
142
DRAG_DROP.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# Drag-and-drop v2.0.0 — next-session handoff
|
||||||
|
|
||||||
|
Use this document as the opening prompt and state record for the next development
|
||||||
|
session. The planning source of truth is `SLICE14.md`, followed by `SLIDE15.md`.
|
||||||
|
|
||||||
|
## Current repository state
|
||||||
|
|
||||||
|
- v1.0.0 is released and published to npm.
|
||||||
|
- Release commit: `7a02834 Release-v1.0.0` on `master`.
|
||||||
|
- At the time this handoff was written, the repository had no pre-existing
|
||||||
|
uncommitted changes before these three planning files were added.
|
||||||
|
- The v1 production artifact is
|
||||||
|
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`.
|
||||||
|
- v1 automated validation recorded 251 Jest tests across 16 suites, plus
|
||||||
|
TypeScript, webview syntax, build, package inspection, whitespace, and
|
||||||
|
prohibited-reference checks.
|
||||||
|
- The user manually accepted the v1 kanban/matrix behavior and card styling.
|
||||||
|
- Do not describe old v1 validation as proof that new v2 mutation behavior works.
|
||||||
|
|
||||||
|
## Confirmed v2 decisions
|
||||||
|
|
||||||
|
- Drag and drop is technically feasible because cards are DOM elements in the
|
||||||
|
Joplin desktop rendered-note webview, not an HTML canvas.
|
||||||
|
- v2.0.0 begins with writable workflow views because persisted mutation,
|
||||||
|
concurrency, recovery, and accessibility are a material behavior change.
|
||||||
|
- Every existing block remains read-only by default.
|
||||||
|
- Mutation requires the exact opt-in `editable: yes`.
|
||||||
|
- Pointer drag and keyboard **Move to…** are both required.
|
||||||
|
- The webview sends constrained semantic intent; only the main plugin process
|
||||||
|
fetches fresh state and derives Joplin data/tag mutations.
|
||||||
|
- Kanban moves persist note workflow tags or native to-do completion according
|
||||||
|
to `SLICE14.md`.
|
||||||
|
- Eisenhower moves persist urgent/important tag combinations according to
|
||||||
|
`SLIDE15.md`.
|
||||||
|
- Completion wins over in-progress in normal collection. An intentional kanban
|
||||||
|
move canonicalizes conflicting workflow tags.
|
||||||
|
- Eisenhower keeps urgent/important axes but adopts Skeleton quadrant names:
|
||||||
|
Do Next, Scheduled, On Deck, and Backlog. “Eliminate” must disappear.
|
||||||
|
- Skeleton remains supported and read-only for v2.0.0; do not remove it.
|
||||||
|
- No free-form card ordering or cross-notebook dragging is included.
|
||||||
|
|
||||||
|
## Required order of work
|
||||||
|
|
||||||
|
1. Read every repository `*.md` file before implementation, with special
|
||||||
|
attention to `SPEC.md`, `TASKS.md`, `SLICE11.md`, `SLICE12.md`, `SLICE13.md`,
|
||||||
|
`SLICE14.md`, and `SLIDE15.md`.
|
||||||
|
2. Inspect `git status --short`, the latest commit, all package/manifest versions,
|
||||||
|
and the current test scripts. Preserve unrelated user changes.
|
||||||
|
3. Execute `SLICE14.md` phase by phase. Update its checkboxes and Status after
|
||||||
|
each completed phase and whenever pausing.
|
||||||
|
4. Keep implementation, focused automation, full automation, JPL packaging, and
|
||||||
|
manual Joplin acceptance as separate boundaries.
|
||||||
|
5. Stop for the user's explicit SLICE 14 manual Joplin sign-off before marking
|
||||||
|
its manual phase complete or beginning SLICE 15, unless the user explicitly
|
||||||
|
asks to continue without that checkpoint.
|
||||||
|
6. Execute `SLIDE15.md` only after the SLICE 14 foundation is accepted. Update it
|
||||||
|
with the same state discipline.
|
||||||
|
7. After both slices pass automation and explicit manual acceptance, complete
|
||||||
|
consolidated v2.0.0 documentation/version/release preparation. Do not publish
|
||||||
|
or push unless the user asks.
|
||||||
|
|
||||||
|
## Opening prompt for the next session
|
||||||
|
|
||||||
|
Copy the following request into the next session:
|
||||||
|
|
||||||
|
> Please read all Markdown files in the repository, then use `DRAG_DROP.md` as
|
||||||
|
> the handoff and begin `SLICE14.md` Phase 1. Work phase by phase, update the
|
||||||
|
> slice file after every completed phase and whenever you pause, and keep
|
||||||
|
> implementation, automated validation, packaging, and manual Joplin acceptance
|
||||||
|
> separate. Do not begin `SLIDE15.md` until SLICE 14 has passed its required
|
||||||
|
> acceptance boundary.
|
||||||
|
|
||||||
|
## Baseline files to inspect before editing
|
||||||
|
|
||||||
|
- `src/index.ts` — content-script message registration and Joplin data adapter.
|
||||||
|
- `src/Gtd/types.ts` — configs, cards, boards, layouts, and adapter types.
|
||||||
|
- `src/Gtd/parseKanbanConfig.ts` and `src/Gtd/parseMatrixConfig.ts` — option
|
||||||
|
parsing and warnings.
|
||||||
|
- `src/Gtd/collectKanban.ts` and `src/Gtd/collectMatrix.ts` — eligibility,
|
||||||
|
completion, tags, grouping, sorting, warnings, and counts.
|
||||||
|
- `src/Gtd/buildKanbanCard.ts` — shared note/to-do card normalization.
|
||||||
|
- `src/Gtd/matrixLabels.ts` — canonical matrix labels.
|
||||||
|
- `src/gtd-calendar-webview.js` — DOM rendering, click handling, pagination, and
|
||||||
|
the future pointer/keyboard interaction layer.
|
||||||
|
- `src/event-calendar.css` — shared kanban/matrix card and quadrant styling.
|
||||||
|
- `src/tests/Gtd/kanban.test.ts`, `matrix.test.ts`, parser tests, grouping tests,
|
||||||
|
field-hint tests, and webview/render checks.
|
||||||
|
|
||||||
|
## Implementation cautions
|
||||||
|
|
||||||
|
- Current collection uses tag titles only. Writable behavior needs stable tag
|
||||||
|
identities and narrowly scoped mutation operations without weakening the
|
||||||
|
read-only collector abstraction.
|
||||||
|
- Do not trust `isTodo`, tag names, source quadrant, scope, or desired field
|
||||||
|
patches supplied by the webview. Reparse config and refetch the item/tags.
|
||||||
|
- Joplin tag and note writes are separate calls. Prefer minimal idempotent writes
|
||||||
|
and canonical refetch over a stale compensating rollback.
|
||||||
|
- Multiple blocks can exist in one rendered note. Every request and response
|
||||||
|
must target one stable view instance and reject stale async responses.
|
||||||
|
- A click must continue opening a card. Use a pointer movement threshold and an
|
||||||
|
explicit drag affordance so click and drag are not confused.
|
||||||
|
- Pagination is derived after sorting. A moved card may legitimately disappear
|
||||||
|
beyond the visible batch after refresh.
|
||||||
|
- Grouped views use exact notebook ownership. Cross-group movement is out of
|
||||||
|
scope and must not accidentally patch `parent_id`.
|
||||||
|
- Completing a recurring to-do can activate the Repeating To-Dos plugin. Always
|
||||||
|
refresh canonical state and manually test the integration.
|
||||||
|
- Matrix completion remains excluded. If an item becomes complete while a move
|
||||||
|
is pending, reject the stale move and refresh.
|
||||||
|
- Equal normalized Eisenhower axis tags cannot represent four states. Editable
|
||||||
|
mode must warn and disable mutation for that block.
|
||||||
|
|
||||||
|
## Expected verification and reporting
|
||||||
|
|
||||||
|
For each slice, report these separately:
|
||||||
|
|
||||||
|
- implemented source/documentation changes;
|
||||||
|
- focused automated checks and exact counts;
|
||||||
|
- full automated checks and exact counts;
|
||||||
|
- JPL build and archive inspection;
|
||||||
|
- manual Joplin tests still pending;
|
||||||
|
- explicit user acceptance when received;
|
||||||
|
- Git commit/push/publish state.
|
||||||
|
|
||||||
|
Do not mark a manual checklist item complete from unit tests or code inspection.
|
||||||
|
Do not mark the slice complete while required manual acceptance remains pending.
|
||||||
|
|
||||||
|
## v2.0.0 release boundary
|
||||||
|
|
||||||
|
After SLICE 14 and SLICE 15 are accepted:
|
||||||
|
|
||||||
|
- update README.md and SPEC.md to describe opt-in editing and both keyboard and
|
||||||
|
pointer workflows;
|
||||||
|
- ensure no current documentation still calls all kanban/matrix cards read-only;
|
||||||
|
- record the Eisenhower label migration and writable-view behavior in
|
||||||
|
CHANGELOG.md;
|
||||||
|
- bump `src/manifest.json`, `package.json`, and `package-lock.json` together to
|
||||||
|
`2.0.0` only at the agreed release phase;
|
||||||
|
- run the complete validation and build a fresh production JPL;
|
||||||
|
- inspect the embedded manifest/version and record its SHA-256;
|
||||||
|
- obtain explicit final manual acceptance;
|
||||||
|
- commit, push, and publish only when the user requests those external actions.
|
||||||
24
README.md
24
README.md
@ -97,6 +97,7 @@ done-tag: done
|
|||||||
card-detail: hover
|
card-detail: hover
|
||||||
page-size: 10
|
page-size: 10
|
||||||
done-window: 7
|
done-window: 7
|
||||||
|
editable: yes
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
@ -141,6 +142,7 @@ not filtered by `done-window` because they have no completion timestamp. The
|
|||||||
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
|
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
|
||||||
| `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. |
|
| `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. |
|
||||||
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
|
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
|
||||||
|
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard moves between columns on desktop. Invalid values warn and remain read-only. |
|
||||||
|
|
||||||
Use the two independent type filters to build focused views:
|
Use the two independent type filters to build focused views:
|
||||||
|
|
||||||
@ -219,7 +221,20 @@ Grouping is deliberately limited to the exact combination above. Plain
|
|||||||
and `scope: all` also remain aggregated; combining any of them with
|
and `scope: all` also remain aggregated; combining any of them with
|
||||||
`group: notebook` shows a warning and disables grouping.
|
`group: notebook` shows a warning and disables grouping.
|
||||||
|
|
||||||
Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning. The board is **read-only** and never changes tags or completion. (Drag-and-drop is planned.)
|
Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning.
|
||||||
|
|
||||||
|
Kanban remains read-only unless the block contains the exact opt-in
|
||||||
|
`editable: yes`. On an editable desktop board, drag a card by its move handle or
|
||||||
|
use its keyboard **Move to…** selector. Backlog removes workflow completion and
|
||||||
|
progress, In Progress applies progress while reopening a to-do/removing a
|
||||||
|
note's done tag, and Done completes a native to-do or applies the configured
|
||||||
|
`done-tag` to an ordinary note. Moves never change content, dates, note type,
|
||||||
|
notebook ownership, unrelated tags, or manual ordering; configured sorting and
|
||||||
|
`page-size` are reapplied after a canonical refresh. Same-column, cancelled,
|
||||||
|
outside, stale, and failed moves do not intentionally write. Completing a
|
||||||
|
recurring native to-do uses Joplin's real completion field, so the Repeating
|
||||||
|
To-Dos plugin may advance it; the board then refreshes from canonical state.
|
||||||
|
Editing is supported in the desktop rendered-note webview only.
|
||||||
|
|
||||||
## The Matrix (Skeleton & Eisenhower)
|
## The Matrix (Skeleton & Eisenhower)
|
||||||
|
|
||||||
@ -284,7 +299,10 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
|
|||||||
|
|
||||||
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
|
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
|
||||||
|
|
||||||
Cards behave like kanban cards: compact, clickable, and read-only. Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄 when no icon is set, and never show completion or recurrence styling. Empty and malformed blocks follow the same opt-in and warning behavior as on kanban.
|
Matrix cards are compact, clickable, and read-only in this release scope.
|
||||||
|
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
|
||||||
|
when no icon is set, and never show completion or recurrence styling. Empty and
|
||||||
|
malformed blocks follow the same opt-in and warning behavior as on kanban.
|
||||||
|
|
||||||
## Gantt chart
|
## Gantt chart
|
||||||
|
|
||||||
@ -365,7 +383,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # 251 unit tests
|
npm test # 295 unit tests
|
||||||
npm run dist # builds publish/*.jpl
|
npm run dist # builds publish/*.jpl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
405
SLICE14.md
Normal file
405
SLICE14.md
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
# SLICE 14 — Opt-in kanban drag and drop (v2.0 foundation)
|
||||||
|
|
||||||
|
> **State-saving rule:** update this file after every completed task and whenever
|
||||||
|
> work pauses. Keep implementation, automated validation, production packaging,
|
||||||
|
> and manual Joplin acceptance as separate status boundaries.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**COMPLETE.** Phases 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.
|
||||||
259
SLIDE15.md
Normal file
259
SLIDE15.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# SLICE 15 — Eisenhower drag and drop with Skeleton quadrant names
|
||||||
|
|
||||||
|
> **Filename note:** this file is named `SLIDE15.md` as requested. Its feature
|
||||||
|
> sequence and heading identify it as SLICE 15.
|
||||||
|
>
|
||||||
|
> **State-saving rule:** update this file after every completed task and whenever
|
||||||
|
> work pauses. Keep implementation, automated validation, production packaging,
|
||||||
|
> and manual Joplin acceptance as separate status boundaries.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**PLANNED; BLOCKED ON SLICE 14.** Begin only after the shared mutation service,
|
||||||
|
interaction model, automated validation, and manual kanban acceptance in
|
||||||
|
SLICE 14 are complete.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make Eisenhower matrices editable by moving cards between quadrants. Each move
|
||||||
|
persists the urgent/important tag combination represented by the destination.
|
||||||
|
|
||||||
|
At the same time, replace the classic action labels with the more constructive
|
||||||
|
Skeleton labels while retaining Eisenhower's urgent/important axes:
|
||||||
|
|
||||||
|
| | **Urgent** | **Not urgent** |
|
||||||
|
|---|---|---|
|
||||||
|
| **Important** | **Do Next** | **Scheduled** |
|
||||||
|
| **Not important** | **On Deck** | **Backlog** |
|
||||||
|
|
||||||
|
This means the current **Eliminate** quadrant becomes **Backlog**. The underlying
|
||||||
|
quadrant remains “not urgent and not important”; only its displayed name changes.
|
||||||
|
|
||||||
|
## Confirmed product contract
|
||||||
|
|
||||||
|
- The label change applies to every Eisenhower matrix, editable or read-only.
|
||||||
|
- Eisenhower's axes remain `urgent-tag` and `important-tag`; no date-derived
|
||||||
|
urgency or in-progress semantics are introduced.
|
||||||
|
- All four Eisenhower quadrant names adopt Skeleton's names:
|
||||||
|
`Do Next`, `Scheduled`, `On Deck`, and `Backlog`.
|
||||||
|
- Skeleton mode remains available and unchanged in v2.0.0, but does not receive
|
||||||
|
drag and drop in this slice.
|
||||||
|
- `editable: yes` enables Eisenhower mutation and defaults to `no`.
|
||||||
|
- Setting `editable: yes` with `mode: skeleton` warns that editing is unavailable
|
||||||
|
and renders a read-only Skeleton matrix. It must not expose inert handles.
|
||||||
|
- Both ordinary opted-in notes and incomplete native to-dos can be moved.
|
||||||
|
- Completed to-dos and notes carrying `done-tag` remain excluded; dragging does
|
||||||
|
not change completion state.
|
||||||
|
- A move changes only the configured urgent/important tag memberships. It does
|
||||||
|
not edit dates, in-progress state, completion, notebook, or unrelated tags.
|
||||||
|
- Pointer drag and keyboard **Move to…** use the shared SLICE 14 behavior.
|
||||||
|
- Sorting remains controlled by `sort` and `sort-type`; drag does not establish
|
||||||
|
manual ordering within a quadrant.
|
||||||
|
- Moves remain within the current notebook group.
|
||||||
|
|
||||||
|
## Eisenhower transition rules
|
||||||
|
|
||||||
|
| Destination | Urgent tag | Important tag | Persisted change |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| Do Next (top-left) | yes | yes | Add both configured tags. |
|
||||||
|
| Scheduled (top-right) | no | yes | Remove urgent; add important. |
|
||||||
|
| On Deck (bottom-left) | yes | no | Add urgent; remove important. |
|
||||||
|
| Backlog (bottom-right) | no | no | Remove both configured tags. |
|
||||||
|
|
||||||
|
The rules are identical for ordinary notes and native to-dos. Joplin note type,
|
||||||
|
native completion, `done-tag`, `in-progress-tag`, `gtd` block, dates, and all
|
||||||
|
unrelated tags remain unchanged.
|
||||||
|
|
||||||
|
If `urgent-tag` and `important-tag` normalize to the same title, the two axes
|
||||||
|
cannot represent four persistent states. Existing parser behavior must be
|
||||||
|
audited. For an editable Eisenhower matrix, treat this configuration as
|
||||||
|
non-editable with a clear warning rather than applying ambiguous mutations.
|
||||||
|
Read-only collection may retain its existing precedence for compatibility.
|
||||||
|
|
||||||
|
## Label migration
|
||||||
|
|
||||||
|
Update the canonical `matrixLabels("eisenhower")` result:
|
||||||
|
|
||||||
|
| Position | v1 label | v2 label |
|
||||||
|
|---|---|---|
|
||||||
|
| top-left | Do First | Do Next |
|
||||||
|
| top-right | Schedule | Scheduled |
|
||||||
|
| bottom-left | Delegate | On Deck |
|
||||||
|
| bottom-right | Eliminate | Backlog |
|
||||||
|
|
||||||
|
Remove duplicated fallback label literals in the webview or update them from
|
||||||
|
the same shared contract so an absent/legacy payload cannot silently restore
|
||||||
|
the old wording. Update code comments, tests, README.md, SPEC.md, and accessible
|
||||||
|
labels. No saved data migration is needed because quadrant labels are not
|
||||||
|
persisted.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
SLICE 15 extends, rather than duplicates, the SLICE 14 pathway:
|
||||||
|
|
||||||
|
```text
|
||||||
|
matrix pointer drop or keyboard Move to…
|
||||||
|
|
|
||||||
|
v
|
||||||
|
{ view: "matrix", hostNoteId, cardId, destination, rawConfig, viewInstanceId }
|
||||||
|
|
|
||||||
|
v
|
||||||
|
shared message validation
|
||||||
|
- config reparses as mode:eisenhower + editable:yes
|
||||||
|
- card remains admitted, incomplete, in scope, and in the same group
|
||||||
|
|
|
||||||
|
v
|
||||||
|
shared tag mutation primitives apply urgent/important target state
|
||||||
|
|
|
||||||
|
v
|
||||||
|
canonical matrix recollection, sorting, totals, pagination, and rerender
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a matrix-specific semantic destination union (`topLeft | topRight |
|
||||||
|
bottomLeft | bottomRight`, or equivalent named states). Do not let the webview
|
||||||
|
submit arbitrary tag titles or add/remove lists. The main process derives tag
|
||||||
|
operations from freshly parsed configuration and the fixed destination table.
|
||||||
|
|
||||||
|
### Fresh-state validation
|
||||||
|
|
||||||
|
Before writing, confirm all SLICE 14 host, scope, type, grouping, and `gtd`
|
||||||
|
eligibility checks, plus:
|
||||||
|
|
||||||
|
- mode is still `eisenhower`;
|
||||||
|
- `editable` is still enabled;
|
||||||
|
- urgent and important tag names remain distinct after normalization;
|
||||||
|
- the item is still incomplete under native or note `done-tag` semantics;
|
||||||
|
- the destination is one of the four fixed quadrants.
|
||||||
|
|
||||||
|
If the item became complete or the block changed mode/configuration during a
|
||||||
|
drag, make no write, report stale state, and refresh the matrix.
|
||||||
|
|
||||||
|
## Interaction and rendering rules
|
||||||
|
|
||||||
|
- Reuse SLICE 14 pointer thresholds, capture, cancellation, pending/error states,
|
||||||
|
request sequencing, focus recovery, live announcements, and canonical refresh.
|
||||||
|
- Highlight only quadrants in the same matrix instance and notebook group.
|
||||||
|
- Use the new quadrant labels everywhere, including **Move to…** options and
|
||||||
|
announcements.
|
||||||
|
- Do not infer a source quadrant in the main process from stale DOM data; fetch
|
||||||
|
current tags and calculate a minimal target-state change.
|
||||||
|
- Same-quadrant moves are no-ops.
|
||||||
|
- A canonical refresh may sort the card to a different position or beyond the
|
||||||
|
currently visible `page-size`; counts and **List more** remain authoritative.
|
||||||
|
- Read-only Eisenhower matrices receive the label change without drag handles,
|
||||||
|
move menus, mutation requests, or new focus stops.
|
||||||
|
- Skeleton matrices receive the label-independent shared rendering regressions
|
||||||
|
only and remain read-only.
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
### Phase 1 — Label change and compatibility tests
|
||||||
|
|
||||||
|
- [ ] Change Eisenhower labels to Do Next, Scheduled, On Deck, and Backlog.
|
||||||
|
- [ ] Update comments and remove/update fallback literals that contain Do First,
|
||||||
|
Schedule, Delegate, or Eliminate.
|
||||||
|
- [ ] Update unit and rendering tests for single and grouped layouts.
|
||||||
|
- [ ] Confirm Skeleton labels and both modes' row/column axes remain unchanged.
|
||||||
|
- [ ] Confirm the label-only change applies when `editable` is omitted.
|
||||||
|
|
||||||
|
### Phase 2 — Matrix configuration and protocol
|
||||||
|
|
||||||
|
- [ ] Add `editable: boolean` to `MatrixConfig`, defaulting to `false`.
|
||||||
|
- [ ] Parse `editable: yes | no` using the SLICE 14 validation contract.
|
||||||
|
- [ ] Warn and disable editing for `mode: skeleton`.
|
||||||
|
- [ ] Warn and disable editable Eisenhower when normalized axis tags are equal.
|
||||||
|
- [ ] Define constrained matrix move intents and fixed destinations using the
|
||||||
|
shared host/view identity and typed result model.
|
||||||
|
- [ ] Add parser/protocol tests for defaults, invalid values, mode interaction,
|
||||||
|
equal-axis tags, and backward compatibility.
|
||||||
|
|
||||||
|
### Phase 3 — Eisenhower mutation planning
|
||||||
|
|
||||||
|
- [ ] Implement the four urgent/important target states through shared tag
|
||||||
|
primitives from SLICE 14.
|
||||||
|
- [ ] Calculate minimal, idempotent tag membership changes from fresh data.
|
||||||
|
- [ ] Preserve completion, progress, dates, body, note type, notebook, and
|
||||||
|
unrelated tags.
|
||||||
|
- [ ] Cover notes and to-dos, custom tag names, casing, duplicate tags, all 12
|
||||||
|
cross-quadrant moves, same-quadrant no-ops, and partial failures.
|
||||||
|
- [ ] Prove a completed or newly filtered item cannot be mutated.
|
||||||
|
|
||||||
|
### Phase 4 — Message validation and refresh
|
||||||
|
|
||||||
|
- [ ] Add the matrix move message to the main-process handler.
|
||||||
|
- [ ] Reparse and validate editable Eisenhower configuration and fresh eligibility.
|
||||||
|
- [ ] Reject arbitrary destinations, tag operations, fields, modes, host notes,
|
||||||
|
out-of-scope items, cross-group moves, and stale completions.
|
||||||
|
- [ ] Return typed results and refresh only the initiating matrix instance.
|
||||||
|
- [ ] Guard against older responses overwriting newer renders.
|
||||||
|
|
||||||
|
### Phase 5 — Pointer and keyboard interaction
|
||||||
|
|
||||||
|
- [ ] Reuse the SLICE 14 drag affordance and pointer lifecycle for quadrants.
|
||||||
|
- [ ] Constrain active targets to the same matrix instance and notebook group.
|
||||||
|
- [ ] Add new-label drag feedback, pending/error states, and cancellation.
|
||||||
|
- [ ] Add keyboard **Move to…** options for the other three quadrants.
|
||||||
|
- [ ] Preserve click-to-open, hover details, pagination, scrolling, focus, and
|
||||||
|
screen-reader announcements.
|
||||||
|
- [ ] Verify read-only Eisenhower and all Skeleton matrices expose no mutation UI.
|
||||||
|
|
||||||
|
### Phase 6 — Documentation and complete validation
|
||||||
|
|
||||||
|
- [ ] Update README.md examples, option tables, quadrant table, read-only text,
|
||||||
|
controls, and limitations.
|
||||||
|
- [ ] Update SPEC.md configuration, label contract, transition table, protocol,
|
||||||
|
mutation safety, accessibility, and refresh behavior.
|
||||||
|
- [ ] Update the unreleased v2.0.0 CHANGELOG entry.
|
||||||
|
- [ ] Decide whether Skeleton remains documented as a supported alternative or
|
||||||
|
is marked as legacy/non-editable; do not remove it in this slice.
|
||||||
|
- [ ] Run focused parser, labels, collector, mutation, handler, render, grouping,
|
||||||
|
pagination, and accessibility tests.
|
||||||
|
- [ ] Run the complete Jest suite, TypeScript, webview syntax, CSS/build,
|
||||||
|
whitespace, and prohibited-reference checks.
|
||||||
|
- [ ] Build and inspect the production JPL without publishing v2.0.0 yet.
|
||||||
|
|
||||||
|
### Phase 7 — Manual Joplin acceptance
|
||||||
|
|
||||||
|
- [ ] Every Eisenhower matrix shows Do Next, Scheduled, On Deck, and Backlog.
|
||||||
|
- [ ] No visible or accessible Eisenhower UI says Do First, Schedule, Delegate,
|
||||||
|
Eliminate, or “Drop this.”
|
||||||
|
- [ ] Read-only Eisenhower matrices retain click, hover, sorting, and pagination
|
||||||
|
behavior without mutation affordances.
|
||||||
|
- [ ] An editable Eisenhower matrix moves an ordinary note through all four
|
||||||
|
quadrants and persists the exact urgent/important tag state after reload.
|
||||||
|
- [ ] An editable Eisenhower matrix moves a native to-do through all four
|
||||||
|
quadrants without changing native completion or progress state.
|
||||||
|
- [ ] Custom distinct axis tags work; equal axis tags warn and disable editing.
|
||||||
|
- [ ] Same-quadrant, cancelled, outside, Escape, and interrupted drags do not write.
|
||||||
|
- [ ] A normal click opens the note; a drag does not accidentally open it.
|
||||||
|
- [ ] Keyboard-only **Move to…** reaches every quadrant and restores focus.
|
||||||
|
- [ ] Stale completion, filtering, scope, group, and API-failure cases converge
|
||||||
|
to canonical state without unrelated data changes.
|
||||||
|
- [ ] Single, grouped, multiple-block, scrolling, and paginated matrices work.
|
||||||
|
- [ ] Skeleton matrices remain correct and read-only.
|
||||||
|
- [ ] Record explicit user sign-off separately from automated checks.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Skeleton drag and drop.
|
||||||
|
- Removing Skeleton mode or migrating Skeleton blocks to Eisenhower.
|
||||||
|
- Date-derived urgency changes.
|
||||||
|
- Changing completion or in-progress state from an Eisenhower move.
|
||||||
|
- Manual ordering within a quadrant.
|
||||||
|
- Dragging between notebook groups or changing notebook ownership.
|
||||||
|
- Editing arbitrary tags or card fields.
|
||||||
|
- Calendar, Gantt, or mobile drag and drop.
|
||||||
|
- Publishing v2.0.0 before consolidated release acceptance.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Eisenhower retains urgent/important semantics but consistently uses Skeleton's
|
||||||
|
four constructive quadrant names.
|
||||||
|
- Mutation occurs only for an explicitly editable, valid Eisenhower block.
|
||||||
|
- Every destination persists exactly the configured urgent/important state and
|
||||||
|
preserves completion, progress, content, notebook, and unrelated tags.
|
||||||
|
- Pointer and keyboard interaction share SLICE 14's validated safety and
|
||||||
|
accessibility foundation.
|
||||||
|
- Skeleton mode remains available, correct, and read-only.
|
||||||
|
- Automated validation, production packaging, and explicit manual Joplin
|
||||||
|
acceptance are completed and recorded separately.
|
||||||
53
SPEC.md
53
SPEC.md
@ -90,6 +90,7 @@ done-tag: done # ordinary-note completion; default: "done"
|
|||||||
card-detail: hover # hover | always | none (default: hover)
|
card-detail: hover # hover | always | none (default: hover)
|
||||||
page-size: 10 # positive integer (default: 10)
|
page-size: 10 # positive integer (default: 10)
|
||||||
done-window: 7 # days, or "all" (default: 7)
|
done-window: 7 # days, or "all" (default: 7)
|
||||||
|
editable: no # yes | no; persisted moves (default: no)
|
||||||
```
|
```
|
||||||
|
|
||||||
Three columns are always shown: **Backlog**, **In Progress**, and **Done**.
|
Three columns are always shown: **Backlog**, **In Progress**, and **Done**.
|
||||||
@ -111,7 +112,21 @@ Under `due-date`, dateless cards sort after dated cards regardless of direction.
|
|||||||
10. Each column initially renders at most that many cards and reveals another
|
10. Each column initially renders at most that many cards and reveals another
|
||||||
batch per **List more** click. Expansion is independent per column and resets
|
batch per **List more** click. Expansion is independent per column and resets
|
||||||
when the rendered note reloads; headings and statistics retain complete totals.
|
when the rendered note reloads; headings and statistics retain complete totals.
|
||||||
Read-only in the current version.
|
|
||||||
|
`editable` accepts scalar `yes | no` case-insensitively and defaults to `no`.
|
||||||
|
Invalid, empty, or non-scalar values warn and remain read-only. With `yes`, the
|
||||||
|
desktop rendered-note view exposes a pointer handle and keyboard **Move to…**
|
||||||
|
selector. Destinations are fixed semantic states, never arbitrary patches:
|
||||||
|
|
||||||
|
| Destination | Ordinary opted-in note | Native Joplin to-do |
|
||||||
|
|---|---|---|
|
||||||
|
| Backlog | remove `done-tag` and `in-progress-tag` | set `todo_completed: 0`; remove `in-progress-tag` |
|
||||||
|
| In Progress | add `in-progress-tag`; remove `done-tag` | set `todo_completed: 0`; add `in-progress-tag` |
|
||||||
|
| Done | add `done-tag`; remove `in-progress-tag` | set `todo_completed` to current epoch ms; remove `in-progress-tag` |
|
||||||
|
|
||||||
|
Same-state moves are idempotent. Content, dates, note type, notebook,
|
||||||
|
unrelated tags, and free-form ordering are preserved. Completing a recurring
|
||||||
|
to-do uses native completion and may activate the Repeating To-Dos plugin.
|
||||||
|
|
||||||
### 2.4 The `gtd-matrix` block (lives in the matrix note)
|
### 2.4 The `gtd-matrix` block (lives in the matrix note)
|
||||||
|
|
||||||
@ -249,6 +264,11 @@ notes never become recurring.
|
|||||||
- **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`.
|
- **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`.
|
||||||
- **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`).
|
- **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`).
|
||||||
- **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches.
|
- **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches.
|
||||||
|
- **Editable kanban:** only `editable: yes` adds a pointer handle and keyboard
|
||||||
|
**Move to…** selector. Pointer movement has a threshold and is scoped to the
|
||||||
|
originating board/notebook group. Pending, success, stale, and failure states
|
||||||
|
are announced through an atomic live region; focus returns to the moved card
|
||||||
|
or status fallback after canonical rerender.
|
||||||
- **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter.
|
- **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter.
|
||||||
- **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it.
|
- **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it.
|
||||||
- **Drilldown:** every tile, card, hover-card row, and chip across all four view types is clickable and opens the source note.
|
- **Drilldown:** every tile, card, hover-card row, and chip across all four view types is clickable and opens the source note.
|
||||||
@ -275,7 +295,8 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
|||||||
│ │ depending on block type. Builds the
|
│ │ depending on block type. Builds the
|
||||||
│ │ calendar / board / matrix / Gantt DOM from
|
│ │ calendar / board / matrix / Gantt DOM from
|
||||||
│ │ returned payload. Click → postMessage
|
│ │ returned payload. Click → postMessage
|
||||||
│ │ ({type: "openNote", noteId}).
|
│ │ ({type: "openNote", noteId}); editable
|
||||||
|
│ │ kanban sends constrained move intent only.
|
||||||
└──────────────┬──────────────┘
|
└──────────────┬──────────────┘
|
||||||
│ postMessage / onMessage
|
│ postMessage / onMessage
|
||||||
┌──────────────▼──────────────┐
|
┌──────────────▼──────────────┐
|
||||||
@ -300,6 +321,9 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
|||||||
│ │ collectGantt.ts)
|
│ │ collectGantt.ts)
|
||||||
│ │ • handles openNote via
|
│ │ • handles openNote via
|
||||||
│ │ joplin.commands.execute
|
│ │ joplin.commands.execute
|
||||||
|
│ │ • reparses editable kanban config, refetches
|
||||||
|
│ │ host/card/scope/tags, derives minimal writes,
|
||||||
|
│ │ then returns a typed move result
|
||||||
└─────────────────────────────┘
|
└─────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -329,7 +353,10 @@ SLICE8 batching remains transient webview state.
|
|||||||
real-world scales (largest tested: ~285 notes, single folder scope), but worth
|
real-world scales (largest tested: ~285 notes, single folder scope), but worth
|
||||||
revisiting if a `children`-scope board on a large tree feels slow, especially
|
revisiting if a `children`-scope board on a large tree feels slow, especially
|
||||||
with Persistent Layout keeping several boards rendered at once.
|
with Persistent Layout keeping several boards rendered at once.
|
||||||
- Refresh model: re-render occurs on note switch (Joplin behavior); no live refresh on background data changes. Fine for a read-only plugin; becomes a sharper question once the authoring axis (§9) lands, where a write on one rendered board should ideally refresh others.
|
- Refresh model: normal background edits appear when Joplin rerenders the note.
|
||||||
|
An editable-kanban move immediately refetches and rerenders only its initiating
|
||||||
|
`viewInstanceId`; request sequencing rejects stale responses. Other open
|
||||||
|
dashboards remain stale until their own rerender.
|
||||||
|
|
||||||
### Platform caveats
|
### Platform caveats
|
||||||
|
|
||||||
@ -474,19 +501,17 @@ remains eligibility-gated. Automated validation passed at 185 tests, the
|
|||||||
production JPL built successfully, and manual Joplin acceptance passed
|
production JPL built successfully, and manual Joplin acceptance passed
|
||||||
2026-07-27.
|
2026-07-27.
|
||||||
|
|
||||||
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic
|
### v2.0.0 — Opt-in kanban authoring — IMPLEMENTED, RELEASE PENDING
|
||||||
|
|
||||||
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
|
Kanban write-back is implemented behind `editable: yes`; existing blocks remain
|
||||||
|
read-only. Pointer and keyboard controls submit constrained intent. The main
|
||||||
|
process reparses configuration, validates the selected host and fresh scope/item
|
||||||
|
eligibility, derives minimal tag/completion writes, and refreshes the initiating
|
||||||
|
view from canonical data. Failures do not use stale rollback values. Cross-group
|
||||||
|
movement and free-form ordering remain excluded. Release and manual Joplin
|
||||||
|
acceptance are tracked in `SLICE14.md`.
|
||||||
|
|
||||||
**Drag-and-drop kanban (write-back).** The first feature making the plugin a **controller** of data rather than a viewer — warrants its own design pass.
|
### Future authoring — create from view
|
||||||
|
|
||||||
- Dragging a card writes back to Joplin via the existing webview→plugin channel:
|
|
||||||
- → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate).
|
|
||||||
- → *Done*: set `todo_completed`.
|
|
||||||
- → *Backlog*: remove the tag / clear completion.
|
|
||||||
- **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes.
|
|
||||||
- Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging.
|
|
||||||
- **Multi-board refresh:** with Persistent Layout keeping several dashboards rendered, a write from one board leaves others stale until re-render — the refresh question is sharper in the fractal-dashboard architecture than for single-board use.
|
|
||||||
|
|
||||||
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.
|
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.
|
||||||
|
|
||||||
|
|||||||
33
TASKS.md
33
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
|
Use this file as the cross-slice implementation checklist. Update it whenever a
|
||||||
task is completed or its status changes. Keep automated validation results and
|
task is completed or its status changes. Keep automated validation results and
|
||||||
@ -8,9 +8,13 @@ manual Joplin acceptance recorded separately.
|
|||||||
|
|
||||||
- [x] SLICE8 complete — incremental card limits
|
- [x] SLICE8 complete — incremental card limits
|
||||||
- [x] SLICE9 complete — opted-in note cards
|
- [x] SLICE9 complete — opted-in note cards
|
||||||
- [ ] SLICE10 complete — child-notebook boards and matrices
|
- [x] SLICE10 complete — child-notebook boards and matrices
|
||||||
|
- [x] SLICE11 complete — ordinary-note completion with a `done` tag
|
||||||
|
- [x] SLICE12 complete — independent project-note and task-to-do filters
|
||||||
|
- [x] SLICE13 complete — underline removal for kanban and matrix cards
|
||||||
|
- [x] SLICE14 complete — opt-in kanban drag and drop
|
||||||
|
|
||||||
Implementation order: SLICE8, then SLICE9, then SLICE10.
|
Slices 8–13 are complete. SLICE14 is the next implementation slice.
|
||||||
|
|
||||||
## SLICE8 — Incremental card limits for kanban and matrix views
|
## SLICE8 — Incremental card limits for kanban and matrix views
|
||||||
|
|
||||||
@ -159,7 +163,7 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
|
|||||||
- [x] Test the same invariant in Skeleton and Eisenhower matrices.
|
- [x] Test the same invariant in Skeleton and Eisenhower matrices.
|
||||||
- [x] Include both note and to-do cards in grouping tests.
|
- [x] Include both note and to-do cards in grouping tests.
|
||||||
- [x] Test aggregate statistics and warnings for double-counting regressions.
|
- [x] Test aggregate statistics and warnings for double-counting regressions.
|
||||||
- [ ] Test independent batching and "List more" state across generated views.
|
- [x] Test independent batching and "List more" state across generated views.
|
||||||
- [x] Test that ungrouped `children`, numeric scope, `this-folder`, and
|
- [x] Test that ungrouped `children`, numeric scope, `this-folder`, and
|
||||||
`scope: all` still produce one view with unchanged behavior.
|
`scope: all` still produce one view with unchanged behavior.
|
||||||
|
|
||||||
@ -176,16 +180,27 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
|
|||||||
grandchildren in Joplin.
|
grandchildren in Joplin.
|
||||||
- [x] Confirm headings, ordering, empty-group handling, no duplication, sorting,
|
- [x] Confirm headings, ordering, empty-group handling, no duplication, sorting,
|
||||||
styling, navigation, warnings, and independent expansion.
|
styling, navigation, warnings, and independent expansion.
|
||||||
- [ ] Mark SLICE10 complete in `SLICE10.md` and this file.
|
- [x] Mark SLICE10 complete in `SLICE10.md` and this file.
|
||||||
|
|
||||||
## Final cross-slice verification
|
## Final cross-slice verification
|
||||||
|
|
||||||
- [ ] Run the complete automated suite after all three slices are integrated.
|
- [x] Run the complete automated suite after all three slices are integrated.
|
||||||
- [ ] Build the final production `.jpl` and record its path and version.
|
- [x] Build the final production `.jpl` and record its path and version.
|
||||||
- [x] Verify a dashboard containing kanban and both matrix modes together.
|
- [x] Verify a dashboard containing kanban and both matrix modes together.
|
||||||
- [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks.
|
- [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks.
|
||||||
- [x] Confirm reload resets every expanded list to its configured initial size.
|
- [x] Confirm reload resets every expanded list to its configured initial size.
|
||||||
- [x] Record manual Joplin acceptance without conflating it with automated checks.
|
- [x] Record manual Joplin acceptance without conflating it with automated checks.
|
||||||
- [ ] Re-run the repository-wide prohibited-reference audit.
|
- [x] Re-run the repository-wide prohibited-reference audit.
|
||||||
- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the
|
- [x] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the
|
||||||
final implementation state.
|
final implementation state.
|
||||||
|
|
||||||
|
## Subsequent slice status
|
||||||
|
|
||||||
|
- [x] SLICE11 complete. Implementation, focused and full automation, production
|
||||||
|
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||||
|
- [x] SLICE12 complete. Implementation, focused and full automation, production
|
||||||
|
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||||
|
- [x] SLICE13 complete. Implementation, automation, production package
|
||||||
|
inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||||
|
- [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was
|
||||||
|
received on 2026-07-31. Slice 15 remains not started.
|
||||||
|
|||||||
132
src/Gtd/kanbanMoveHandler.ts
Normal file
132
src/Gtd/kanbanMoveHandler.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const YAML = require("yaml");
|
||||||
|
|
||||||
|
import extractGtdBlock from "./gtdBlock";
|
||||||
|
import resolveScopedFolderIds from "./folderScope";
|
||||||
|
import { moveKanbanItem } from "./kanbanMutation";
|
||||||
|
import { isKanbanDestination } from "./moveProtocol";
|
||||||
|
import parseKanbanConfig from "./parseKanbanConfig";
|
||||||
|
import resolveNotebook from "./resolveNotebook";
|
||||||
|
import {
|
||||||
|
DataAdapter,
|
||||||
|
MoveCardResult,
|
||||||
|
MoveKanbanCardIntent,
|
||||||
|
MutationAdapter,
|
||||||
|
RawFolder,
|
||||||
|
RawNote,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface KanbanMoveHost {
|
||||||
|
id: string;
|
||||||
|
parentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stale(viewInstanceId: string, message: string): MoveCardResult {
|
||||||
|
return { status: "stale", viewInstanceId, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(viewInstanceId: string, message: string): MoveCardResult {
|
||||||
|
return { status: "error", viewInstanceId, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validId(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StaleMoveError extends Error {}
|
||||||
|
|
||||||
|
function validateFreshEligibility(
|
||||||
|
note: RawNote,
|
||||||
|
host: KanbanMoveHost,
|
||||||
|
folderIds: Set<string>,
|
||||||
|
config: ReturnType<typeof parseKanbanConfig>
|
||||||
|
): void {
|
||||||
|
if (note.id === host.id) throw new StaleMoveError("The dashboard note cannot be moved.");
|
||||||
|
if (!folderIds.has(note.parent_id)) throw new StaleMoveError("The card is no longer in scope.");
|
||||||
|
if (!note.is_todo && config.notes === "none") {
|
||||||
|
throw new StaleMoveError("The card is no longer admitted by notes.");
|
||||||
|
}
|
||||||
|
const block = extractGtdBlock(note.body);
|
||||||
|
if (note.is_todo) {
|
||||||
|
if (config.todos === "none") throw new StaleMoveError("The card is no longer admitted by todos.");
|
||||||
|
if (config.todos === "gtd-only" && !block.found) {
|
||||||
|
throw new StaleMoveError("The to-do no longer contains a gtd block.");
|
||||||
|
}
|
||||||
|
} else if (!block.found) {
|
||||||
|
throw new StaleMoveError("The note no longer contains a gtd block.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate untrusted webview intent against fresh host, scope, and item state. */
|
||||||
|
export async function validateAndMoveKanbanCard(
|
||||||
|
intent: unknown,
|
||||||
|
host: KanbanMoveHost | null,
|
||||||
|
readAdapter: DataAdapter,
|
||||||
|
mutationAdapter: MutationAdapter,
|
||||||
|
now: () => number = Date.now,
|
||||||
|
logDiagnostic: (error: unknown) => void = () => undefined
|
||||||
|
): Promise<MoveCardResult> {
|
||||||
|
const candidate = intent as Partial<MoveKanbanCardIntent> | null;
|
||||||
|
const viewInstanceId = validId(candidate?.viewInstanceId)
|
||||||
|
? candidate.viewInstanceId
|
||||||
|
: "unknown";
|
||||||
|
const allowedKeys = new Set([
|
||||||
|
"type", "view", "hostNoteId", "cardId", "destination", "rawConfig", "viewInstanceId",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
!candidate ||
|
||||||
|
Array.isArray(candidate) ||
|
||||||
|
Object.keys(candidate).some((key) => !allowedKeys.has(key)) ||
|
||||||
|
candidate.type !== "moveKanbanCard" ||
|
||||||
|
candidate.view !== "kanban" ||
|
||||||
|
!validId(candidate.hostNoteId) ||
|
||||||
|
!validId(candidate.cardId) ||
|
||||||
|
!validId(candidate.viewInstanceId) ||
|
||||||
|
typeof candidate.rawConfig !== "string" ||
|
||||||
|
!isKanbanDestination(candidate.destination)
|
||||||
|
) {
|
||||||
|
return error(viewInstanceId, "Invalid kanban move request.");
|
||||||
|
}
|
||||||
|
if (!host || candidate.hostNoteId !== host.id) {
|
||||||
|
return stale(viewInstanceId, "The rendered dashboard is no longer current.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = YAML.parse(candidate.rawConfig || "") || {};
|
||||||
|
} catch (_parseError) {
|
||||||
|
return stale(viewInstanceId, "The kanban configuration is no longer valid.");
|
||||||
|
}
|
||||||
|
const config = parseKanbanConfig(parsed);
|
||||||
|
if (!config.editable) {
|
||||||
|
return error(viewInstanceId, "This kanban is read-only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = await readAdapter.getFolders();
|
||||||
|
let scanFolderId = host.parentId;
|
||||||
|
if (!config.scopeAll && config.notebook) {
|
||||||
|
scanFolderId = resolveNotebook(folders, config.notebook, host.parentId).folderId;
|
||||||
|
}
|
||||||
|
const folderIds = new Set(
|
||||||
|
resolveScopedFolderIds(folders, scanFolderId, config.scopeDepth, config.scopeAll)
|
||||||
|
);
|
||||||
|
const outcome = await moveKanbanItem(
|
||||||
|
mutationAdapter,
|
||||||
|
candidate.cardId,
|
||||||
|
candidate.destination,
|
||||||
|
config,
|
||||||
|
now,
|
||||||
|
(note) => validateFreshEligibility(note, host, folderIds, config)
|
||||||
|
);
|
||||||
|
return { status: "success", viewInstanceId, changed: outcome.changed };
|
||||||
|
} catch (moveError) {
|
||||||
|
if (moveError instanceof StaleMoveError) {
|
||||||
|
return stale(viewInstanceId, moveError.message);
|
||||||
|
}
|
||||||
|
if (moveError instanceof Error && moveError.message === "The card no longer exists.") {
|
||||||
|
return stale(viewInstanceId, moveError.message);
|
||||||
|
}
|
||||||
|
logDiagnostic(moveError);
|
||||||
|
return error(viewInstanceId, "Could not apply the move. Refresh and try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/Gtd/kanbanMutation.ts
Normal file
113
src/Gtd/kanbanMutation.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
KanbanDestination,
|
||||||
|
MutationAdapter,
|
||||||
|
RawNote,
|
||||||
|
RawTag,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface KanbanMutationConfig {
|
||||||
|
inProgressTag: string;
|
||||||
|
doneTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanMutationOutcome {
|
||||||
|
note: RawNote;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = (title: string) => title.trim().toLowerCase();
|
||||||
|
|
||||||
|
function matchingTags(tags: RawTag[], title: string): RawTag[] {
|
||||||
|
const target = normalized(title);
|
||||||
|
return tags
|
||||||
|
.filter((tag) => normalized(tag.title || "") === target)
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTag(
|
||||||
|
adapter: MutationAdapter,
|
||||||
|
noteId: string,
|
||||||
|
title: string,
|
||||||
|
noteTags: RawTag[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (matchingTags(noteTags, title).length > 0) return false;
|
||||||
|
const existing = matchingTags(await adapter.getAllTags(), title)[0];
|
||||||
|
const tag = existing || (await adapter.createTag(title));
|
||||||
|
await adapter.attachTag(tag.id, noteId);
|
||||||
|
noteTags.push(tag);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(
|
||||||
|
adapter: MutationAdapter,
|
||||||
|
noteId: string,
|
||||||
|
title: string,
|
||||||
|
noteTags: RawTag[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
const matches = matchingTags(noteTags, title);
|
||||||
|
for (const tag of matches) await adapter.detachTag(tag.id, noteId);
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const removed = new Set(matches.map((tag) => tag.id));
|
||||||
|
noteTags.splice(0, noteTags.length, ...noteTags.filter((tag) => !removed.has(tag.id)));
|
||||||
|
}
|
||||||
|
return matches.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one canonical kanban workflow state from freshly fetched note/tag data.
|
||||||
|
* Eligibility and scope are deliberately the caller's responsibility (Phase 3).
|
||||||
|
*/
|
||||||
|
export async function moveKanbanItem(
|
||||||
|
adapter: MutationAdapter,
|
||||||
|
noteId: string,
|
||||||
|
destination: KanbanDestination,
|
||||||
|
config: KanbanMutationConfig,
|
||||||
|
now: () => number = Date.now,
|
||||||
|
validateFreshNote?: (note: RawNote) => void
|
||||||
|
): Promise<KanbanMutationOutcome> {
|
||||||
|
const note = await adapter.getNote(noteId);
|
||||||
|
if (!note) throw new Error("The card no longer exists.");
|
||||||
|
if (validateFreshNote) validateFreshNote(note);
|
||||||
|
const noteTags = await adapter.getNoteTags(noteId);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (note.is_todo) {
|
||||||
|
if (destination === "done") {
|
||||||
|
if (note.todo_completed <= 0) {
|
||||||
|
await adapter.setTodoCompleted(noteId, now());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
changed =
|
||||||
|
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
|
changed;
|
||||||
|
} else {
|
||||||
|
if (note.todo_completed > 0) {
|
||||||
|
await adapter.setTodoCompleted(noteId, 0);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (destination === "inProgress") {
|
||||||
|
changed =
|
||||||
|
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
|
changed;
|
||||||
|
} else {
|
||||||
|
changed =
|
||||||
|
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) ||
|
||||||
|
changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (destination === "done") {
|
||||||
|
changed = (await ensureTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
|
changed =
|
||||||
|
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
|
} else if (destination === "inProgress") {
|
||||||
|
changed =
|
||||||
|
(await ensureTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
|
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
|
} else {
|
||||||
|
changed = (await removeTag(adapter, noteId, config.doneTag, noteTags)) || changed;
|
||||||
|
changed =
|
||||||
|
(await removeTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { note, changed };
|
||||||
|
}
|
||||||
12
src/Gtd/moveProtocol.ts
Normal file
12
src/Gtd/moveProtocol.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { KanbanDestination } from "./types";
|
||||||
|
|
||||||
|
const KANBAN_DESTINATIONS: KanbanDestination[] = [
|
||||||
|
"backlog",
|
||||||
|
"inProgress",
|
||||||
|
"done",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Runtime guard for the only kanban workflow states the plugin may persist. */
|
||||||
|
export function isKanbanDestination(value: unknown): value is KanbanDestination {
|
||||||
|
return KANBAN_DESTINATIONS.includes(value as KanbanDestination);
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
"done-tag",
|
"done-tag",
|
||||||
"card-detail",
|
"card-detail",
|
||||||
"page-size",
|
"page-size",
|
||||||
|
"editable",
|
||||||
"done-window",
|
"done-window",
|
||||||
];
|
];
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
@ -185,6 +186,19 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
|
|
||||||
const pageSize = parsePageSize(input["page-size"], warnings);
|
const pageSize = parsePageSize(input["page-size"], warnings);
|
||||||
|
|
||||||
|
let editable = false;
|
||||||
|
if (input.editable !== undefined) {
|
||||||
|
const candidate =
|
||||||
|
typeof input.editable === "string"
|
||||||
|
? input.editable.trim().toLowerCase()
|
||||||
|
: "";
|
||||||
|
if (candidate === "yes") {
|
||||||
|
editable = true;
|
||||||
|
} else if (candidate !== "no") {
|
||||||
|
warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
scopeDepth,
|
scopeDepth,
|
||||||
@ -199,6 +213,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
doneTag,
|
doneTag,
|
||||||
cardDetail,
|
cardDetail,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
editable,
|
||||||
doneWindow,
|
doneWindow,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -103,6 +103,22 @@ export interface DataAdapter {
|
|||||||
getNoteTagTitles(noteId: string): Promise<string[]>;
|
getNoteTagTitles(noteId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RawTag {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow writable Joplin boundary used only by validated workflow moves. */
|
||||||
|
export interface MutationAdapter {
|
||||||
|
getNote(noteId: string): Promise<RawNote | null>;
|
||||||
|
getNoteTags(noteId: string): Promise<RawTag[]>;
|
||||||
|
getAllTags(): Promise<RawTag[]>;
|
||||||
|
createTag(title: string): Promise<RawTag>;
|
||||||
|
attachTag(tagId: string, noteId: string): Promise<void>;
|
||||||
|
detachTag(tagId: string, noteId: string): Promise<void>;
|
||||||
|
setTodoCompleted(noteId: string, completedTime: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag maintained by the "Repeating To-Dos" plugin family as a query index
|
* Tag maintained by the "Repeating To-Dos" plugin family as a query index
|
||||||
* for recurring to-dos. We treat its presence as the recurrence signal.
|
* for recurring to-dos. We treat its presence as the recurrence signal.
|
||||||
@ -145,11 +161,32 @@ export interface KanbanConfig {
|
|||||||
cardDetail: "hover" | "always" | "none";
|
cardDetail: "hover" | "always" | "none";
|
||||||
/** Cards initially shown, and revealed per "List more" action. */
|
/** Cards initially shown, and revealed per "List more" action. */
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
/** Whether this board explicitly opts in to persisted workflow moves. */
|
||||||
|
editable: boolean;
|
||||||
/** Days; Infinity means "all". */
|
/** Days; Infinity means "all". */
|
||||||
doneWindow: number;
|
doneWindow: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixed semantic destinations accepted from an editable kanban webview. */
|
||||||
|
export type KanbanDestination = "backlog" | "inProgress" | "done";
|
||||||
|
|
||||||
|
/** Constrained intent sent by the webview; the main process derives all writes. */
|
||||||
|
export interface MoveKanbanCardIntent {
|
||||||
|
type: "moveKanbanCard";
|
||||||
|
view: "kanban";
|
||||||
|
hostNoteId: string;
|
||||||
|
cardId: string;
|
||||||
|
destination: KanbanDestination;
|
||||||
|
rawConfig: string;
|
||||||
|
viewInstanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveCardResult =
|
||||||
|
| { status: "success"; viewInstanceId: string; changed: boolean }
|
||||||
|
| { status: "stale"; viewInstanceId: string; message: string }
|
||||||
|
| { status: "error"; viewInstanceId: string; message: string };
|
||||||
|
|
||||||
/** A note or to-do rendered as a kanban/matrix card. */
|
/** A note or to-do rendered as a kanban/matrix card. */
|
||||||
export interface KanbanCard {
|
export interface KanbanCard {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -389,6 +389,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gtd-kanban-card {
|
.gtd-kanban-card {
|
||||||
|
position: relative;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.4);
|
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.35em 0.5em;
|
padding: 0.35em 0.5em;
|
||||||
@ -399,6 +400,78 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2em;
|
||||||
|
right: 0.25em;
|
||||||
|
width: 1.65em;
|
||||||
|
height: 1.65em;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.45);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(128, 128, 128, 0.12);
|
||||||
|
color: inherit;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-editable {
|
||||||
|
padding-right: 2.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-editable:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-move-select {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0.35em;
|
||||||
|
padding: 0.15em 0.25em;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--joplin-background-color, white);
|
||||||
|
color: var(--joplin-color, black);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.86em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-move-select:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-drop-target {
|
||||||
|
outline: 1px dashed rgba(80, 120, 200, 0.8);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-drop-active {
|
||||||
|
outline: 3px solid rgba(50, 120, 220, 0.95);
|
||||||
|
background-color: rgba(50, 120, 220, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-pending .gtd-kanban-drag-handle {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-move-status {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
.gtd-kanban-card:hover {
|
.gtd-kanban-card:hover {
|
||||||
border-color: rgba(20, 20, 20, 0.9);
|
border-color: rgba(20, 20, 20, 0.9);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
".gtd-calendar-placeholder:not([data-gtd-processed])"
|
".gtd-calendar-placeholder:not([data-gtd-processed])"
|
||||||
);
|
);
|
||||||
|
|
||||||
placeholders.forEach(function (el) {
|
placeholders.forEach(function (el, placeholderIndex) {
|
||||||
el.setAttribute("data-gtd-processed", "true");
|
el.setAttribute("data-gtd-processed", "true");
|
||||||
|
|
||||||
const contentScriptId = el.getAttribute("data-content-script-id");
|
const contentScriptId = el.getAttribute("data-content-script-id");
|
||||||
@ -26,6 +26,10 @@
|
|||||||
const rawConfig = decodeURIComponent(
|
const rawConfig = decodeURIComponent(
|
||||||
el.getAttribute("data-config") || ""
|
el.getAttribute("data-config") || ""
|
||||||
);
|
);
|
||||||
|
const viewInstanceId =
|
||||||
|
"gtd-view-" + Date.now() + "-" + placeholderIndex + "-" +
|
||||||
|
Math.random().toString(36).slice(2);
|
||||||
|
el.setAttribute("data-view-instance-id", viewInstanceId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof webviewApi === "undefined" ||
|
typeof webviewApi === "undefined" ||
|
||||||
@ -48,10 +52,19 @@
|
|||||||
? "getGantt"
|
? "getGantt"
|
||||||
: "getEvents";
|
: "getEvents";
|
||||||
|
|
||||||
|
const renderContext = {
|
||||||
|
contentScriptId: contentScriptId,
|
||||||
|
rawConfig: rawConfig,
|
||||||
|
viewInstanceId: viewInstanceId,
|
||||||
|
requestSequence: 0,
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
|
||||||
webviewApi
|
webviewApi
|
||||||
.postMessage(contentScriptId, {
|
.postMessage(contentScriptId, {
|
||||||
type: messageType,
|
type: messageType,
|
||||||
rawConfig: rawConfig,
|
rawConfig: rawConfig,
|
||||||
|
viewInstanceId: viewInstanceId,
|
||||||
})
|
})
|
||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
@ -59,7 +72,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (blockType === "kanban") {
|
if (blockType === "kanban") {
|
||||||
renderKanban(el, payload, contentScriptId);
|
renderKanban(el, payload, contentScriptId, renderContext);
|
||||||
} else if (blockType === "matrix") {
|
} else if (blockType === "matrix") {
|
||||||
renderMatrix(el, payload, contentScriptId);
|
renderMatrix(el, payload, contentScriptId);
|
||||||
} else if (blockType === "gantt") {
|
} else if (blockType === "gantt") {
|
||||||
@ -465,7 +478,7 @@
|
|||||||
// Kanban board (gtd-kanban block)
|
// Kanban board (gtd-kanban block)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
function renderKanban(el, payload, contentScriptId) {
|
function renderKanban(el, payload, contentScriptId, renderContext) {
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
@ -503,17 +516,36 @@
|
|||||||
};
|
};
|
||||||
const detail = payload.cardDetail || "hover";
|
const detail = payload.cardDetail || "hover";
|
||||||
const pageSize = payload.pageSize || 10;
|
const pageSize = payload.pageSize || 10;
|
||||||
|
const interaction = payload.editable && payload.hostNoteId && renderContext
|
||||||
|
? {
|
||||||
|
context: renderContext,
|
||||||
|
hostNoteId: payload.hostNoteId,
|
||||||
|
root: el,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (renderContext && renderContext.statusMessage) {
|
||||||
|
const status = document.createElement("p");
|
||||||
|
status.className = renderContext.statusError
|
||||||
|
? "gtd-kanban-move-status gtd-calendar-error"
|
||||||
|
: "gtd-kanban-move-status";
|
||||||
|
status.textContent = renderContext.statusMessage;
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
status.setAttribute("aria-live", "polite");
|
||||||
|
status.setAttribute("aria-atomic", "true");
|
||||||
|
wrapper.appendChild(status);
|
||||||
|
}
|
||||||
|
|
||||||
if (layout.kind === "notebooks" && layout.groups.length > 0) {
|
if (layout.kind === "notebooks" && layout.groups.length > 0) {
|
||||||
layout.groups.forEach(function (group) {
|
layout.groups.forEach(function (group) {
|
||||||
wrapper.appendChild(
|
wrapper.appendChild(
|
||||||
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId)
|
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const board = layout.kind === "single" ? layout.board : emptyBoard;
|
const board = layout.kind === "single" ? layout.board : emptyBoard;
|
||||||
wrapper.appendChild(
|
wrapper.appendChild(
|
||||||
renderKanbanBoard(board, pageSize, detail, contentScriptId)
|
renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,9 +564,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.appendChild(wrapper);
|
el.appendChild(wrapper);
|
||||||
|
if (renderContext && renderContext.restoreFocusCardId) {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!renderContext.restoreFocusCardId) return;
|
||||||
|
const fallback = el.querySelector(".gtd-kanban-move-status") || wrapper;
|
||||||
|
fallback.setAttribute("tabindex", "-1");
|
||||||
|
fallback.focus();
|
||||||
|
renderContext.restoreFocusCardId = null;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) {
|
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction) {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "gtd-notebook-view";
|
section.className = "gtd-notebook-view";
|
||||||
const heading = document.createElement("h4");
|
const heading = document.createElement("h4");
|
||||||
@ -543,37 +584,43 @@
|
|||||||
section.appendChild(heading);
|
section.appendChild(heading);
|
||||||
renderViewWarnings(section, group.warnings);
|
renderViewWarnings(section, group.warnings);
|
||||||
section.appendChild(
|
section.appendChild(
|
||||||
renderKanbanBoard(group.board, pageSize, detail, contentScriptId)
|
renderKanbanBoard(group.board, pageSize, detail, contentScriptId, interaction)
|
||||||
);
|
);
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKanbanBoard(board, pageSize, detail, contentScriptId) {
|
function renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) {
|
||||||
const columns = document.createElement("div");
|
const columns = document.createElement("div");
|
||||||
columns.className = "gtd-kanban-columns";
|
columns.className = "gtd-kanban-columns";
|
||||||
|
const boardInteraction = interaction
|
||||||
|
? Object.assign({}, interaction, { board: columns })
|
||||||
|
: null;
|
||||||
|
|
||||||
columns.appendChild(
|
columns.appendChild(
|
||||||
renderColumn("Backlog", board.backlog, pageSize, detail, contentScriptId)
|
renderColumn("Backlog", "backlog", board.backlog, pageSize, detail, contentScriptId, boardInteraction)
|
||||||
);
|
);
|
||||||
columns.appendChild(
|
columns.appendChild(
|
||||||
renderColumn(
|
renderColumn(
|
||||||
"In Progress",
|
"In Progress",
|
||||||
|
"inProgress",
|
||||||
board.inProgress,
|
board.inProgress,
|
||||||
pageSize,
|
pageSize,
|
||||||
detail,
|
detail,
|
||||||
contentScriptId
|
contentScriptId,
|
||||||
|
boardInteraction
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
columns.appendChild(
|
columns.appendChild(
|
||||||
renderColumn("Done", board.done, pageSize, detail, contentScriptId)
|
renderColumn("Done", "done", board.done, pageSize, detail, contentScriptId, boardInteraction)
|
||||||
);
|
);
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderColumn(title, cards, pageSize, detail, contentScriptId) {
|
function renderColumn(title, destination, cards, pageSize, detail, contentScriptId, interaction) {
|
||||||
const column = document.createElement("div");
|
const column = document.createElement("div");
|
||||||
column.className = "gtd-kanban-column";
|
column.className = "gtd-kanban-column";
|
||||||
|
column.setAttribute("data-kanban-destination", destination);
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "gtd-kanban-column-header";
|
header.className = "gtd-kanban-column-header";
|
||||||
@ -581,14 +628,14 @@
|
|||||||
column.appendChild(header);
|
column.appendChild(header);
|
||||||
|
|
||||||
column.appendChild(
|
column.appendChild(
|
||||||
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
|
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
|
||||||
);
|
);
|
||||||
return column;
|
return column;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextListMoreTooltipId = 1;
|
let nextListMoreTooltipId = 1;
|
||||||
|
|
||||||
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId) {
|
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) {
|
||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
list.className = "gtd-kanban-cards";
|
list.className = "gtd-kanban-cards";
|
||||||
|
|
||||||
@ -633,7 +680,7 @@
|
|||||||
cards.length
|
cards.length
|
||||||
);
|
);
|
||||||
for (let index = visibleCount; index < nextVisibleCount; index += 1) {
|
for (let index = visibleCount; index < nextVisibleCount; index += 1) {
|
||||||
const card = renderCard(cards[index], detail, contentScriptId);
|
const card = renderCard(cards[index], detail, contentScriptId, interaction, destination);
|
||||||
if (moreControl) list.insertBefore(card, moreControl);
|
if (moreControl) list.insertBefore(card, moreControl);
|
||||||
else list.appendChild(card);
|
else list.appendChild(card);
|
||||||
}
|
}
|
||||||
@ -660,12 +707,75 @@
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(card, detail, contentScriptId) {
|
function renderCard(card, detail, contentScriptId, interaction, sourceDestination) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "gtd-kanban-card gtd-calendar-clickable";
|
el.className = "gtd-kanban-card gtd-calendar-clickable";
|
||||||
if (detail === "hover") el.className += " gtd-kanban-card-hover";
|
if (detail === "hover") el.className += " gtd-kanban-card-hover";
|
||||||
if (card.bgColour) el.style.backgroundColor = card.bgColour;
|
if (card.bgColour) el.style.backgroundColor = card.bgColour;
|
||||||
if (card.fgColour) el.style.color = card.fgColour;
|
if (card.fgColour) el.style.color = card.fgColour;
|
||||||
|
let suppressOpenUntil = 0;
|
||||||
|
|
||||||
|
if (interaction) {
|
||||||
|
el.classList.add("gtd-kanban-card-editable");
|
||||||
|
el.setAttribute("tabindex", "0");
|
||||||
|
el.setAttribute("role", "link");
|
||||||
|
el.setAttribute("aria-label", "Open " + card.title);
|
||||||
|
const handle = document.createElement("button");
|
||||||
|
handle.type = "button";
|
||||||
|
handle.className = "gtd-kanban-drag-handle";
|
||||||
|
handle.textContent = "↕";
|
||||||
|
handle.setAttribute("aria-hidden", "true");
|
||||||
|
handle.setAttribute("tabindex", "-1");
|
||||||
|
handle.addEventListener("click", function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
installKanbanPointerDrag(
|
||||||
|
handle,
|
||||||
|
el,
|
||||||
|
card,
|
||||||
|
sourceDestination,
|
||||||
|
interaction,
|
||||||
|
function () { suppressOpenUntil = Date.now() + 500; }
|
||||||
|
);
|
||||||
|
el.appendChild(handle);
|
||||||
|
|
||||||
|
const move = document.createElement("select");
|
||||||
|
move.className = "gtd-kanban-move-select";
|
||||||
|
move.setAttribute("aria-label", "Move " + card.title + " to");
|
||||||
|
move.disabled = interaction.context.pending;
|
||||||
|
[
|
||||||
|
["", "Move to…"],
|
||||||
|
["backlog", "Backlog"],
|
||||||
|
["inProgress", "In Progress"],
|
||||||
|
["done", "Done"],
|
||||||
|
].forEach(function (entry, index) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = entry[0];
|
||||||
|
option.textContent = entry[1];
|
||||||
|
if (index === 0) {
|
||||||
|
option.selected = true;
|
||||||
|
option.disabled = true;
|
||||||
|
}
|
||||||
|
if (entry[0] === sourceDestination) option.disabled = true;
|
||||||
|
move.appendChild(option);
|
||||||
|
});
|
||||||
|
move.addEventListener("click", function (event) { event.stopPropagation(); });
|
||||||
|
move.addEventListener("keydown", function (event) { event.stopPropagation(); });
|
||||||
|
move.addEventListener("change", function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!move.value || interaction.context.pending) return;
|
||||||
|
interaction.context.restoreFocusCardId = card.id;
|
||||||
|
submitKanbanMove(card, move.value, interaction);
|
||||||
|
});
|
||||||
|
el.appendChild(move);
|
||||||
|
if (interaction.context.restoreFocusCardId === card.id) {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!document.contains(move)) return;
|
||||||
|
move.focus();
|
||||||
|
interaction.context.restoreFocusCardId = null;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const titleRow = document.createElement("div");
|
const titleRow = document.createElement("div");
|
||||||
titleRow.className = "gtd-kanban-card-title";
|
titleRow.className = "gtd-kanban-card-title";
|
||||||
@ -698,12 +808,164 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener("click", function () {
|
el.addEventListener("click", function () {
|
||||||
|
if (Date.now() < suppressOpenUntil) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
openNote(contentScriptId, card.id);
|
openNote(contentScriptId, card.id);
|
||||||
});
|
});
|
||||||
|
if (interaction) {
|
||||||
|
el.addEventListener("keydown", function (event) {
|
||||||
|
if (event.target !== el) return;
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openNote(contentScriptId, card.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
|
||||||
|
let state = null;
|
||||||
|
const threshold = 6;
|
||||||
|
|
||||||
|
function clearTargets() {
|
||||||
|
interaction.board.querySelectorAll(".gtd-kanban-drop-target, .gtd-kanban-drop-active")
|
||||||
|
.forEach(function (column) {
|
||||||
|
column.classList.remove("gtd-kanban-drop-target", "gtd-kanban-drop-active");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(event) {
|
||||||
|
if (!state) return;
|
||||||
|
if (event && event.pointerId !== undefined && event.pointerId !== state.pointerId) return;
|
||||||
|
clearTargets();
|
||||||
|
cardEl.classList.remove("gtd-kanban-card-dragging");
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.key === "Escape" && state) {
|
||||||
|
suppressOpen();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener("pointerdown", function (event) {
|
||||||
|
if (interaction.context.pending || event.button !== 0) return;
|
||||||
|
state = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragging: false, target: null };
|
||||||
|
handle.setPointerCapture(event.pointerId);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener("pointermove", function (event) {
|
||||||
|
if (!state || event.pointerId !== state.pointerId) return;
|
||||||
|
const distance = Math.hypot(event.clientX - state.x, event.clientY - state.y);
|
||||||
|
if (!state.dragging && distance < threshold) return;
|
||||||
|
if (!state.dragging) {
|
||||||
|
state.dragging = true;
|
||||||
|
suppressOpen();
|
||||||
|
cardEl.classList.add("gtd-kanban-card-dragging");
|
||||||
|
interaction.board.querySelectorAll(".gtd-kanban-column").forEach(function (column) {
|
||||||
|
if (column.getAttribute("data-kanban-destination") !== source) {
|
||||||
|
column.classList.add("gtd-kanban-drop-target");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
interaction.board.querySelectorAll(".gtd-kanban-drop-active").forEach(function (column) {
|
||||||
|
column.classList.remove("gtd-kanban-drop-active");
|
||||||
|
});
|
||||||
|
const hit = document.elementFromPoint(event.clientX, event.clientY);
|
||||||
|
const target = hit && hit.closest ? hit.closest(".gtd-kanban-column") : null;
|
||||||
|
state.target = target && interaction.board.contains(target) &&
|
||||||
|
target.getAttribute("data-kanban-destination") !== source ? target : null;
|
||||||
|
if (state.target) state.target.classList.add("gtd-kanban-drop-active");
|
||||||
|
if (event.clientY < 40) window.scrollBy(0, -16);
|
||||||
|
else if (event.clientY > window.innerHeight - 40) window.scrollBy(0, 16);
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener("pointerup", function (event) {
|
||||||
|
if (!state || event.pointerId !== state.pointerId) return;
|
||||||
|
const destination = state.target && state.target.getAttribute("data-kanban-destination");
|
||||||
|
const shouldMove = state.dragging && destination && destination !== source;
|
||||||
|
cancel(event);
|
||||||
|
if (shouldMove) submitKanbanMove(card, destination, interaction);
|
||||||
|
});
|
||||||
|
handle.addEventListener("pointercancel", cancel);
|
||||||
|
handle.addEventListener("lostpointercapture", cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitKanbanMove(card, destination, interaction) {
|
||||||
|
const context = interaction.context;
|
||||||
|
if (context.pending) return;
|
||||||
|
context.pending = true;
|
||||||
|
context.statusMessage = "Moving “" + card.title + "”…";
|
||||||
|
context.statusError = false;
|
||||||
|
showKanbanStatus(interaction.root, context.statusMessage, false);
|
||||||
|
interaction.root.classList.add("gtd-kanban-pending");
|
||||||
|
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
|
||||||
|
.forEach(function (control) { control.disabled = true; });
|
||||||
|
webviewApi.postMessage(context.contentScriptId, {
|
||||||
|
type: "moveKanbanCard",
|
||||||
|
view: "kanban",
|
||||||
|
hostNoteId: interaction.hostNoteId,
|
||||||
|
cardId: card.id,
|
||||||
|
destination: destination,
|
||||||
|
rawConfig: context.rawConfig,
|
||||||
|
viewInstanceId: context.viewInstanceId,
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || result.viewInstanceId !== context.viewInstanceId) return;
|
||||||
|
context.statusMessage = result.status === "success"
|
||||||
|
? "Moved “" + card.title + "”."
|
||||||
|
: result.message || "The move could not be completed.";
|
||||||
|
context.statusError = result.status !== "success";
|
||||||
|
return refreshKanban(interaction.root, context);
|
||||||
|
}).catch(function () {
|
||||||
|
context.statusMessage = "The move could not be completed. Refresh and try again.";
|
||||||
|
context.statusError = true;
|
||||||
|
showKanbanStatus(interaction.root, context.statusMessage, true);
|
||||||
|
return refreshKanban(interaction.root, context);
|
||||||
|
}).finally(function () {
|
||||||
|
context.pending = false;
|
||||||
|
interaction.root.classList.remove("gtd-kanban-pending");
|
||||||
|
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
|
||||||
|
.forEach(function (control) { control.disabled = false; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showKanbanStatus(root, message, isError) {
|
||||||
|
let status = root.querySelector(".gtd-kanban-move-status");
|
||||||
|
if (!status) {
|
||||||
|
status = document.createElement("p");
|
||||||
|
const wrapper = root.querySelector(".gtd-kanban");
|
||||||
|
if (wrapper) wrapper.insertBefore(status, wrapper.firstChild);
|
||||||
|
}
|
||||||
|
status.className = isError
|
||||||
|
? "gtd-kanban-move-status gtd-calendar-error"
|
||||||
|
: "gtd-kanban-move-status";
|
||||||
|
status.textContent = message;
|
||||||
|
status.setAttribute("role", "status");
|
||||||
|
status.setAttribute("aria-live", isError ? "assertive" : "polite");
|
||||||
|
status.setAttribute("aria-atomic", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshKanban(root, context) {
|
||||||
|
const sequence = ++context.requestSequence;
|
||||||
|
return webviewApi.postMessage(context.contentScriptId, {
|
||||||
|
type: "getKanban",
|
||||||
|
rawConfig: context.rawConfig,
|
||||||
|
viewInstanceId: context.viewInstanceId,
|
||||||
|
}).then(function (payload) {
|
||||||
|
if (sequence !== context.requestSequence || !payload ||
|
||||||
|
payload.viewInstanceId !== context.viewInstanceId) return;
|
||||||
|
renderKanban(root, payload, context.contentScriptId, context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatCardDate(dateISO) {
|
function formatCardDate(dateISO) {
|
||||||
const date = isoToLocalDate(dateISO);
|
const date = isoToLocalDate(dateISO);
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
|
|||||||
85
src/index.ts
85
src/index.ts
@ -13,12 +13,19 @@ import collectMatrix from "./Gtd/collectMatrix";
|
|||||||
import parseGanttConfig from "./Gtd/parseGanttConfig";
|
import parseGanttConfig from "./Gtd/parseGanttConfig";
|
||||||
import collectGantt from "./Gtd/collectGantt";
|
import collectGantt from "./Gtd/collectGantt";
|
||||||
import matrixLabels from "./Gtd/matrixLabels";
|
import matrixLabels from "./Gtd/matrixLabels";
|
||||||
|
import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler";
|
||||||
import resolveNotebook from "./Gtd/resolveNotebook";
|
import resolveNotebook from "./Gtd/resolveNotebook";
|
||||||
import {
|
import {
|
||||||
emptyKanbanLayout,
|
emptyKanbanLayout,
|
||||||
emptyMatrixLayout,
|
emptyMatrixLayout,
|
||||||
} from "./Gtd/layoutPayload";
|
} from "./Gtd/layoutPayload";
|
||||||
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
|
import {
|
||||||
|
DataAdapter,
|
||||||
|
MutationAdapter,
|
||||||
|
RawFolder,
|
||||||
|
RawNote,
|
||||||
|
RawTag,
|
||||||
|
} from "./Gtd/types";
|
||||||
|
|
||||||
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
||||||
|
|
||||||
@ -61,6 +68,47 @@ const joplinAdapter: DataAdapter = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Writable operations are kept separate from the read-only collector adapter. */
|
||||||
|
const joplinMutationAdapter: MutationAdapter = {
|
||||||
|
async getNote(noteId: string): Promise<RawNote | null> {
|
||||||
|
try {
|
||||||
|
return await joplin.data.get(["notes", noteId], {
|
||||||
|
fields: NOTE_FIELDS_WITH_BODY,
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getNoteTags(noteId: string): Promise<RawTag[]> {
|
||||||
|
return fetchAllPages<RawTag>(["notes", noteId, "tags"], {
|
||||||
|
fields: ["id", "title"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllTags(): Promise<RawTag[]> {
|
||||||
|
return fetchAllPages<RawTag>(["tags"], { fields: ["id", "title"] });
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTag(title: string): Promise<RawTag> {
|
||||||
|
return joplin.data.post(["tags"], null, { title });
|
||||||
|
},
|
||||||
|
|
||||||
|
async attachTag(tagId: string, noteId: string): Promise<void> {
|
||||||
|
await joplin.data.post(["tags", tagId, "notes"], null, { id: noteId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async detachTag(tagId: string, noteId: string): Promise<void> {
|
||||||
|
await joplin.data.delete(["tags", tagId, "notes", noteId]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setTodoCompleted(noteId: string, completedTime: number): Promise<void> {
|
||||||
|
await joplin.data.put(["notes", noteId], null, {
|
||||||
|
todo_completed: completedTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advisory threshold: under `scope: all`, if more notes than this were scanned
|
* Advisory threshold: under `scope: all`, if more notes than this were scanned
|
||||||
* we surface a soft warning so a huge profile-wide scan is visible. Advisory
|
* we surface a soft warning so a huge profile-wide scan is visible. Advisory
|
||||||
@ -134,6 +182,8 @@ joplin.plugins.register({
|
|||||||
return handleGetEvents(message);
|
return handleGetEvents(message);
|
||||||
case "getKanban":
|
case "getKanban":
|
||||||
return handleGetKanban(message);
|
return handleGetKanban(message);
|
||||||
|
case "moveKanbanCard":
|
||||||
|
return handleMoveKanbanCard(message);
|
||||||
case "getMatrix":
|
case "getMatrix":
|
||||||
return handleGetMatrix(message);
|
return handleGetMatrix(message);
|
||||||
case "getGantt":
|
case "getGantt":
|
||||||
@ -148,6 +198,28 @@ joplin.plugins.register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleMoveKanbanCard(message: unknown) {
|
||||||
|
let host: { id: string; parentId: string } | null = null;
|
||||||
|
try {
|
||||||
|
const note = await joplin.workspace.selectedNote();
|
||||||
|
if (note) host = { id: note.id, parentId: note.parent_id };
|
||||||
|
} catch (hostError) {
|
||||||
|
console.warn("gtd-kanban: could not validate move host", hostError);
|
||||||
|
}
|
||||||
|
const result = await validateAndMoveKanbanCard(
|
||||||
|
message,
|
||||||
|
host,
|
||||||
|
joplinAdapter,
|
||||||
|
joplinMutationAdapter,
|
||||||
|
Date.now,
|
||||||
|
(error) => console.warn("gtd-kanban: move failed", error)
|
||||||
|
);
|
||||||
|
if (result.status !== "success") {
|
||||||
|
console.warn("gtd-kanban: move rejected", result.status, result.message);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetEvents(message: { rawConfig?: string }) {
|
async function handleGetEvents(message: { rawConfig?: string }) {
|
||||||
// 1. Parse the gtd-calendar config.
|
// 1. Parse the gtd-calendar config.
|
||||||
let rawParsed: any = {};
|
let rawParsed: any = {};
|
||||||
@ -222,7 +294,10 @@ async function handleGetEvents(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetKanban(message: { rawConfig?: string }) {
|
async function handleGetKanban(message: {
|
||||||
|
rawConfig?: string;
|
||||||
|
viewInstanceId?: string;
|
||||||
|
}) {
|
||||||
let rawParsed: any = {};
|
let rawParsed: any = {};
|
||||||
let configError: string | null = null;
|
let configError: string | null = null;
|
||||||
try {
|
try {
|
||||||
@ -252,6 +327,9 @@ async function handleGetKanban(message: { rawConfig?: string }) {
|
|||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
|
editable: config.editable,
|
||||||
|
hostNoteId: null,
|
||||||
|
viewInstanceId: message.viewInstanceId || null,
|
||||||
scopeAll: config.scopeAll,
|
scopeAll: config.scopeAll,
|
||||||
configError:
|
configError:
|
||||||
configError || "Could not determine which note contains this kanban.",
|
configError || "Could not determine which note contains this kanban.",
|
||||||
@ -275,6 +353,9 @@ async function handleGetKanban(message: { rawConfig?: string }) {
|
|||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
pageSize: config.pageSize,
|
pageSize: config.pageSize,
|
||||||
|
editable: config.editable,
|
||||||
|
hostNoteId: sourceNote.id,
|
||||||
|
viewInstanceId: message.viewInstanceId || null,
|
||||||
scopeAll: config.scopeAll,
|
scopeAll: config.scopeAll,
|
||||||
configError,
|
configError,
|
||||||
warnings: [
|
warnings: [
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
|||||||
describe("parseKanbanConfig", () => {
|
describe("parseKanbanConfig", () => {
|
||||||
test("applies SPEC defaults", () => {
|
test("applies SPEC defaults", () => {
|
||||||
const c = parseKanbanConfig({});
|
const c = parseKanbanConfig({});
|
||||||
expect(c).toMatchObject({
|
expect(c).toMatchObject({
|
||||||
title: null,
|
title: null,
|
||||||
scopeDepth: 0,
|
scopeDepth: 0,
|
||||||
notes: "all",
|
notes: "all",
|
||||||
@ -20,11 +20,26 @@ describe("parseKanbanConfig", () => {
|
|||||||
doneTag: "done",
|
doneTag: "done",
|
||||||
cardDetail: "hover",
|
cardDetail: "hover",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
editable: false,
|
||||||
doneWindow: 7,
|
doneWindow: 7,
|
||||||
});
|
});
|
||||||
expect(c.warnings).toHaveLength(0);
|
expect(c.warnings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("editable requires a yes/no scalar and defaults to read-only", () => {
|
||||||
|
expect(parseKanbanConfig({}).editable).toBe(false);
|
||||||
|
expect(parseKanbanConfig({ editable: " YES " }).editable).toBe(true);
|
||||||
|
expect(parseKanbanConfig({ editable: "No" }).editable).toBe(false);
|
||||||
|
|
||||||
|
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
|
||||||
|
const config = parseKanbanConfig({ editable: value });
|
||||||
|
expect(config.editable).toBe(false);
|
||||||
|
expect(config.warnings).toEqual([
|
||||||
|
`Invalid editable "${String(value)}" (using "no")`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("parses notes independently and rejects invalid or singular forms", () => {
|
test("parses notes independently and rejects invalid or singular forms", () => {
|
||||||
expect(parseKanbanConfig({ notes: " NONE " }).notes).toBe("none");
|
expect(parseKanbanConfig({ notes: " NONE " }).notes).toBe("none");
|
||||||
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
||||||
|
|||||||
147
src/tests/Gtd/kanbanMoveHandler.test.ts
Normal file
147
src/tests/Gtd/kanbanMoveHandler.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { validateAndMoveKanbanCard } from "../../Gtd/kanbanMoveHandler";
|
||||||
|
import { DataAdapter, MutationAdapter, RawFolder, RawNote, RawTag } from "../../Gtd/types";
|
||||||
|
|
||||||
|
const folders: RawFolder[] = [
|
||||||
|
{ id: "board", parent_id: "", title: "Board" },
|
||||||
|
{ id: "child", parent_id: "board", title: "Child" },
|
||||||
|
{ id: "outside", parent_id: "", title: "Outside" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function card(overrides: Partial<RawNote> = {}): RawNote {
|
||||||
|
return {
|
||||||
|
id: "card",
|
||||||
|
title: "Card",
|
||||||
|
parent_id: "board",
|
||||||
|
is_todo: 0,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(current: RawNote | null = card()) {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const read: DataAdapter = {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async () => [],
|
||||||
|
getNoteTagTitles: async () => [],
|
||||||
|
};
|
||||||
|
const mutation: MutationAdapter = {
|
||||||
|
getNote: async () => current,
|
||||||
|
getNoteTags: async () => [],
|
||||||
|
getAllTags: async () => [{ id: "progress", title: "in-progress" }],
|
||||||
|
createTag: async (title) => ({ id: title, title }),
|
||||||
|
attachTag: async (tagId) => { writes.push(`attach:${tagId}`); },
|
||||||
|
detachTag: async (tagId) => { writes.push(`detach:${tagId}`); },
|
||||||
|
setTodoCompleted: async (_noteId, value) => { writes.push(`complete:${value}`); },
|
||||||
|
};
|
||||||
|
return { read, mutation, writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function intent(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
type: "moveKanbanCard",
|
||||||
|
view: "kanban",
|
||||||
|
hostNoteId: "host",
|
||||||
|
cardId: "card",
|
||||||
|
destination: "inProgress",
|
||||||
|
rawConfig: "editable: yes\nnotes: all\ntodos: all",
|
||||||
|
viewInstanceId: "view-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = { id: "host", parentId: "board" };
|
||||||
|
|
||||||
|
describe("validateAndMoveKanbanCard", () => {
|
||||||
|
test("accepts a constrained editable move", async () => {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
await expect(validateAndMoveKanbanCard(intent(), host, read, mutation)).resolves.toEqual({
|
||||||
|
status: "success", viewInstanceId: "view-1", changed: true,
|
||||||
|
});
|
||||||
|
expect(writes).toEqual(["attach:progress"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{},
|
||||||
|
intent({ view: "matrix" }),
|
||||||
|
intent({ destination: "urgent" }),
|
||||||
|
intent({ cardId: "" }),
|
||||||
|
intent({ rawConfig: { editable: "yes" } }),
|
||||||
|
intent({ patch: { todo_completed: 1 } }),
|
||||||
|
intent({ patch: { todo_completed: 1 }, destination: "arbitrary" }),
|
||||||
|
])("rejects malformed or unconstrained input without writes", async (request) => {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
const result = await validateAndMoveKanbanCard(request, host, read, mutation);
|
||||||
|
expect(result.status).toBe("error");
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires the currently selected host and matching host identity", async () => {
|
||||||
|
for (const selected of [null, { id: "other", parentId: "board" }]) {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
const result = await validateAndMoveKanbanCard(intent(), selected, read, mutation);
|
||||||
|
expect(result.status).toBe("stale");
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["", "error"],
|
||||||
|
["editable: no", "error"],
|
||||||
|
["editable: maybe", "error"],
|
||||||
|
["editable: [yes", "stale"],
|
||||||
|
] as const)("rejects non-editable or invalid config %p", async (rawConfig, status) => {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
const result = await validateAndMoveKanbanCard(intent({ rawConfig }), host, read, mutation);
|
||||||
|
expect(result.status).toBe(status);
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[card({ id: "host" }), "dashboard"],
|
||||||
|
[card({ parent_id: "outside" }), "scope"],
|
||||||
|
[card({ body: "plain" }), "gtd block"],
|
||||||
|
])("rejects stale note state before writes", async (current, message) => {
|
||||||
|
const { read, mutation, writes } = setup(current);
|
||||||
|
const result = await validateAndMoveKanbanCard(intent(), host, read, mutation);
|
||||||
|
expect(result).toMatchObject({ status: "stale" });
|
||||||
|
expect((result as any).message).toContain(message);
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves children scope and notebook targeting from fresh folders", async () => {
|
||||||
|
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
|
||||||
|
const result = await validateAndMoveKanbanCard(
|
||||||
|
intent({ rawConfig: "editable: yes\nnotebook: Board\nscope: children" }),
|
||||||
|
host, read, mutation
|
||||||
|
);
|
||||||
|
expect(result.status).toBe("success");
|
||||||
|
expect(writes).toEqual(["attach:progress"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[card({ is_todo: 0 }), "editable: yes\nnotes: none\ntodos: all"],
|
||||||
|
[card({ is_todo: 1, body: "plain" }), "editable: yes\nnotes: all\ntodos: gtd-only"],
|
||||||
|
[card({ is_todo: 1 }), "editable: yes\nnotes: all\ntodos: none"],
|
||||||
|
])("rechecks type filters and opt-in on fresh data", async (current, rawConfig) => {
|
||||||
|
const { read, mutation, writes } = setup(current);
|
||||||
|
const result = await validateAndMoveKanbanCard(intent({ rawConfig }), host, read, mutation);
|
||||||
|
expect(result.status).toBe("stale");
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a safe stale result for API failures", async () => {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
read.getFolders = async () => { throw new Error("private diagnostic"); };
|
||||||
|
const result = await validateAndMoveKanbanCard(intent(), host, read, mutation);
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "error",
|
||||||
|
viewInstanceId: "view-1",
|
||||||
|
message: "Could not apply the move. Refresh and try again.",
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/tests/Gtd/kanbanMutation.test.ts
Normal file
138
src/tests/Gtd/kanbanMutation.test.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { moveKanbanItem } from "../../Gtd/kanbanMutation";
|
||||||
|
import { MutationAdapter, RawNote, RawTag } from "../../Gtd/types";
|
||||||
|
|
||||||
|
function note(overrides: Partial<RawNote> = {}): RawNote {
|
||||||
|
return {
|
||||||
|
id: "card",
|
||||||
|
title: "Card",
|
||||||
|
parent_id: "folder",
|
||||||
|
is_todo: 0,
|
||||||
|
todo_due: 123,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 456,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeAdapter(current: RawNote | null, attached: RawTag[] = [], all: RawTag[] = []) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let created = 0;
|
||||||
|
const adapter: MutationAdapter = {
|
||||||
|
getNote: async () => current,
|
||||||
|
getNoteTags: async () => attached.slice(),
|
||||||
|
getAllTags: async () => all.slice(),
|
||||||
|
createTag: async (title) => {
|
||||||
|
calls.push(`create:${title}`);
|
||||||
|
return { id: `created-${++created}`, title };
|
||||||
|
},
|
||||||
|
attachTag: async (tagId, noteId) => {
|
||||||
|
calls.push(`attach:${tagId}:${noteId}`);
|
||||||
|
},
|
||||||
|
detachTag: async (tagId, noteId) => {
|
||||||
|
calls.push(`detach:${tagId}:${noteId}`);
|
||||||
|
},
|
||||||
|
setTodoCompleted: async (noteId, value) => {
|
||||||
|
calls.push(`complete:${noteId}:${value}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { adapter, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = { inProgressTag: "in-progress", doneTag: "done" };
|
||||||
|
|
||||||
|
describe("moveKanbanItem", () => {
|
||||||
|
test.each([
|
||||||
|
["backlog", ["done", "in-progress"], ["detach:d:card", "detach:p:card"]],
|
||||||
|
["inProgress", ["done"], ["attach:p:card", "detach:d:card"]],
|
||||||
|
["done", ["in-progress"], ["attach:d:card", "detach:p:card"]],
|
||||||
|
] as const)("canonicalizes an ordinary note to %s", async (destination, titles, expected) => {
|
||||||
|
const tags = titles.map((title) => ({ id: title === "done" ? "d" : "p", title }));
|
||||||
|
const { adapter, calls } = fakeAdapter(note(), tags, [
|
||||||
|
{ id: "p", title: "In-Progress" },
|
||||||
|
{ id: "d", title: "Done" },
|
||||||
|
]);
|
||||||
|
const result = await moveKanbanItem(adapter, "card", destination, config);
|
||||||
|
expect(calls).toEqual(expected);
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["backlog", 99, ["complete:card:0", "detach:p:card"]],
|
||||||
|
["inProgress", 99, ["complete:card:0"]],
|
||||||
|
["done", 0, ["complete:card:777", "detach:p:card"]],
|
||||||
|
] as const)("canonicalizes a native to-do to %s", async (destination, completed, expected) => {
|
||||||
|
const { adapter, calls } = fakeAdapter(
|
||||||
|
note({ is_todo: 1, todo_completed: completed }),
|
||||||
|
[{ id: "p", title: "in-progress" }]
|
||||||
|
);
|
||||||
|
await moveKanbanItem(adapter, "card", destination, config, () => 777);
|
||||||
|
expect(calls).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses an existing case variant deterministically and never creates a duplicate", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note(), [], [
|
||||||
|
{ id: "z", title: "DONE" },
|
||||||
|
{ id: "a", title: "Done" },
|
||||||
|
]);
|
||||||
|
await moveKanbanItem(adapter, "card", "done", config);
|
||||||
|
expect(calls).toEqual(["attach:a:card"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a missing custom tag and preserves unrelated note fields", async () => {
|
||||||
|
const current = note({ title: "Keep", parent_id: "keep-folder", todo_due: 42 });
|
||||||
|
const { adapter, calls } = fakeAdapter(current);
|
||||||
|
const result = await moveKanbanItem(adapter, "card", "inProgress", {
|
||||||
|
inProgressTag: "doing",
|
||||||
|
doneTag: "finished",
|
||||||
|
});
|
||||||
|
expect(calls).toEqual(["create:doing", "attach:created-1:card"]);
|
||||||
|
expect(result.note).toBe(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes every attached duplicate of an unwanted tag", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note(), [
|
||||||
|
{ id: "z", title: "DONE" },
|
||||||
|
{ id: "a", title: "done" },
|
||||||
|
{ id: "other", title: "other" },
|
||||||
|
]);
|
||||||
|
await moveKanbanItem(adapter, "card", "backlog", config);
|
||||||
|
expect(calls).toEqual(["detach:a:card", "detach:z:card"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("already canonical states are idempotent", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note(), [{ id: "p", title: "in-progress" }]);
|
||||||
|
const result = await moveKanbanItem(adapter, "card", "inProgress", config);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(result.changed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not use done-tag to complete native to-dos", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note({ is_todo: 1 }), [
|
||||||
|
{ id: "d", title: "done" },
|
||||||
|
]);
|
||||||
|
await moveKanbanItem(adapter, "card", "done", config, () => 12);
|
||||||
|
expect(calls).toEqual(["complete:card:12"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails before writes when the card disappeared", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(null);
|
||||||
|
await expect(moveKanbanItem(adapter, "card", "done", config)).rejects.toThrow(
|
||||||
|
"no longer exists"
|
||||||
|
);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces partial write failures without stale compensation", async () => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note(), [
|
||||||
|
{ id: "p", title: "in-progress" },
|
||||||
|
], [{ id: "d", title: "done" }]);
|
||||||
|
adapter.detachTag = async () => {
|
||||||
|
throw new Error("detach failed");
|
||||||
|
};
|
||||||
|
await expect(moveKanbanItem(adapter, "card", "done", config)).rejects.toThrow(
|
||||||
|
"detach failed"
|
||||||
|
);
|
||||||
|
expect(calls).toEqual(["attach:d:card"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/tests/Gtd/moveProtocol.test.ts
Normal file
17
src/tests/Gtd/moveProtocol.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { isKanbanDestination } from "../../Gtd/moveProtocol";
|
||||||
|
|
||||||
|
describe("kanban move protocol", () => {
|
||||||
|
test.each(["backlog", "inProgress", "done"])(
|
||||||
|
"accepts fixed destination %s",
|
||||||
|
(destination) => {
|
||||||
|
expect(isKanbanDestination(destination)).toBe(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(["", "in-progress", "matrix", null, 1, { tag: "done" }])(
|
||||||
|
"rejects arbitrary destination %p",
|
||||||
|
(destination) => {
|
||||||
|
expect(isKanbanDestination(destination)).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user