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.
|
||||
|
||||
## 2.0.0 — Unreleased
|
||||
|
||||
### Opt-in editable Kanban
|
||||
|
||||
- Added `editable: yes | no` to `gtd-kanban`, defaulting to `no` so every
|
||||
existing board remains read-only.
|
||||
- Editable desktop boards support pointer drag handles and keyboard **Move to…**
|
||||
controls for Backlog, In Progress, and Done.
|
||||
- Ordinary-note moves persist configured completion/progress tags; native to-do
|
||||
moves persist native completion plus the configured progress tag. Unrelated
|
||||
fields, tags, content, dates, note type, and notebook ownership are preserved.
|
||||
- The webview submits constrained semantic intent. The main plugin process
|
||||
reparses configuration, refetches host/scope/item/tag state, derives minimal
|
||||
writes, rejects stale or malformed requests, and refreshes only the initiating
|
||||
view from canonical state.
|
||||
- Added pending/error feedback, pointer cancellation and movement thresholds,
|
||||
duplicate-request guards, request sequencing, live announcements, and focus
|
||||
restoration. Sorting and pagination remain authoritative after a move.
|
||||
- Pointer and keyboard workflows still require final manual Joplin acceptance;
|
||||
v2.0.0 has not been released or published.
|
||||
- Automated validation: 295 tests across 19 suites, TypeScript, webview syntax,
|
||||
whitespace, prohibited-reference checks, and production JPL inspection passed.
|
||||
|
||||
## 1.0.0 — 2026-07-28
|
||||
|
||||
### Card underline cleanup
|
||||
|
||||
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
|
||||
page-size: 10
|
||||
done-window: 7
|
||||
editable: yes
|
||||
```
|
||||
````
|
||||
|
||||
@ -141,6 +142,7 @@ not filtered by `done-window` because they have no completion timestamp. The
|
||||
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
|
||||
| `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. |
|
||||
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
|
||||
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard moves between columns on desktop. Invalid values warn and remain read-only. |
|
||||
|
||||
Use the two independent type filters to build focused views:
|
||||
|
||||
@ -219,7 +221,20 @@ Grouping is deliberately limited to the exact combination above. Plain
|
||||
and `scope: all` also remain aggregated; combining any of them with
|
||||
`group: notebook` shows a warning and disables grouping.
|
||||
|
||||
Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning. The board is **read-only** and never changes tags or completion. (Drag-and-drop is planned.)
|
||||
Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and never become recurring. Completed notes keep 📄 and use completed title styling. A custom `icon` overrides the fallback glyph. Note dates come only from the `gtd` block; to-do dates use the block first, then the native due date. Empty blocks opt in with defaults, while malformed blocks remain visible with fallback values and a warning.
|
||||
|
||||
Kanban remains read-only unless the block contains the exact opt-in
|
||||
`editable: yes`. On an editable desktop board, drag a card by its move handle or
|
||||
use its keyboard **Move to…** selector. Backlog removes workflow completion and
|
||||
progress, In Progress applies progress while reopening a to-do/removing a
|
||||
note's done tag, and Done completes a native to-do or applies the configured
|
||||
`done-tag` to an ordinary note. Moves never change content, dates, note type,
|
||||
notebook ownership, unrelated tags, or manual ordering; configured sorting and
|
||||
`page-size` are reapplied after a canonical refresh. Same-column, cancelled,
|
||||
outside, stale, and failed moves do not intentionally write. Completing a
|
||||
recurring native to-do uses Joplin's real completion field, so the Repeating
|
||||
To-Dos plugin may advance it; the board then refreshes from canonical state.
|
||||
Editing is supported in the desktop rendered-note webview only.
|
||||
|
||||
## The Matrix (Skeleton & Eisenhower)
|
||||
|
||||
@ -284,7 +299,10 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
|
||||
|
||||
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
|
||||
|
||||
Cards behave like kanban cards: compact, clickable, and read-only. Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄 when no icon is set, and never show completion or recurrence styling. Empty and malformed blocks follow the same opt-in and warning behavior as on kanban.
|
||||
Matrix cards are compact, clickable, and read-only in this release scope.
|
||||
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
|
||||
when no icon is set, and never show completion or recurrence styling. Empty and
|
||||
malformed blocks follow the same opt-in and warning behavior as on kanban.
|
||||
|
||||
## Gantt chart
|
||||
|
||||
@ -365,7 +383,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test # 251 unit tests
|
||||
npm test # 295 unit tests
|
||||
npm run dist # builds publish/*.jpl
|
||||
```
|
||||
|
||||
|
||||
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)
|
||||
page-size: 10 # positive integer (default: 10)
|
||||
done-window: 7 # days, or "all" (default: 7)
|
||||
editable: no # yes | no; persisted moves (default: no)
|
||||
```
|
||||
|
||||
Three columns are always shown: **Backlog**, **In Progress**, and **Done**.
|
||||
@ -111,7 +112,21 @@ Under `due-date`, dateless cards sort after dated cards regardless of direction.
|
||||
10. Each column initially renders at most that many cards and reveals another
|
||||
batch per **List more** click. Expansion is independent per column and resets
|
||||
when the rendered note reloads; headings and statistics retain complete totals.
|
||||
Read-only in the current version.
|
||||
|
||||
`editable` accepts scalar `yes | no` case-insensitively and defaults to `no`.
|
||||
Invalid, empty, or non-scalar values warn and remain read-only. With `yes`, the
|
||||
desktop rendered-note view exposes a pointer handle and keyboard **Move to…**
|
||||
selector. Destinations are fixed semantic states, never arbitrary patches:
|
||||
|
||||
| Destination | Ordinary opted-in note | Native Joplin to-do |
|
||||
|---|---|---|
|
||||
| Backlog | remove `done-tag` and `in-progress-tag` | set `todo_completed: 0`; remove `in-progress-tag` |
|
||||
| In Progress | add `in-progress-tag`; remove `done-tag` | set `todo_completed: 0`; add `in-progress-tag` |
|
||||
| Done | add `done-tag`; remove `in-progress-tag` | set `todo_completed` to current epoch ms; remove `in-progress-tag` |
|
||||
|
||||
Same-state moves are idempotent. Content, dates, note type, notebook,
|
||||
unrelated tags, and free-form ordering are preserved. Completing a recurring
|
||||
to-do uses native completion and may activate the Repeating To-Dos plugin.
|
||||
|
||||
### 2.4 The `gtd-matrix` block (lives in the matrix note)
|
||||
|
||||
@ -249,6 +264,11 @@ notes never become recurring.
|
||||
- **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`.
|
||||
- **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`).
|
||||
- **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches.
|
||||
- **Editable kanban:** only `editable: yes` adds a pointer handle and keyboard
|
||||
**Move to…** selector. Pointer movement has a threshold and is scoped to the
|
||||
originating board/notebook group. Pending, success, stale, and failure states
|
||||
are announced through an atomic live region; focus returns to the moved card
|
||||
or status fallback after canonical rerender.
|
||||
- **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter.
|
||||
- **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it.
|
||||
- **Drilldown:** every tile, card, hover-card row, and chip across all four view types is clickable and opens the source note.
|
||||
@ -275,7 +295,8 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
||||
│ │ depending on block type. Builds the
|
||||
│ │ calendar / board / matrix / Gantt DOM from
|
||||
│ │ returned payload. Click → postMessage
|
||||
│ │ ({type: "openNote", noteId}).
|
||||
│ │ ({type: "openNote", noteId}); editable
|
||||
│ │ kanban sends constrained move intent only.
|
||||
└──────────────┬──────────────┘
|
||||
│ postMessage / onMessage
|
||||
┌──────────────▼──────────────┐
|
||||
@ -300,6 +321,9 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
||||
│ │ collectGantt.ts)
|
||||
│ │ • handles openNote via
|
||||
│ │ joplin.commands.execute
|
||||
│ │ • reparses editable kanban config, refetches
|
||||
│ │ host/card/scope/tags, derives minimal writes,
|
||||
│ │ then returns a typed move result
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -329,7 +353,10 @@ SLICE8 batching remains transient webview state.
|
||||
real-world scales (largest tested: ~285 notes, single folder scope), but worth
|
||||
revisiting if a `children`-scope board on a large tree feels slow, especially
|
||||
with Persistent Layout keeping several boards rendered at once.
|
||||
- Refresh model: re-render occurs on note switch (Joplin behavior); no live refresh on background data changes. Fine for a read-only plugin; becomes a sharper question once the authoring axis (§9) lands, where a write on one rendered board should ideally refresh others.
|
||||
- Refresh model: normal background edits appear when Joplin rerenders the note.
|
||||
An editable-kanban move immediately refetches and rerenders only its initiating
|
||||
`viewInstanceId`; request sequencing rejects stale responses. Other open
|
||||
dashboards remain stale until their own rerender.
|
||||
|
||||
### Platform caveats
|
||||
|
||||
@ -474,19 +501,17 @@ remains eligibility-gated. Automated validation passed at 185 tests, the
|
||||
production JPL built successfully, and manual Joplin acceptance passed
|
||||
2026-07-27.
|
||||
|
||||
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic
|
||||
### v2.0.0 — Opt-in kanban authoring — IMPLEMENTED, RELEASE PENDING
|
||||
|
||||
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
|
||||
Kanban write-back is implemented behind `editable: yes`; existing blocks remain
|
||||
read-only. Pointer and keyboard controls submit constrained intent. The main
|
||||
process reparses configuration, validates the selected host and fresh scope/item
|
||||
eligibility, derives minimal tag/completion writes, and refreshes the initiating
|
||||
view from canonical data. Failures do not use stale rollback values. Cross-group
|
||||
movement and free-form ordering remain excluded. Release and manual Joplin
|
||||
acceptance are tracked in `SLICE14.md`.
|
||||
|
||||
**Drag-and-drop kanban (write-back).** The first feature making the plugin a **controller** of data rather than a viewer — warrants its own design pass.
|
||||
|
||||
- Dragging a card writes back to Joplin via the existing webview→plugin channel:
|
||||
- → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate).
|
||||
- → *Done*: set `todo_completed`.
|
||||
- → *Backlog*: remove the tag / clear completion.
|
||||
- **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes.
|
||||
- Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging.
|
||||
- **Multi-board refresh:** with Persistent Layout keeping several dashboards rendered, a write from one board leaves others stale until re-render — the refresh question is sharper in the fractal-dashboard architecture than for single-board use.
|
||||
### Future authoring — create from view
|
||||
|
||||
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.
|
||||
|
||||
|
||||
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
|
||||
task is completed or its status changes. Keep automated validation results and
|
||||
@ -8,9 +8,13 @@ manual Joplin acceptance recorded separately.
|
||||
|
||||
- [x] SLICE8 complete — incremental card limits
|
||||
- [x] SLICE9 complete — opted-in note cards
|
||||
- [ ] SLICE10 complete — child-notebook boards and matrices
|
||||
- [x] SLICE10 complete — child-notebook boards and matrices
|
||||
- [x] SLICE11 complete — ordinary-note completion with a `done` tag
|
||||
- [x] SLICE12 complete — independent project-note and task-to-do filters
|
||||
- [x] SLICE13 complete — underline removal for kanban and matrix cards
|
||||
- [x] SLICE14 complete — opt-in kanban drag and drop
|
||||
|
||||
Implementation order: SLICE8, then SLICE9, then SLICE10.
|
||||
Slices 8–13 are complete. SLICE14 is the next implementation slice.
|
||||
|
||||
## SLICE8 — Incremental card limits for kanban and matrix views
|
||||
|
||||
@ -159,7 +163,7 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
|
||||
- [x] Test the same invariant in Skeleton and Eisenhower matrices.
|
||||
- [x] Include both note and to-do cards in grouping tests.
|
||||
- [x] Test aggregate statistics and warnings for double-counting regressions.
|
||||
- [ ] Test independent batching and "List more" state across generated views.
|
||||
- [x] Test independent batching and "List more" state across generated views.
|
||||
- [x] Test that ungrouped `children`, numeric scope, `this-folder`, and
|
||||
`scope: all` still produce one view with unchanged behavior.
|
||||
|
||||
@ -176,16 +180,27 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
|
||||
grandchildren in Joplin.
|
||||
- [x] Confirm headings, ordering, empty-group handling, no duplication, sorting,
|
||||
styling, navigation, warnings, and independent expansion.
|
||||
- [ ] Mark SLICE10 complete in `SLICE10.md` and this file.
|
||||
- [x] Mark SLICE10 complete in `SLICE10.md` and this file.
|
||||
|
||||
## Final cross-slice verification
|
||||
|
||||
- [ ] Run the complete automated suite after all three slices are integrated.
|
||||
- [ ] Build the final production `.jpl` and record its path and version.
|
||||
- [x] Run the complete automated suite after all three slices are integrated.
|
||||
- [x] Build the final production `.jpl` and record its path and version.
|
||||
- [x] Verify a dashboard containing kanban and both matrix modes together.
|
||||
- [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks.
|
||||
- [x] Confirm reload resets every expanded list to its configured initial size.
|
||||
- [x] Record manual Joplin acceptance without conflating it with automated checks.
|
||||
- [ ] Re-run the repository-wide prohibited-reference audit.
|
||||
- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the
|
||||
- [x] Re-run the repository-wide prohibited-reference audit.
|
||||
- [x] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the
|
||||
final implementation state.
|
||||
|
||||
## Subsequent slice status
|
||||
|
||||
- [x] SLICE11 complete. Implementation, focused and full automation, production
|
||||
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||
- [x] SLICE12 complete. Implementation, focused and full automation, production
|
||||
JPL inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||
- [x] SLICE13 complete. Implementation, automation, production package
|
||||
inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||
- [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was
|
||||
received on 2026-07-31. Slice 15 remains not started.
|
||||
|
||||
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",
|
||||
"card-detail",
|
||||
"page-size",
|
||||
"editable",
|
||||
"done-window",
|
||||
];
|
||||
for (const key of Object.keys(input)) {
|
||||
@ -185,6 +186,19 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
||||
|
||||
const pageSize = parsePageSize(input["page-size"], warnings);
|
||||
|
||||
let editable = false;
|
||||
if (input.editable !== undefined) {
|
||||
const candidate =
|
||||
typeof input.editable === "string"
|
||||
? input.editable.trim().toLowerCase()
|
||||
: "";
|
||||
if (candidate === "yes") {
|
||||
editable = true;
|
||||
} else if (candidate !== "no") {
|
||||
warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
scopeDepth,
|
||||
@ -199,6 +213,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
||||
doneTag,
|
||||
cardDetail,
|
||||
pageSize,
|
||||
editable,
|
||||
doneWindow,
|
||||
warnings,
|
||||
};
|
||||
|
||||
@ -103,6 +103,22 @@ export interface DataAdapter {
|
||||
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
|
||||
* for recurring to-dos. We treat its presence as the recurrence signal.
|
||||
@ -145,11 +161,32 @@ export interface KanbanConfig {
|
||||
cardDetail: "hover" | "always" | "none";
|
||||
/** Cards initially shown, and revealed per "List more" action. */
|
||||
pageSize: number;
|
||||
/** Whether this board explicitly opts in to persisted workflow moves. */
|
||||
editable: boolean;
|
||||
/** Days; Infinity means "all". */
|
||||
doneWindow: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Fixed semantic destinations accepted from an editable kanban webview. */
|
||||
export type KanbanDestination = "backlog" | "inProgress" | "done";
|
||||
|
||||
/** Constrained intent sent by the webview; the main process derives all writes. */
|
||||
export interface MoveKanbanCardIntent {
|
||||
type: "moveKanbanCard";
|
||||
view: "kanban";
|
||||
hostNoteId: string;
|
||||
cardId: string;
|
||||
destination: KanbanDestination;
|
||||
rawConfig: string;
|
||||
viewInstanceId: string;
|
||||
}
|
||||
|
||||
export type MoveCardResult =
|
||||
| { status: "success"; viewInstanceId: string; changed: boolean }
|
||||
| { status: "stale"; viewInstanceId: string; message: string }
|
||||
| { status: "error"; viewInstanceId: string; message: string };
|
||||
|
||||
/** A note or to-do rendered as a kanban/matrix card. */
|
||||
export interface KanbanCard {
|
||||
id: string;
|
||||
|
||||
@ -389,6 +389,7 @@
|
||||
}
|
||||
|
||||
.gtd-kanban-card {
|
||||
position: relative;
|
||||
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||
border-radius: 4px;
|
||||
padding: 0.35em 0.5em;
|
||||
@ -399,6 +400,78 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.gtd-kanban-drag-handle {
|
||||
position: absolute;
|
||||
top: 0.2em;
|
||||
right: 0.25em;
|
||||
width: 1.65em;
|
||||
height: 1.65em;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(128, 128, 128, 0.45);
|
||||
border-radius: 3px;
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
color: inherit;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gtd-kanban-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gtd-kanban-card-editable {
|
||||
padding-right: 2.4em;
|
||||
}
|
||||
|
||||
.gtd-kanban-card-editable:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.gtd-kanban-move-select {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 0.35em;
|
||||
padding: 0.15em 0.25em;
|
||||
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||
border-radius: 3px;
|
||||
background-color: var(--joplin-background-color, white);
|
||||
color: var(--joplin-color, black);
|
||||
font: inherit;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.gtd-kanban-move-select:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.gtd-kanban-card-dragging {
|
||||
opacity: 0.55;
|
||||
outline: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.gtd-kanban-drop-target {
|
||||
outline: 1px dashed rgba(80, 120, 200, 0.8);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.gtd-kanban-drop-active {
|
||||
outline: 3px solid rgba(50, 120, 220, 0.95);
|
||||
background-color: rgba(50, 120, 220, 0.12);
|
||||
}
|
||||
|
||||
.gtd-kanban-pending .gtd-kanban-drag-handle {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.gtd-kanban-move-status {
|
||||
margin: 0.25em 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.gtd-kanban-card:hover {
|
||||
border-color: rgba(20, 20, 20, 0.9);
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
".gtd-calendar-placeholder:not([data-gtd-processed])"
|
||||
);
|
||||
|
||||
placeholders.forEach(function (el) {
|
||||
placeholders.forEach(function (el, placeholderIndex) {
|
||||
el.setAttribute("data-gtd-processed", "true");
|
||||
|
||||
const contentScriptId = el.getAttribute("data-content-script-id");
|
||||
@ -26,6 +26,10 @@
|
||||
const rawConfig = decodeURIComponent(
|
||||
el.getAttribute("data-config") || ""
|
||||
);
|
||||
const viewInstanceId =
|
||||
"gtd-view-" + Date.now() + "-" + placeholderIndex + "-" +
|
||||
Math.random().toString(36).slice(2);
|
||||
el.setAttribute("data-view-instance-id", viewInstanceId);
|
||||
|
||||
if (
|
||||
typeof webviewApi === "undefined" ||
|
||||
@ -48,10 +52,19 @@
|
||||
? "getGantt"
|
||||
: "getEvents";
|
||||
|
||||
const renderContext = {
|
||||
contentScriptId: contentScriptId,
|
||||
rawConfig: rawConfig,
|
||||
viewInstanceId: viewInstanceId,
|
||||
requestSequence: 0,
|
||||
pending: false,
|
||||
};
|
||||
|
||||
webviewApi
|
||||
.postMessage(contentScriptId, {
|
||||
type: messageType,
|
||||
rawConfig: rawConfig,
|
||||
viewInstanceId: viewInstanceId,
|
||||
})
|
||||
.then(function (payload) {
|
||||
if (!payload) {
|
||||
@ -59,7 +72,7 @@
|
||||
return;
|
||||
}
|
||||
if (blockType === "kanban") {
|
||||
renderKanban(el, payload, contentScriptId);
|
||||
renderKanban(el, payload, contentScriptId, renderContext);
|
||||
} else if (blockType === "matrix") {
|
||||
renderMatrix(el, payload, contentScriptId);
|
||||
} else if (blockType === "gantt") {
|
||||
@ -465,7 +478,7 @@
|
||||
// Kanban board (gtd-kanban block)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
function renderKanban(el, payload, contentScriptId) {
|
||||
function renderKanban(el, payload, contentScriptId, renderContext) {
|
||||
el.innerHTML = "";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
@ -503,17 +516,36 @@
|
||||
};
|
||||
const detail = payload.cardDetail || "hover";
|
||||
const pageSize = payload.pageSize || 10;
|
||||
const interaction = payload.editable && payload.hostNoteId && renderContext
|
||||
? {
|
||||
context: renderContext,
|
||||
hostNoteId: payload.hostNoteId,
|
||||
root: el,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (renderContext && renderContext.statusMessage) {
|
||||
const status = document.createElement("p");
|
||||
status.className = renderContext.statusError
|
||||
? "gtd-kanban-move-status gtd-calendar-error"
|
||||
: "gtd-kanban-move-status";
|
||||
status.textContent = renderContext.statusMessage;
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
status.setAttribute("aria-atomic", "true");
|
||||
wrapper.appendChild(status);
|
||||
}
|
||||
|
||||
if (layout.kind === "notebooks" && layout.groups.length > 0) {
|
||||
layout.groups.forEach(function (group) {
|
||||
wrapper.appendChild(
|
||||
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId)
|
||||
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
const board = layout.kind === "single" ? layout.board : emptyBoard;
|
||||
wrapper.appendChild(
|
||||
renderKanbanBoard(board, pageSize, detail, contentScriptId)
|
||||
renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction)
|
||||
);
|
||||
}
|
||||
|
||||
@ -532,9 +564,18 @@
|
||||
}
|
||||
|
||||
el.appendChild(wrapper);
|
||||
if (renderContext && renderContext.restoreFocusCardId) {
|
||||
setTimeout(function () {
|
||||
if (!renderContext.restoreFocusCardId) return;
|
||||
const fallback = el.querySelector(".gtd-kanban-move-status") || wrapper;
|
||||
fallback.setAttribute("tabindex", "-1");
|
||||
fallback.focus();
|
||||
renderContext.restoreFocusCardId = null;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) {
|
||||
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId, interaction) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "gtd-notebook-view";
|
||||
const heading = document.createElement("h4");
|
||||
@ -543,37 +584,43 @@
|
||||
section.appendChild(heading);
|
||||
renderViewWarnings(section, group.warnings);
|
||||
section.appendChild(
|
||||
renderKanbanBoard(group.board, pageSize, detail, contentScriptId)
|
||||
renderKanbanBoard(group.board, pageSize, detail, contentScriptId, interaction)
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderKanbanBoard(board, pageSize, detail, contentScriptId) {
|
||||
function renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) {
|
||||
const columns = document.createElement("div");
|
||||
columns.className = "gtd-kanban-columns";
|
||||
const boardInteraction = interaction
|
||||
? Object.assign({}, interaction, { board: columns })
|
||||
: null;
|
||||
|
||||
columns.appendChild(
|
||||
renderColumn("Backlog", board.backlog, pageSize, detail, contentScriptId)
|
||||
renderColumn("Backlog", "backlog", board.backlog, pageSize, detail, contentScriptId, boardInteraction)
|
||||
);
|
||||
columns.appendChild(
|
||||
renderColumn(
|
||||
"In Progress",
|
||||
"inProgress",
|
||||
board.inProgress,
|
||||
pageSize,
|
||||
detail,
|
||||
contentScriptId
|
||||
contentScriptId,
|
||||
boardInteraction
|
||||
)
|
||||
);
|
||||
columns.appendChild(
|
||||
renderColumn("Done", board.done, pageSize, detail, contentScriptId)
|
||||
renderColumn("Done", "done", board.done, pageSize, detail, contentScriptId, boardInteraction)
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function renderColumn(title, cards, pageSize, detail, contentScriptId) {
|
||||
function renderColumn(title, destination, cards, pageSize, detail, contentScriptId, interaction) {
|
||||
const column = document.createElement("div");
|
||||
column.className = "gtd-kanban-column";
|
||||
column.setAttribute("data-kanban-destination", destination);
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "gtd-kanban-column-header";
|
||||
@ -581,14 +628,14 @@
|
||||
column.appendChild(header);
|
||||
|
||||
column.appendChild(
|
||||
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
|
||||
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
|
||||
);
|
||||
return column;
|
||||
}
|
||||
|
||||
let nextListMoreTooltipId = 1;
|
||||
|
||||
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId) {
|
||||
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) {
|
||||
const list = document.createElement("div");
|
||||
list.className = "gtd-kanban-cards";
|
||||
|
||||
@ -633,7 +680,7 @@
|
||||
cards.length
|
||||
);
|
||||
for (let index = visibleCount; index < nextVisibleCount; index += 1) {
|
||||
const card = renderCard(cards[index], detail, contentScriptId);
|
||||
const card = renderCard(cards[index], detail, contentScriptId, interaction, destination);
|
||||
if (moreControl) list.insertBefore(card, moreControl);
|
||||
else list.appendChild(card);
|
||||
}
|
||||
@ -660,12 +707,75 @@
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderCard(card, detail, contentScriptId) {
|
||||
function renderCard(card, detail, contentScriptId, interaction, sourceDestination) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "gtd-kanban-card gtd-calendar-clickable";
|
||||
if (detail === "hover") el.className += " gtd-kanban-card-hover";
|
||||
if (card.bgColour) el.style.backgroundColor = card.bgColour;
|
||||
if (card.fgColour) el.style.color = card.fgColour;
|
||||
let suppressOpenUntil = 0;
|
||||
|
||||
if (interaction) {
|
||||
el.classList.add("gtd-kanban-card-editable");
|
||||
el.setAttribute("tabindex", "0");
|
||||
el.setAttribute("role", "link");
|
||||
el.setAttribute("aria-label", "Open " + card.title);
|
||||
const handle = document.createElement("button");
|
||||
handle.type = "button";
|
||||
handle.className = "gtd-kanban-drag-handle";
|
||||
handle.textContent = "↕";
|
||||
handle.setAttribute("aria-hidden", "true");
|
||||
handle.setAttribute("tabindex", "-1");
|
||||
handle.addEventListener("click", function (event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
installKanbanPointerDrag(
|
||||
handle,
|
||||
el,
|
||||
card,
|
||||
sourceDestination,
|
||||
interaction,
|
||||
function () { suppressOpenUntil = Date.now() + 500; }
|
||||
);
|
||||
el.appendChild(handle);
|
||||
|
||||
const move = document.createElement("select");
|
||||
move.className = "gtd-kanban-move-select";
|
||||
move.setAttribute("aria-label", "Move " + card.title + " to");
|
||||
move.disabled = interaction.context.pending;
|
||||
[
|
||||
["", "Move to…"],
|
||||
["backlog", "Backlog"],
|
||||
["inProgress", "In Progress"],
|
||||
["done", "Done"],
|
||||
].forEach(function (entry, index) {
|
||||
const option = document.createElement("option");
|
||||
option.value = entry[0];
|
||||
option.textContent = entry[1];
|
||||
if (index === 0) {
|
||||
option.selected = true;
|
||||
option.disabled = true;
|
||||
}
|
||||
if (entry[0] === sourceDestination) option.disabled = true;
|
||||
move.appendChild(option);
|
||||
});
|
||||
move.addEventListener("click", function (event) { event.stopPropagation(); });
|
||||
move.addEventListener("keydown", function (event) { event.stopPropagation(); });
|
||||
move.addEventListener("change", function (event) {
|
||||
event.stopPropagation();
|
||||
if (!move.value || interaction.context.pending) return;
|
||||
interaction.context.restoreFocusCardId = card.id;
|
||||
submitKanbanMove(card, move.value, interaction);
|
||||
});
|
||||
el.appendChild(move);
|
||||
if (interaction.context.restoreFocusCardId === card.id) {
|
||||
setTimeout(function () {
|
||||
if (!document.contains(move)) return;
|
||||
move.focus();
|
||||
interaction.context.restoreFocusCardId = null;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "gtd-kanban-card-title";
|
||||
@ -698,12 +808,164 @@
|
||||
}
|
||||
|
||||
el.addEventListener("click", function () {
|
||||
if (Date.now() < suppressOpenUntil) {
|
||||
return;
|
||||
}
|
||||
openNote(contentScriptId, card.id);
|
||||
});
|
||||
if (interaction) {
|
||||
el.addEventListener("keydown", function (event) {
|
||||
if (event.target !== el) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openNote(contentScriptId, card.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
|
||||
let state = null;
|
||||
const threshold = 6;
|
||||
|
||||
function clearTargets() {
|
||||
interaction.board.querySelectorAll(".gtd-kanban-drop-target, .gtd-kanban-drop-active")
|
||||
.forEach(function (column) {
|
||||
column.classList.remove("gtd-kanban-drop-target", "gtd-kanban-drop-active");
|
||||
});
|
||||
}
|
||||
|
||||
function cancel(event) {
|
||||
if (!state) return;
|
||||
if (event && event.pointerId !== undefined && event.pointerId !== state.pointerId) return;
|
||||
clearTargets();
|
||||
cardEl.classList.remove("gtd-kanban-card-dragging");
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
state = null;
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key === "Escape" && state) {
|
||||
suppressOpen();
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
handle.addEventListener("pointerdown", function (event) {
|
||||
if (interaction.context.pending || event.button !== 0) return;
|
||||
state = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragging: false, target: null };
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
handle.addEventListener("pointermove", function (event) {
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
const distance = Math.hypot(event.clientX - state.x, event.clientY - state.y);
|
||||
if (!state.dragging && distance < threshold) return;
|
||||
if (!state.dragging) {
|
||||
state.dragging = true;
|
||||
suppressOpen();
|
||||
cardEl.classList.add("gtd-kanban-card-dragging");
|
||||
interaction.board.querySelectorAll(".gtd-kanban-column").forEach(function (column) {
|
||||
if (column.getAttribute("data-kanban-destination") !== source) {
|
||||
column.classList.add("gtd-kanban-drop-target");
|
||||
}
|
||||
});
|
||||
}
|
||||
interaction.board.querySelectorAll(".gtd-kanban-drop-active").forEach(function (column) {
|
||||
column.classList.remove("gtd-kanban-drop-active");
|
||||
});
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const target = hit && hit.closest ? hit.closest(".gtd-kanban-column") : null;
|
||||
state.target = target && interaction.board.contains(target) &&
|
||||
target.getAttribute("data-kanban-destination") !== source ? target : null;
|
||||
if (state.target) state.target.classList.add("gtd-kanban-drop-active");
|
||||
if (event.clientY < 40) window.scrollBy(0, -16);
|
||||
else if (event.clientY > window.innerHeight - 40) window.scrollBy(0, 16);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerup", function (event) {
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
const destination = state.target && state.target.getAttribute("data-kanban-destination");
|
||||
const shouldMove = state.dragging && destination && destination !== source;
|
||||
cancel(event);
|
||||
if (shouldMove) submitKanbanMove(card, destination, interaction);
|
||||
});
|
||||
handle.addEventListener("pointercancel", cancel);
|
||||
handle.addEventListener("lostpointercapture", cancel);
|
||||
}
|
||||
|
||||
function submitKanbanMove(card, destination, interaction) {
|
||||
const context = interaction.context;
|
||||
if (context.pending) return;
|
||||
context.pending = true;
|
||||
context.statusMessage = "Moving “" + card.title + "”…";
|
||||
context.statusError = false;
|
||||
showKanbanStatus(interaction.root, context.statusMessage, false);
|
||||
interaction.root.classList.add("gtd-kanban-pending");
|
||||
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
|
||||
.forEach(function (control) { control.disabled = true; });
|
||||
webviewApi.postMessage(context.contentScriptId, {
|
||||
type: "moveKanbanCard",
|
||||
view: "kanban",
|
||||
hostNoteId: interaction.hostNoteId,
|
||||
cardId: card.id,
|
||||
destination: destination,
|
||||
rawConfig: context.rawConfig,
|
||||
viewInstanceId: context.viewInstanceId,
|
||||
}).then(function (result) {
|
||||
if (!result || result.viewInstanceId !== context.viewInstanceId) return;
|
||||
context.statusMessage = result.status === "success"
|
||||
? "Moved “" + card.title + "”."
|
||||
: result.message || "The move could not be completed.";
|
||||
context.statusError = result.status !== "success";
|
||||
return refreshKanban(interaction.root, context);
|
||||
}).catch(function () {
|
||||
context.statusMessage = "The move could not be completed. Refresh and try again.";
|
||||
context.statusError = true;
|
||||
showKanbanStatus(interaction.root, context.statusMessage, true);
|
||||
return refreshKanban(interaction.root, context);
|
||||
}).finally(function () {
|
||||
context.pending = false;
|
||||
interaction.root.classList.remove("gtd-kanban-pending");
|
||||
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle")
|
||||
.forEach(function (control) { control.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
function showKanbanStatus(root, message, isError) {
|
||||
let status = root.querySelector(".gtd-kanban-move-status");
|
||||
if (!status) {
|
||||
status = document.createElement("p");
|
||||
const wrapper = root.querySelector(".gtd-kanban");
|
||||
if (wrapper) wrapper.insertBefore(status, wrapper.firstChild);
|
||||
}
|
||||
status.className = isError
|
||||
? "gtd-kanban-move-status gtd-calendar-error"
|
||||
: "gtd-kanban-move-status";
|
||||
status.textContent = message;
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", isError ? "assertive" : "polite");
|
||||
status.setAttribute("aria-atomic", "true");
|
||||
}
|
||||
|
||||
function refreshKanban(root, context) {
|
||||
const sequence = ++context.requestSequence;
|
||||
return webviewApi.postMessage(context.contentScriptId, {
|
||||
type: "getKanban",
|
||||
rawConfig: context.rawConfig,
|
||||
viewInstanceId: context.viewInstanceId,
|
||||
}).then(function (payload) {
|
||||
if (sequence !== context.requestSequence || !payload ||
|
||||
payload.viewInstanceId !== context.viewInstanceId) return;
|
||||
renderKanban(root, payload, context.contentScriptId, context);
|
||||
});
|
||||
}
|
||||
|
||||
function formatCardDate(dateISO) {
|
||||
const date = isoToLocalDate(dateISO);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
|
||||
85
src/index.ts
85
src/index.ts
@ -13,12 +13,19 @@ import collectMatrix from "./Gtd/collectMatrix";
|
||||
import parseGanttConfig from "./Gtd/parseGanttConfig";
|
||||
import collectGantt from "./Gtd/collectGantt";
|
||||
import matrixLabels from "./Gtd/matrixLabels";
|
||||
import { validateAndMoveKanbanCard } from "./Gtd/kanbanMoveHandler";
|
||||
import resolveNotebook from "./Gtd/resolveNotebook";
|
||||
import {
|
||||
emptyKanbanLayout,
|
||||
emptyMatrixLayout,
|
||||
} from "./Gtd/layoutPayload";
|
||||
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
|
||||
import {
|
||||
DataAdapter,
|
||||
MutationAdapter,
|
||||
RawFolder,
|
||||
RawNote,
|
||||
RawTag,
|
||||
} from "./Gtd/types";
|
||||
|
||||
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
||||
|
||||
@ -61,6 +68,47 @@ const joplinAdapter: DataAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Writable operations are kept separate from the read-only collector adapter. */
|
||||
const joplinMutationAdapter: MutationAdapter = {
|
||||
async getNote(noteId: string): Promise<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
|
||||
* we surface a soft warning so a huge profile-wide scan is visible. Advisory
|
||||
@ -134,6 +182,8 @@ joplin.plugins.register({
|
||||
return handleGetEvents(message);
|
||||
case "getKanban":
|
||||
return handleGetKanban(message);
|
||||
case "moveKanbanCard":
|
||||
return handleMoveKanbanCard(message);
|
||||
case "getMatrix":
|
||||
return handleGetMatrix(message);
|
||||
case "getGantt":
|
||||
@ -148,6 +198,28 @@ joplin.plugins.register({
|
||||
},
|
||||
});
|
||||
|
||||
async function handleMoveKanbanCard(message: unknown) {
|
||||
let host: { id: string; parentId: string } | null = null;
|
||||
try {
|
||||
const note = await joplin.workspace.selectedNote();
|
||||
if (note) host = { id: note.id, parentId: note.parent_id };
|
||||
} catch (hostError) {
|
||||
console.warn("gtd-kanban: could not validate move host", hostError);
|
||||
}
|
||||
const result = await validateAndMoveKanbanCard(
|
||||
message,
|
||||
host,
|
||||
joplinAdapter,
|
||||
joplinMutationAdapter,
|
||||
Date.now,
|
||||
(error) => console.warn("gtd-kanban: move failed", error)
|
||||
);
|
||||
if (result.status !== "success") {
|
||||
console.warn("gtd-kanban: move rejected", result.status, result.message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleGetEvents(message: { rawConfig?: string }) {
|
||||
// 1. Parse the gtd-calendar config.
|
||||
let rawParsed: any = {};
|
||||
@ -222,7 +294,10 @@ async function handleGetEvents(message: { rawConfig?: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetKanban(message: { rawConfig?: string }) {
|
||||
async function handleGetKanban(message: {
|
||||
rawConfig?: string;
|
||||
viewInstanceId?: string;
|
||||
}) {
|
||||
let rawParsed: any = {};
|
||||
let configError: string | null = null;
|
||||
try {
|
||||
@ -252,6 +327,9 @@ async function handleGetKanban(message: { rawConfig?: string }) {
|
||||
title: config.title,
|
||||
cardDetail: config.cardDetail,
|
||||
pageSize: config.pageSize,
|
||||
editable: config.editable,
|
||||
hostNoteId: null,
|
||||
viewInstanceId: message.viewInstanceId || null,
|
||||
scopeAll: config.scopeAll,
|
||||
configError:
|
||||
configError || "Could not determine which note contains this kanban.",
|
||||
@ -275,6 +353,9 @@ async function handleGetKanban(message: { rawConfig?: string }) {
|
||||
title: config.title,
|
||||
cardDetail: config.cardDetail,
|
||||
pageSize: config.pageSize,
|
||||
editable: config.editable,
|
||||
hostNoteId: sourceNote.id,
|
||||
viewInstanceId: message.viewInstanceId || null,
|
||||
scopeAll: config.scopeAll,
|
||||
configError,
|
||||
warnings: [
|
||||
|
||||
@ -9,7 +9,7 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||
describe("parseKanbanConfig", () => {
|
||||
test("applies SPEC defaults", () => {
|
||||
const c = parseKanbanConfig({});
|
||||
expect(c).toMatchObject({
|
||||
expect(c).toMatchObject({
|
||||
title: null,
|
||||
scopeDepth: 0,
|
||||
notes: "all",
|
||||
@ -20,11 +20,26 @@ describe("parseKanbanConfig", () => {
|
||||
doneTag: "done",
|
||||
cardDetail: "hover",
|
||||
pageSize: 10,
|
||||
editable: false,
|
||||
doneWindow: 7,
|
||||
});
|
||||
expect(c.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("editable requires a yes/no scalar and defaults to read-only", () => {
|
||||
expect(parseKanbanConfig({}).editable).toBe(false);
|
||||
expect(parseKanbanConfig({ editable: " YES " }).editable).toBe(true);
|
||||
expect(parseKanbanConfig({ editable: "No" }).editable).toBe(false);
|
||||
|
||||
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
|
||||
const config = parseKanbanConfig({ editable: value });
|
||||
expect(config.editable).toBe(false);
|
||||
expect(config.warnings).toEqual([
|
||||
`Invalid editable "${String(value)}" (using "no")`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("parses notes independently and rejects invalid or singular forms", () => {
|
||||
expect(parseKanbanConfig({ notes: " NONE " }).notes).toBe("none");
|
||||
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
|
||||
|
||||
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