Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

51 changed files with 3178 additions and 3090 deletions

4
.gitignore vendored
View File

@ -3,7 +3,3 @@ dist/
node_modules/
publish/
build-archive/
scripts/
SLICE*.md
TASKS.md
SPEC.md

View File

@ -2,93 +2,6 @@
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.2.1 — Unreleased
### Matrix completion action
- Editable Skeleton and Eisenhower matrix move menus now include **Done**.
- Done completes native to-dos and applies the configured `done-tag` to
ordinary notes, removing the completed card from the matrix.
- Successful matrix moves now perform a settled follow-up refresh after the
immediate redraw, avoiding stale cards while Joplin publishes tag/to-do writes.
## 2.2.0
### Tag-driven editable Skeleton matrix
- Skeleton routing no longer uses native to-do due dates or `gtd` dates.
- Cards route by tag precedence: `urgent`, `in-progress`, `on-deck`, then
Backlog. The tags remain configurable with `urgent-tag`, `in-progress-tag`,
and the new `on-deck-tag` option.
- Skeleton quadrants are now Urgent, In Progress, On Deck, and Backlog.
- `editable: yes` enables the existing pointer drag and keyboard move controls
for Skeleton matrices. A move normalizes the three workflow tags while
preserving dates, completion, content, notebooks, and unrelated tags.
## 2.1.0 — Unreleased
### Notebook-tree exclusions
- Added `exclude-notebook:` to `gtd-calendar`, `gtd-kanban`, and `gtd-matrix`.
It accepts one notebook reference or a YAML list and removes every resolved
notebook plus all descendants from the otherwise normal scope.
- Exclusions support the same folder ID, unique bare-title, and full-path forms
as `notebook:`. Unknown or ambiguous entries warn without affecting other
valid exclusions.
- Exclusions apply after every scope form, including `scope: all`, and happen
before note bodies or tags are fetched. Statistics and global-scope warnings
reflect the post-exclusion scan.
- Notebook-grouped boards and matrices omit excluded trees. Editable kanban and
Eisenhower moves revalidate the exclusion boundary against fresh state.
## 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.
- Refined the move control into a compact disclosure arrow beneath the drag
handle; its accessible destination menu stays closed until requested instead
of occupying a permanent row on every card.
- 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.
### Eisenhower label migration
- Eisenhower matrices retain urgent/important axes but now use Do Next,
Scheduled, On Deck, and Backlog consistently with Skeleton mode. No saved data
migration is required because quadrant labels are display-only.
### Opt-in editable Eisenhower matrices
- Added `editable: yes | no` to `gtd-matrix`, defaulting to read-only and
applying only to Eisenhower mode with distinct urgent/important tag names.
- Editable matrices reuse the compact pointer handle and disclosure-controlled
keyboard menu for Do Next, Scheduled, On Deck, and Backlog. Skeleton remains
supported and read-only; requesting editing warns without adding inert UI.
- Moves derive minimal urgent/important tag changes from fixed semantic
destinations and fresh host, scope/group, eligibility, completion, mode, and
configuration state. Completion, dates, content, note type, notebook, and
unrelated tags remain unchanged.
- Added same-board target scoping, cancellation, pending/error feedback, live
announcements, focus recovery, canonical refresh, and stale-response guards.
- Automated validation now covers 374 tests across 22 suites. Production build
inspection and manual Joplin acceptance remain pending.
## 1.0.0 — 2026-07-28
### Card underline cleanup

View File

@ -1,142 +0,0 @@
# 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 `SLICE15.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
`SLICE15.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 `SLICE15.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 `SLICE15.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 `SLICE15.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.

132
README.md
View File

@ -48,7 +48,6 @@ Goes in the note that should display the calendar. All options are optional.
| `title` | text | — | Heading above the calendar. |
| `scope` | `this-folder`, `children`, a number, or `all` | `this-folder` | Which folders to scan: just this one, all descendants, or *n* levels down (`scope: 2`). Broad scopes scan every note body in the tree — fine for normal folders, worth knowing for huge ones. **`scope: all` scans every notebook in your entire profile** — powerful for a global dashboard, but potentially slow on large profiles; a footnote reports how much was scanned, and a ⚠ appears past ~2000 notes. `notebook:` is ignored under `scope: all`. |
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook instead of the one holding this note — even one in a different tree. `scope` then applies relative to it. A bare name must be unique; if several notebooks share it, qualify with a path (`Work/Archive`). Unknown/ambiguous → ⚠ warning + falls back to this note's folder. |
| `exclude-notebook` | one notebook or a YAML list | — | Remove each named notebook and its complete descendant tree from the resolved scope. Uses the same unique name, full path, and folder-id forms as `notebook`. Unknown or ambiguous entries warn and exclude nothing. This still applies under `scope: all`. |
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes appear: only those with a `gtd` block, every note in scope, or none. |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same, for to-dos. `todos: all` is handy — every to-do with a due date lands on the calendar with zero markup. |
| `sort` | `asc`, `desc` | `asc` | Order of events *within* each day/week/month and in Unscheduled. The timeline itself is always chronological. |
@ -56,20 +55,6 @@ Goes in the note that should display the calendar. All options are optional.
Invalid values fall back to defaults and show a ⚠ warning above the calendar.
For a profile-wide dashboard that omits private or expensive notebook trees,
use a scalar for one exclusion or a YAML list for several:
```yaml
scope: all
exclude-notebook:
- IBM
- Archive/Old Projects
```
Exclusions are resolved independently and removed before notes are fetched.
Excluding a notebook also excludes every notebook beneath it. With a narrower
scope, an exclusion outside that scope is simply a no-op.
## The `gtd` block
Goes in any note or to-do that should participate in calendar, kanban, or matrix views. **An empty block is valid**—it is the minimal opt-in. All properties are optional:
@ -112,7 +97,6 @@ done-tag: done
card-detail: hover
page-size: 10
done-window: 7
editable: yes
```
````
@ -147,7 +131,6 @@ not filtered by `done-window` because they have no completion timestamp. The
| `title` | text | — | Heading above the board. |
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar, including `all` (every notebook — see the calendar table's caution). |
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
| `exclude-notebook` | one notebook or a YAML list | — | Remove each resolved notebook and all descendants after applying `scope`; see the calendar table for resolution and warning rules. |
| `group` | `notebook` | — | With the literal `scope: children`, render a separate board for the root notebook and every non-empty descendant instead of one aggregated board. Other scope forms warn and remain aggregated. |
| `notes` | `all`, `none` | `all` | Which project notes appear. `all` means every ordinary note in scope that explicitly opts in with a `gtd` block; it never includes plain notes without that block. |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. `gtd-only` requires a `gtd` block; `all` includes every to-do in scope; `none` excludes all to-dos. This option does not affect opted-in ordinary notes. |
@ -158,7 +141,6 @@ 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:
@ -237,21 +219,7 @@ 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.
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
activate the compact arrow beneath it to open the keyboard-accessible **Move
to…** menu. 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.
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.)
## The Matrix (Skeleton & Eisenhower)
@ -261,8 +229,7 @@ A third view: a 2×2 prioritisation matrix for incomplete to-dos and ordinary no
```gtd-matrix
title: Priorities
todos: all
mode: eisenhower
editable: yes
mode: skeleton
page-size: 8
done-tag: done
```
@ -282,62 +249,42 @@ Two modes:
### `mode: skeleton` (default) — the Skeleton Matrix
Built to complement the kanban and calendar as a four-state, tag-driven workflow. Due dates and `gtd` dates do not affect Skeleton placement. When old or externally edited cards carry overlapping workflow tags, the first matching state below wins:
Built to complement the kanban and calendar, using the tags and dates you already maintain. Rows ask *am I on this?* (the in-progress tag); columns ask *is the clock running?* (due within `urgent-window` days — overdue counts — or carrying the urgent tag as a manual override):
| | **Priority** | **Current / backlog** |
| | **Due soon** | **Not due soon** |
|---|---|---|
| **Now** | Urgent (`urgent`) | In Progress (`in-progress`) |
| **Next / later** | On Deck (`on-deck`) | Backlog (none) |
| **Active** | Do Next | Scheduled |
| **Not active** | On Deck | Backlog |
Precedence is Urgent, In Progress, On Deck, then Backlog. Completed to-dos and notes carrying `done-tag` do not appear at all.
**On Deck** is the quadrant to watch: due soon, not yet started. Nothing is ever labelled "Eliminate" — a Backlog item is simply low priority, not condemned. Dateless in-progress work sits in Scheduled (active, no clock). Eligible ordinary notes use their `gtd` date and the same tag rules; completed to-dos and notes carrying `done-tag` do not appear at all.
### `mode: eisenhower`urgent/important axes
### `mode: eisenhower`the classic
Eisenhower retains urgent/important tag semantics while using the same
constructive quadrant names as Skeleton: Do Next (both), Scheduled (important),
On Deck (urgent), and Backlog (neither).
Quadrants from the urgent/important tags: Do First (both), Schedule (important), Delegate (urgent), Eliminate (neither).
### Options
| Option | Values | Default | Description |
|---|---|---|---|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves on desktop. Ambiguous tag configurations warn and disable editing. |
| `title` | text | — | Heading above the matrix. |
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
| `exclude-notebook` | one notebook or a YAML list | — | Remove each resolved notebook and all descendants after applying `scope`; see the calendar table for resolution and warning rules. |
| `group` | `notebook` | — | With the literal `scope: children`, render a separate complete matrix for the root notebook and every non-empty descendant. Other scope forms warn and remain aggregated. |
| `notes` | `all`, `none` | `all` | Which project notes appear. `all` includes only ordinary notes explicitly opted in with a `gtd` block; `none` produces a task-only matrix. |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. This option does not affect ordinary notes, which appear only when they contain a `gtd` block. |
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; Skeleton's Urgent destination. |
| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. |
| `important-tag` | any tag name | `important` | Eisenhower's important axis (unused in Skeleton mode). |
| `in-progress-tag` | any tag name | `in-progress` | Skeleton's In Progress destination; shared with kanban by default. |
| `on-deck-tag` | any tag name | `on-deck` | Skeleton's On Deck destination. |
| `in-progress-tag` | any tag name | `in-progress` | Skeleton mode's active row — same tag as the kanban's In Progress column. |
| `urgent-window` | integer (days) | `3` | Skeleton mode: how close a due date must be to count as "due soon". |
| `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. |
| `page-size` | positive integer | `10` | Cards initially shown in each quadrant and revealed by each **List more** click. Invalid values warn and fall back to 10. |
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.
On an editable matrix, drag a card by its move handle or use the
disclosure arrow beneath it to open the keyboard **Move to…** menu. Do Next adds
both axis tags; Scheduled keeps only important; On Deck keeps only urgent; and
Backlog removes both. **Done** completes a native to-do or applies the configured
`done-tag` to an ordinary note, so the card leaves the matrix. Quadrant moves
never change completion, dates, content, notebook, or unrelated tags, and
sorting plus pagination remain authoritative after the matrix refreshes. In
Skeleton mode, quadrant destinations keep exactly one of the three workflow
tags (or none for Backlog); due dates are never changed.
After any successful matrix move, the view refreshes in place and performs a
short follow-up refresh to account for Joplin's write propagation; reloading the
dashboard note is not required.
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.
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.
## Gantt chart
@ -418,7 +365,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop
```bash
npm install
npm test # 374 unit tests
npm test # 251 unit tests
npm run dist # builds publish/*.jpl
```
@ -426,54 +373,11 @@ Design notes and the full specification live in [SPEC.md](SPEC.md). Release hist
### Cutting a release
Joplin discovers plugins from npm and imports qualifying packages into its
official plugin repository automatically. A separate pull request to
`joplin/plugins` is not normally required.
1. Bump `version` in `src/manifest.json`, `package.json`, and `package-lock.json` (the manifest and package versions must match).
2. Add an entry to `CHANGELOG.md`.
3. `npm publish` (the `prepare` script rebuilds the `.jpl` fresh from source, so you can't accidentally publish a stale build).
1. Bump `version` in `src/manifest.json`, `package.json`, and the root package
entries in `package-lock.json`. All versions must match.
2. Finish the release entry in `CHANGELOG.md`, commit the release, and push it.
3. Sign in to npm and confirm the expected account and public registry:
```bash
npm login
npm whoami
npm config get registry
```
Publishing requires npm two-factor authentication or a granular access token
that is permitted to publish.
4. Confirm the version has not already been published, then build and inspect
the exact package contents:
```bash
npm view joplin-plugin-gtd-calendar@VERSION version
npm run dist
npm pack --dry-run
```
The dry run must include `package.json`, this README, and matching `.jpl` and
`.json` files under `publish/`. Verify that the JSON manifest and JPL manifest
both contain the intended version. Published npm name/version pairs are
immutable, so a version cannot be corrected and reused after publication.
5. Publish publicly and verify npm's `latest` tag:
```bash
npm publish --access public
npm view joplin-plugin-gtd-calendar@VERSION
npm view joplin-plugin-gtd-calendar dist-tags
```
The `prepare` lifecycle rebuilds the `.jpl` during `npm publish`. This package's
name begins with `joplin-plugin-`, its npm keywords include `joplin-plugin`, and
its `publish/` directory contains the `.jpl` and `.json` required by Joplin's
importer. The official repository refreshes about every 30 minutes; after
publishing, confirm the release appears on
[Joplin Plugins](https://joplinapp.org/plugins/) and in Joplin's plugin search.
If publishing fails with an authentication-looking `E404` or `E401`, run
`npm whoami` and sign in again. If the package already exists, the signed-in npm
account must be one of its maintainers.
If `npm publish` fails with `E404` on the `PUT`, check `npm whoami` first — a 401 there means your login session has expired, which npm surfaces as a misleading 404 on publish. Fix with `npm login --auth-type=legacy`, or avoid it entirely with a [granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens) so publishing never needs an interactive session.
## Credits & license

76
SLICE1.md Normal file
View File

@ -0,0 +1,76 @@
# SLICE 1 — Groundwork: process rules + body-fetch optimization
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-15). All six tasks done; 97/97 tests pass. Ready to
proceed to SLICE2.
Decision recorded 2026-07-15: body-fetch optimization (Task 4) is scoped
**plumbing-only, behaviour-preserving** — add the fields-hint adapter param and
drop `body` only where `extractGtdBlock` is provably never called (both-`none`
paths). No change to card styling/title for `todos: all`. This lands the adapter
plumbing SLICE3's `scope: all` needs without altering render output.
Pending user input (non-blocking, defaults recorded in SLICE4):
1. gtd-gantt fence disambiguation: `project:` key present = item block,
absent = chart block (chart filters via `filter-project:`).
2. Dateless gantt items: excluded with a warning (no Unscheduled strip).
## Goal
Set up the working rules for the whole effort and land the performance
optimization that `scope: all` (SLICE3) depends on.
## Tasks
- [x] Verify `.git/info/exclude` covers local guidance, planning files, and`n other workspace-only metadata. Confirmed 2026-07-15; no change needed.
- [x] Establish the .jpl archive rule: `scripts/archive-jpl.sh` moves existing
`publish/*.jpl` to `build-archive/<name>-<version>-<yyyymmdd-HHMMSS>.jpl`.
Wired as the `predist` npm script so it runs automatically before every
`npm run dist`. Validated 2026-07-15 (archives + no-op-on-empty both work).
CORRECTION (2026-07-15, during first real `npm run dist`): the archive was
originally written to `publish/archive/`, but webpack's `buildMain` step
runs `fs.removeSync(publishDir)` (wipes it) and `package.json`
`"files": ["publish"]` would ship it to npm. Moved to repo-root
`build-archive/` (git-ignored, outside publish/). local guidance + .gitignore
updated. Re-validated with a full `npm run dist`: archive survives, fresh
jpl written to publish/.
- [x] Fix `jest.config.js` ESM/CJS mismatch (`export default``module.exports`).
Done 2026-07-15: `npx jest` runs, all 88 tests pass (8 suites).
- [x] Body-fetch optimization (plumbing-only, behaviour-preserving). Done 2026-07-15:
- `DataAdapter.getNotesInFolder` gains an `includeBody: boolean` fetch hint;
`RawNote.body` is now optional. Real adapter (`index.ts`) requests
`NOTE_FIELDS_WITH_BODY` vs `NOTE_FIELDS` accordingly.
- Each collector computes `needsBody` before the fetch:
events = `notes !== "none" || todos !== "none"`;
kanban/matrix = `todos !== "none"`.
These are exactly the paths where `extractGtdBlock` is reached, so render
output is identical — body is only dropped for degenerate empty views.
- `extractGtdBlock` param widened to `string | undefined` (already guarded
by `body || ""`).
Investigation confirmed the slice's stated "todos: all" win is NOT
behaviour-preserving (block is read for card styling even in `all` mode),
so it was deliberately excluded per the recorded decision. The value here is
the adapter plumbing that SLICE3's `scope: all` needs.
- [x] Unit tests for the fields-hint logic. Done 2026-07-15:
`src/tests/Gtd/fieldsHint.test.ts` — a recording adapter asserts each
collector passes the correct `includeBody` flag per mode (9 cases).
- [x] Run full test suite; record results here. 2026-07-15: **97 passed, 97 total**
(9 suites) — 88 pre-existing + 9 new fields-hint cases.
## Decisions / notes
- Node on this machine: 18.19.1 (WSL Ubuntu). Jest 30.
- Run all commands via `wsl -d Ubuntu -- bash -c "cd ~/projects/joplin-plugin-gtd-calendar && ..."`.
## Resume notes
SLICE1 is complete. Files touched:
- `jest.config.js` (`export default``module.exports`)
- `scripts/archive-jpl.sh` (new) + `predist` npm script in `package.json`
- `src/Gtd/types.ts` (`RawNote.body` optional; `getNotesInFolder` gains `includeBody`)
- `src/index.ts` (`NOTE_FIELDS` / `NOTE_FIELDS_WITH_BODY`, conditional fetch)
- `src/Gtd/collectEvents.ts`, `collectKanban.ts`, `collectMatrix.ts` (`needsBody`)
- `src/Gtd/gtdBlock.ts` (param `string | undefined`)
- `src/tests/Gtd/fieldsHint.test.ts` (new)
Nothing left open. `npx tsc --noEmit` shows only pre-existing `api/` typing
errors (generated Joplin typings referencing absent modules); no new errors in
`src/`. Next: SLICE2.

478
SLICE10.md Normal file
View File

@ -0,0 +1,478 @@
# SLICE 10 — Separate child-notebook kanban and matrix views
> **State-saving rule:** update this file and `TASKS.md` after every completed
> task and whenever work pauses. Automated checks and manual Joplin acceptance
> must be recorded separately.
## Status
**COMPLETE.** Phases 18 are implemented and validated. Automated checks and
manual Joplin acceptance passed; README, SPEC, and v1.0.0 CHANGELOG coverage are
complete.
## Goal
When a kanban or matrix block uses `scope: children` with `group: notebook`,
render a separate complete view for the root notebook and each descendant notebook that owns eligible
cards. This keeps large notebook trees browsable without nesting subsections
inside one board or matrix.
## Confirmed behavior
- Multi-view rendering requires both the literal `scope: children` keyword and
`group: notebook`.
- `scope: children` without `group: notebook` retains the current aggregated
single view.
- `this-folder`, numeric depths (including very large values), and `scope: all`
keep their current single aggregated view.
- The root notebook gets its own view for cards it owns directly.
- Every descendant notebook gets its own view for cards it owns directly.
- Cards are not rolled up into ancestor views; each card appears exactly once.
- Empty notebook views are omitted.
- If every notebook is empty, show the existing overall empty board/matrix once.
- Each view heading uses a full notebook path so duplicate titles are clear.
- Views use deterministic depth-first tree order: root first, then descendants;
siblings sort case-insensitively by title with folder ID as a stable tie-breaker.
- Each generated view contains the normal three kanban columns or four matrix
quadrants, rather than notebook subsections inside those buckets.
- Each bucket receives independent SLICE8 visible-count state and the configured
`page-size`.
- SLICE9 normal-note cards group by `parent_id` exactly like to-do cards.
- Overall scan statistics count folders and notes once. Card totals equal the sum
of all non-empty notebook groups.
## Example
```text
Projects <- root view (direct cards only)
├── Home <- separate Home view
│ ├── Repairs <- separate Repairs view
│ └── Garden <- separate Garden view
└── Work <- separate Work view
└── Release <- separate Release view
```
If `Home` has no eligible cards, its view is omitted; `Repairs` and `Garden`
still appear if they have cards.
## Architecture and data flow
```text
parse scope
children + group: notebook -> scopeDepth: Infinity + groupByNotebook: true
all other combinations -> existing depth + groupByNotebook: false
|
v
resolve root notebook + ordered scoped folder metadata
|
v
collector scans each folder once
single mode -> existing aggregate board/matrix
grouped mode -> one exact-owner board/matrix per folder
|
v
plugin payload: discriminated single or notebook-grouped result
|
v
webview
single -> current component
grouped -> repeat complete component per non-empty notebook group
(each component owns independent SLICE8 state)
```
## Data-contract decision
Use a discriminated view layout instead of returning both aggregate and grouped
copies of every card:
```ts
type KanbanLayout =
| { kind: "single"; board: KanbanBoard }
| { kind: "notebooks"; groups: NotebookKanbanGroup[] };
type MatrixLayout =
| { kind: "single"; board: MatrixBoard }
| { kind: "notebooks"; groups: NotebookMatrixGroup[] };
```
Each group carries `folderId`, `notebookPath`, its board/matrix, `cardCount`, and
item-level warnings for that notebook. Config warnings, notebook-resolution
warnings, and profile-wide soft-cap warnings remain at the overall block level.
The empty grouped case carries `groups: []` and renders one existing empty state.
## Implementation plan
### Phase 1 — Preserve the explicit grouping intent
- [x] Add `groupByNotebook: boolean` to `KanbanConfig` and `MatrixConfig`
in `src/Gtd/types.ts`.
- [x] Recognize `group: notebook` in both parsers.
- [x] Set `scopeDepth = Infinity` for literal `scope: children`, preserving
its existing recursion behavior.
- [x] Set `groupByNotebook = true` only when literal `scope: children` and
`group: notebook` appear together.
- [x] Default `groupByNotebook` to false, including `scope: children` without
the grouping option.
- [x] Keep `this-folder`, numeric depth, invalid-value fallback, and
`scope: all` parsing unchanged.
- [x] Warn and remain non-grouped when `group: notebook` is combined with
another scope, or when `group:` has an unsupported value.
- [x] Add parser tests covering the grouped combination and every non-grouped
scope form.
A boolean is deliberately preferred over a broad scope-enum refactor: existing
collectors still need `scopeDepth` and `scopeAll`, while this slice needs only
one additional rendering decision.
### Phase 1 completion record
- Kanban and matrix configs now preserve explicit notebook-grouping intent
independently of recursive scope depth.
- Existing `scope: children` dashboards remain aggregated unless they opt in
with `group: notebook`.
- Focused validation: 4 suites and 104 tests passed.
- Full validation: 14 suites and 205 tests passed.
- TypeScript, whitespace, and prohibited-reference checks passed.
- No manual Joplin test is required for this parser-only phase.
### Phase 2 — Ordered notebook metadata helper
- [x] Extend `folderScope.ts` or add a focused `folderTree.ts` helper that returns
scoped folder metadata rather than only IDs when grouping is requested.
- [x] Include `id`, `parentId`, display title, and full path for the resolved root
and every descendant.
- [x] Build paths from the actual notebook tree, including ancestors above a
`notebook:`-selected root so headings remain unambiguous.
- [x] Order root first, then perform depth-first traversal with siblings sorted
by case-insensitive title and folder ID tie-breaker.
- [x] Handle missing/empty titles with a stable documented fallback such as
`Untitled notebook` while retaining the folder ID tie-breaker.
- [x] Defend against malformed folder data (orphan parent references and cycles)
without hanging; keep every reachable scoped folder at most once.
- [x] Leave `resolveScopedFolderIds` behavior intact for calendars, Gantt, and
non-grouped views.
### Phase 2 completion record
- Added `folderTree.ts` with typed scoped-folder metadata: folder ID, parent ID,
display title, and full notebook path.
- Ordering is deterministic root-first depth-first; siblings sort by
case-insensitive display title and then folder ID.
- Notebook-selected roots retain real ancestor paths above the selected root.
- Blank titles use `Untitled notebook`.
- Missing roots return no metadata; orphan roots remain usable; visited guards
prevent cycles and duplicate traversal.
- The legacy `resolveScopedFolderIds` implementation and output remain unchanged.
- Focused validation: 4 suites and 52 tests passed.
- Full validation: 15 suites and 214 tests passed.
- TypeScript, whitespace, and prohibited-reference checks passed.
- No manual Joplin test is required for this pure metadata phase.
### Phase 3 — Grouped collector contracts
- [x] Add shared notebook-view metadata types plus `KanbanLayout` and
`MatrixLayout` discriminated unions in `src/Gtd/types.ts` or collector-local
result types where appropriate.
- [x] Refactor kanban collection so each eligible card is appended to the board
associated with the current scanned `folderId` when grouping is enabled.
- [x] Apply done-window filtering and sort each kanban group's columns using the
existing logic.
- [x] Refactor matrix collection equivalently and sort each group's quadrants.
- [x] Attach malformed-block/item warnings to the owning notebook group.
- [x] Omit groups whose final `cardCount` is zero, including groups emptied by
done-window or eligibility filtering.
- [x] Retain the current aggregate path and result behavior when grouping is off.
- [x] Compute overall `scannedFolders`, `scannedNotes`, and `cardCount` once,
without summing repeated scans or duplicating cards.
- [x] Ensure the host view note is excluded before group counts are finalized.
### Phase 3 completion record
- Added shared notebook-view metadata plus discriminated single/notebook layouts
for kanban and matrix results. The existing top-level board remains a temporary
empty alias in grouped mode so later payload consumers could migrate cleanly
without duplicating cards. The alias was removed after the Phase 5 webview
migration.
- Grouped collectors scan every scoped folder once, bucket cards by the folder
currently being scanned, apply existing filtering and sorting per group, and
preserve the aggregate collector path when grouping is disabled.
- Empty groups are omitted after filtering. Item warnings remain with their
owning non-empty group; overall folder, note, and card totals count unique
scans/cards once.
- Added exact-owner coverage for root/child/grandchild cards, mixed notes and
to-dos, host-note exclusion, empty groups, done-window removal, group-local
warnings, ordering, sorting, both matrix modes, and single-layout regressions.
- Focused validation: 4 suites and 110 tests passed.
- Full validation: 15 suites and 219 tests passed.
- Production TypeScript/webpack build and `git diff --check` passed; the package
was created at `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`.
- No manual Joplin test is required for this collector-contract phase; grouped
payload and visual acceptance remain in Phases 48.
### Phase 4 — Plugin payloads and warning ownership
- [x] Update `handleGetKanban` and `handleGetMatrix` in `src/index.ts` to return
the discriminated layout plus SLICE8 `pageSize`.
- [x] Keep config warnings, notebook-resolution warnings, config parse errors,
and `scope: all` soft-cap warnings at the overall block level.
- [x] Send item-level warnings inside their notebook group in grouped mode.
- [x] Preserve the current top-level warning array in single mode.
- [x] Return a structurally valid empty layout when the source note cannot be
resolved.
- [x] Keep overall statistics visible once per source block; optionally show a
compact per-notebook card count in each generated heading, but do not add
separate scan totals that could imply folders were scanned repeatedly.
### Phase 4 completion record
- Kanban and matrix handlers now send the collector's discriminated `layout`
together with normalized `pageSize`, one overall statistics object, and the
existing global configuration fields.
- Missing-source responses use the requested single/notebook discriminator and
return a structurally valid empty layout, including matrix labels and scope
metadata.
- Configuration, YAML parse, notebook-resolution, and soft-cap warnings remain
overall. Single-mode item warnings retain the top-level warning path; grouped
item warnings remain inside their exact-owner notebook group.
- A temporary legacy `board` alias kept the single-view webview working during
this phase. It was removed in Phase 5 after the webview migrated to `layout`;
grouped mode never duplicated cards into an aggregate payload.
- Added pure payload-boundary helpers and tests for all missing-source layouts
and the no-aggregate compatibility behavior.
- Focused validation: 3 suites and 83 tests passed.
- Full validation: 16 suites and 221 tests passed.
- TypeScript with `--skipLibCheck`, `git diff --check`, and the production
webpack/JPL build passed. The current artifact is
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`.
- No manual Joplin test is required for this payload-only phase; rendering and
manual acceptance remain in Phases 58.
### Phase 5 — Webview component reuse
- [x] Separate the existing kanban wrapper concerns (overall title, errors,
global warnings, stats) from rendering one kanban board component.
- [x] Do the same for one matrix component.
- [x] In single mode, render exactly one component with current markup and visual
behavior.
- [x] In grouped mode, iterate groups in payload order and render one full
component per group beneath a notebook-path heading.
- [x] Render each group's item warnings adjacent to that group.
- [x] Reuse the SLICE8 incremental-list helper inside every component; closure
state must not be shared between groups or buckets.
- [x] When `groups` is empty, render the existing empty board/matrix once rather
than showing a blank block.
- [x] Add only the spacing/heading CSS required to distinguish notebook views;
preserve column and matrix layout styles.
### Phase 5 completion record
- Split kanban and matrix rendering into overall wrappers plus reusable
one-board/one-matrix component functions. Single layouts retain the existing
component markup and visual behavior.
- Notebook layouts render every non-empty group in collector order beneath its
full notebook path, with item warnings adjacent to the owning view.
- Every generated component calls the existing SLICE8 incremental-list helper
separately for each column/quadrant, giving every bucket independent closure
state and the configured `pageSize`.
- Empty notebook layouts render one ordinary empty board or matrix, preserving
the established overall empty presentation.
- Removed the temporary top-level `board` payload alias. The webview consumes
`layout` directly, retaining only a defensive fallback for stale payloads.
- Added only shared notebook-view spacing and heading styles; existing board,
matrix, card, and batching styles remain unchanged.
- Full validation: JavaScript syntax, TypeScript with `--skipLibCheck`,
`git diff --check`, 16 suites/220 tests, and the production webpack/JPL build
all passed. The current artifact is
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`.
- Browser-only interaction testing remains deliberately manual because the
repository has no DOM harness; Phase 6 records that boundary explicitly.
### Phase 6 — Automated tests
#### Scope/parser tests
- [x] `children` sets infinite depth plus grouping.
- [x] Numeric depth remains non-grouped, including a numeric string.
- [x] `this-folder`, invalid scope, and omitted scope remain non-grouped.
- [x] `scope: all` remains non-grouped and retains notebook-conflict warnings.
#### Folder-tree tests
- [x] Root plus multiple children and grandchildren.
- [x] Deterministic depth-first order regardless of input folder order.
- [x] Case-insensitive sibling ordering and folder-ID tie-breaker.
- [x] Duplicate titles represented by unambiguous full paths.
- [x] A `notebook:`-selected child root with its ancestor path retained.
- [x] Empty titles, orphan parents, and cycle protection.
#### Kanban collector tests
- [x] Root-owned and descendant-owned cards appear in separate exact-owner groups.
- [x] Every card appears once with no ancestor rollup.
- [x] Empty groups are omitted while non-empty grandchildren remain.
- [x] Done-window filtering can remove an otherwise non-empty group.
- [x] Per-group sorting and warnings are correct.
- [x] SLICE9 note and to-do cards group identically.
- [x] Aggregate stats and card totals are not double-counted.
- [x] Non-grouped scope results remain unchanged.
#### Matrix collector tests
- [x] Repeat exact-owner, omission, sorting, warning, mixed-card, and total-count
coverage for Skeleton and Eisenhower modes.
- [x] Confirm all four quadrants remain local to their owning notebook group.
- [x] Confirm non-grouped scope results remain unchanged.
#### Payload/rendering contracts
- [x] Test single and grouped payload construction at the thinnest practical
boundary without adding a new browser-test dependency solely for this slice.
- [x] If SLICE8 introduced reusable DOM tests, extend them to prove independent
visible counts across notebook components; otherwise cover this manually.
- [x] Run focused parser, folder-tree, kanban, and matrix tests.
- [x] Run the complete Jest suite and record suite/test totals.
- [x] Run `npm run dist` and record the produced `.jpl` path.
- [x] Run `git diff --check` and the prohibited-reference audit.
### Phase 6 completion record
- Parser coverage now explicitly locks literal `children` grouping, numeric and
default non-grouped scopes, invalid fallbacks, and `scope: all` notebook
conflict warnings for both kanban and matrix.
- Existing folder-tree coverage proves root/descendant traversal, deterministic
ordering, duplicate and blank titles, selected-root ancestor paths, orphans,
cycles, and unchanged legacy ID-only scope behavior.
- Collector coverage now includes exact ownership, no rollups, empty-group and
done-window omission, local sorting/warnings, mixed cards, unique totals, all
four local quadrants in both matrix modes, and single-layout regressions for
`this-folder`, numeric depth, recursive children, and `scope: all`.
- Pure layout helpers cover valid empty single/grouped payload construction.
The repository still has no DOM harness, so independent click-state remains
an explicit Phase 8 manual acceptance check rather than a test-only browser
abstraction.
- Focused validation: 5 suites and 121 tests passed.
- Full validation: 16 suites and 230 tests passed.
- JavaScript syntax, TypeScript with `--skipLibCheck`, `git diff --check`, and
the prohibited-reference audit passed.
- `npm run dist` produced
`publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl` (163,328 bytes,
SHA-256 `cc5eddd2dcd74453e5ebd2c2fd4635cb34eb5996bf736efd54441931f2a54930`).
The five-file tar archive was inspected and contains the grouped notebook-view
runtime markers.
- No manual Joplin acceptance was performed; that remains Phase 8.
### Phase 7 — Documentation
- [x] Update README.md to state that `scope: children` plus `group: notebook`
produces separate notebook views for kanban and matrix blocks.
- [x] Add a parent/child/grandchild example showing root ownership, omitted empty
notebooks, full-path headings, and no rollups.
- [x] State that ungrouped `children`, numeric depth, and `scope: all` remain
aggregated.
- [x] Explain that every generated bucket has independent SLICE8 expansion state.
- [x] Explain that SLICE9 notes group by their owning notebook.
- [x] Update SPEC.md scope parsing, folder traversal, layout payloads, warning
ownership, statistics, and rendering flow.
- [x] Add the SLICE10 feature to the v1.0.0 CHANGELOG entry.
### Phase 7 README progress record
- README option tables now document `group: notebook` for kanban and matrix and
constrain it to the literal `scope: children` combination.
- Added a parent/child/grandchild example covering exact ownership, no ancestor
rollups, omitted empty notebooks, non-empty descendants, full-path headings,
deterministic order, warning/statistics placement, and independent batching.
- Documented that ordinary note cards group exactly like to-dos and that all
other scope forms remain aggregated, warning when grouping is requested.
- Updated the development test count to 230 and release-version instructions to
include the lockfile.
- `SPEC.md` and the v1.0.0 `CHANGELOG.md` now record the grouped layout contract.
### Phase 8 — Manual Joplin acceptance
Create a root notebook containing direct cards, multiple children, grandchildren,
duplicate child titles under different parents, empty notebooks, and more than
one SLICE8 batch in at least two generated views. Include SLICE9 notes and to-dos.
- [x] Confirm root-owned cards render only in the root view.
- [x] Confirm every descendant card renders only in its owning notebook view.
- [x] Confirm empty notebooks are omitted but non-empty descendants still appear.
- [x] Confirm full paths distinguish duplicate titles.
- [x] Confirm view ordering is stable and matches the documented traversal.
- [x] Confirm both matrix modes create separate complete matrices.
- [x] Confirm per-group warnings appear with the correct notebook.
- [x] Confirm overall statistics equal the unique scanned/card totals.
- [x] Confirm independent `page-size` limits and "List more" state across groups,
buckets, and multiple source blocks.
- [x] Confirm reload resets all expanded buckets.
- [x] Confirm `this-folder`, numeric depths, and `scope: all` still render one
aggregated view.
- [x] Confirm notebook targeting plus `scope: children` roots grouping at the
resolved target and uses correct full paths.
- [x] Confirm sorting, styling, navigation, completion, recurrence, and note-card
behavior do not regress.
- [x] Record user sign-off here; do not mark manual acceptance complete before
confirmation.
### Phase 8 acceptance record
- **PASSED — USER SIGN-OFF RECEIVED 2026-07-28.** The user confirmed that all
Phase 8 manual tests passed against the current production JPL.
- Accepted workflows include exact root/descendant ownership, no rollups or
duplication, empty-group handling, full-path headings, deterministic order,
both matrix modes, warning/statistics ownership, independent batching across
groups and blocks, reload reset, notebook targeting, and all non-grouped scope
regressions.
- Sorting, styling, navigation, completion, recurrence, mixed note/to-do cards,
hover/focus controls, and configured page sizes were also manually accepted.
- This manual acceptance is recorded separately from Phase 6 automated
validation. Phase 7 documentation remains pending.
## Acceptance criteria
SLICE10 is complete only when:
- Literal `scope: children` renders separate root/descendant views with every
eligible card present exactly once in its owning notebook.
- Empty groups are omitted, headings are unambiguous, and ordering is stable.
- SLICE8 state is independent per bucket and SLICE9 cards group correctly.
- Warnings and statistics are correctly owned and never double-counted.
- Every other scope form retains its prior single-view behavior.
- Focused tests, the full suite, and the production package build pass.
- Manual Joplin acceptance is explicitly confirmed.
## Files expected to change
- `src/Gtd/types.ts`
- `src/Gtd/parseKanbanConfig.ts`
- `src/Gtd/parseMatrixConfig.ts`
- `src/Gtd/folderScope.ts` and/or a new `src/Gtd/folderTree.ts`
- `src/Gtd/collectKanban.ts`
- `src/Gtd/collectMatrix.ts`
- `src/index.ts`
- `src/gtd-calendar-webview.js`
- `src/event-calendar.css`
- `src/tests/Gtd/kanban.test.ts`
- `src/tests/Gtd/matrix.test.ts`
- `src/tests/Gtd/resolveNotebook.test.ts` and/or a new folder-tree test file
- `README.md`
- `SPEC.md`
- `CHANGELOG.md`
- `SLICE10.md`
- `TASKS.md`
## Out of scope
- Grouping numeric-depth or `scope: all` results.
- Ancestor rollups or duplicated cards across parent/child views.
- Nested notebook subsections inside a single kanban or matrix.
- Persisting expanded/collapsed notebook-view state.
- Notebook-level filtering controls or user-selectable grouping modes.
- Applying notebook grouping to calendar or Gantt views.
## Dependencies and resume point
Implement after SLICE8 and SLICE9 are complete. Start by preserving
`groupByNotebook` in parser tests, then build and test ordered folder metadata.
Introduce discriminated collector layouts before changing the webview. Complete
single-view regression tests before grouped manual acceptance.

216
SLICE11.md Normal file
View File

@ -0,0 +1,216 @@
# SLICE 11 — Complete ordinary notes with a `done` tag
> **State-saving rule:** update this file after every completed task and whenever
> work pauses. Keep automated validation and manual Joplin acceptance separate.
## Status
**COMPLETE.** Phases 16 are implemented and fully validated. The user confirmed
all Phase 7 manual Joplin acceptance tests pass on 2026-07-28. The production
JPL is built and inspected.
## Goal
Give ordinary notes containing a `gtd` block a natural completion state without
turning them into native Joplin to-dos. Completion follows the same tag-driven
progression already used by kanban:
```text
no workflow tag -> Backlog
in-progress -> In Progress
done -> Done
```
If an ordinary note carries both `in-progress` and `done`, **Done wins**.
## Confirmed behavior
- Only ordinary notes that already opt in through a found `gtd` block are
affected. A `done` tag does not opt a plain note into a view.
- The default completion tag is `done`.
- For ordinary notes, `done` takes priority over `in-progress` during kanban
bucketing.
- Existing native to-do completion remains authoritative for Joplin to-dos.
- The feature must preserve SLICE8 batching, SLICE9 mixed cards, and SLICE10
exact-owner notebook grouping.
## Confirmed design decisions
### 1. Configuration
**Recommendation:** add `done-tag:` to both `gtd-kanban` and `gtd-matrix`,
defaulting to `done`, just as `in-progress-tag:` defaults to `in-progress`.
Normalize tag names case-insensitively.
Warn when `done-tag` and `in-progress-tag` are identical because the workflow
would collapse directly to Done. Do not silently choose a different tag.
### 2. Which item types the tag affects
**Recommendation:** `done-tag` changes completion only for ordinary notes.
Native to-dos continue to use `todo_completed`; an incomplete to-do tagged
`done` remains incomplete. This avoids two competing completion authorities for
Joplin to-dos.
### 3. Kanban `done-window`
Ordinary notes have no native completion timestamp. Their `updated_time` is not
a reliable substitute because any later edit would make an old completion look
recent.
**Recommendation:** completed ordinary notes are not filtered by `done-window`;
all opted-in notes carrying `done-tag` appear in Done. `done-window` continues to
apply only to native completed to-dos. Document this explicitly.
Alternative if an unbounded Done column is unacceptable: add a completion date
to the `gtd` block in a later slice. Do not infer it from `updated_time`.
### 4. Matrix behavior
**Recommendation:** exclude `done` ordinary notes from both Skeleton and
Eisenhower matrices, matching the existing exclusion of completed to-dos. A
prioritization view should contain only unfinished work.
The exclusion happens before quadrant bucketing. Thus `done` also wins over
`in-progress`, `urgent`, and `important` tags.
### 5. Calendar behavior
**Recommendation for this slice:** leave calendars unchanged. A completed
ordinary note remains a scheduled/unscheduled calendar item and does not gain
strikethrough solely from `done-tag`.
Calendar completion styling can be considered separately because calendars may
serve as historical records, while kanban and matrix are workflow views.
### 6. Card representation and styling
Completed ordinary-note cards keep the 📄 fallback glyph and use the existing
completed title styling in kanban Done. They do not use a checked-checkbox glyph
and never become recurring.
The shared card model may represent `completed: true` with `isTodo: false`.
`completedTime` remains `0` for notes because no completion timestamp exists.
### 7. Removing or changing tags
- Removing `done` from a note makes it unfinished again.
- If `in-progress` remains, the note returns to In Progress.
- If neither workflow tag remains, it returns to Backlog.
- No tag is automatically added or removed; the plugin remains read-only.
## Precedence table
| Item | Native completion | `done` tag | `in-progress` tag | Kanban result | Matrix result |
|---|---:|---:|---:|---|---|
| Ordinary opted-in note | n/a | yes | either | Done | Excluded |
| Ordinary opted-in note | n/a | no | yes | In Progress | Existing mode rules |
| Ordinary opted-in note | n/a | no | no | Backlog | Existing mode rules |
| Ordinary note without `gtd` | n/a | any | any | Excluded | Excluded |
| Completed to-do | yes | any | any | Done, subject to `done-window` | Excluded |
| Incomplete to-do | no | yes | yes/no | Existing in-progress rule | Existing mode rules |
## Architecture
```text
eligible note + normalized tag titles
|
v
buildKanbanCard
to-do -> completion from todo_completed
note -> completion from configured done-tag
|
+-- kanban: completed wins -> Done
| note: no done-window filtering
| to-do: existing done-window filtering
|
+-- matrix: completed -> exclude before quadrant bucketing
```
Because `done-tag` is configurable per view, card construction may need either a
completion-tag argument or a small view-specific completion step after the
shared builder. Prefer one explicit data path shared by kanban and matrix; do not
hide the configured tag in a global constant lookup.
## Implementation plan
### Phase 1 — Configuration and types
- [x] Add `DONE_TAG` with the value `done`.
- [x] Add normalized `doneTag` fields to `KanbanConfig` and `MatrixConfig`.
- [x] Parse `done-tag` in both view blocks with a default of `done`.
- [x] Warn when `done-tag` and `in-progress-tag` are identical.
- [x] Add parser tests for defaults, custom tags, normalization, empty values,
unknown-option handling, and identical-tag warnings.
### Phase 2 — Shared completion model
- [x] Allow an ordinary `KanbanCard` to be completed from the configured tag.
- [x] Keep `isTodo: false`, `completedTime: 0`, and `isRecurring: false`.
- [x] Preserve native to-do completion regardless of `done-tag`.
- [x] Test ordinary-note, native-to-do, custom-tag, and both-tags precedence.
### Phase 3 — Kanban behavior
- [x] Bucket completed ordinary notes into Done before checking in-progress.
- [x] Keep all completed note cards regardless of `done-window`.
- [x] Preserve the existing to-do done-window behavior.
- [x] Sort completed notes and to-dos together with the configured Done sorter.
- [x] Cover single and SLICE10 notebook-grouped layouts with exact card totals.
### Phase 4 — Matrix behavior
- [x] Exclude completed ordinary notes before Skeleton/Eisenhower bucketing.
- [x] Prove `done` wins over in-progress, urgent, and important tags.
- [x] Preserve native completed-to-do exclusion.
- [x] Cover single and SLICE10 notebook-grouped layouts without double-counting.
### Phase 5 — Rendering and payload regression
- [x] Confirm completed note cards retain the 📄 glyph and completed styling.
- [x] Confirm no new payload branch is needed beyond the normalized card fields.
- [x] Confirm SLICE8 independent batching and SLICE10 grouped rendering remain
unchanged.
### Phase 6 — Documentation and automated verification
- [x] Document `done-tag`, precedence, note-only semantics, matrix exclusion,
and the done-window exception in README.md and SPEC.md.
- [x] Add an unreleased CHANGELOG entry.
- [x] Run focused parser/builder/kanban/matrix tests (103 passed).
- [x] Run the complete Jest suite (240 tests), TypeScript, JavaScript syntax, whitespace,
and prohibited-reference checks.
- [x] Build the production JPL.
### Phase 7 — Manual Joplin acceptance
- [x] Note with `done` only appears in Kanban Done.
- [x] Note with both `done` and `in-progress` appears only in Done.
- [x] Removing `done` returns the note to In Progress or Backlog as appropriate.
- [x] Custom `done-tag` works independently in kanban and matrix blocks.
- [x] Completed notes are excluded from both matrix modes.
- [x] Incomplete to-dos tagged `done` remain governed by native completion.
- [x] `done-window` filters native to-dos but not completed ordinary notes.
- [x] Glyph, styling, sorting, navigation, warnings, statistics, batching, and
child-notebook grouping do not regress.
- [x] Record explicit user sign-off separately from automated checks.
## Out of scope
- Mutating tags from a rendered card.
- Automatically removing `in-progress` when `done` is added.
- Inferring a completion time from `updated_time`.
- Adding a completion date to the `gtd` block.
- Changing calendar completion styling.
- Treating `done` as an opt-in for ordinary notes without a `gtd` block.
## Acceptance criteria
- An opted-in ordinary note carrying the configured done tag is complete.
- Done wins over in-progress and every matrix axis tag.
- Native to-do completion behavior is unchanged.
- Done-window semantics are explicit and deterministic without fabricated dates.
- Single and grouped views retain correct ownership, totals, sorting, warnings,
batching, styling, and navigation.
- Automated validation, production packaging, and explicit manual acceptance
all pass and are recorded separately.

276
SLICE12.md Normal file
View File

@ -0,0 +1,276 @@
# SLICE 12 — Filter project notes independently from task to-dos
> **State-saving rule:** update this file after every completed task and whenever
> work pauses. Keep implementation, automated validation, production packaging,
> and manual Joplin acceptance as separate status boundaries.
## Status
**COMPLETE.** Phases 17 are implemented and validated. Focused and full
automation passed, the production JPL was built, and the user confirmed manual
Joplin acceptance on 2026-07-28.
## Goal
Allow `gtd-kanban` and `gtd-matrix` blocks to include or exclude opted-in
ordinary notes independently from native Joplin to-dos. The user treats
ordinary `gtd` notes as projects and native to-dos as tasks, and needs focused
project-only, task-only, and combined views.
```yaml
# Projects only
notes: all
todos: none
```
```yaml
# Tasks only
notes: none
todos: all
```
```yaml
# Projects and tasks
notes: all
todos: all
```
## Confirmed configuration contract
- The option is named `notes`, plural, matching the existing `todos` option and
the calendar configuration vocabulary.
- `notes` is valid only in `gtd-kanban` and `gtd-matrix` blocks in this slice.
- Accepted values are `all` and `none`.
- The default is `all`, preserving current behavior for existing blocks.
- `notes: all` means all **eligible ordinary notes containing a found `gtd`
block**. It never means every plain note in scope.
- `notes: none` excludes every ordinary note, including notes tagged
`in-progress`, `done`, `urgent`, or `important`.
- A tag alone never opts an ordinary note into a view.
- The existing `todos` contract remains unchanged: `gtd-only | all | none`,
defaulting to `gtd-only`.
- `notes` and `todos` are independent. Every valid combination is supported.
- Invalid or empty `notes` values warn and fall back to `all`.
- The singular key `note` remains unknown and produces the normal unknown-option
warning; no alias is introduced.
## Inclusion table
| Item | `gtd` block | `notes` | `todos` | Result |
|---|---:|---|---|---|
| Ordinary note | yes | `all` | any | Included, then normal view rules apply |
| Ordinary note | yes | `none` | any | Excluded |
| Ordinary note | no | `all` | any | Excluded |
| Ordinary note | no | `none` | any | Excluded |
| Native to-do | yes | any | `gtd-only` or `all` | Included, subject to completion rules |
| Native to-do | no | any | `all` | Included, subject to completion rules |
| Native to-do | no | any | `gtd-only` or `none` | Excluded |
The host dashboard note remains excluded regardless of its type, block, or
configuration.
## Interaction with existing behavior
### Kanban
- `notes: all` preserves SLICE9 and SLICE11 note behavior.
- An admitted unfinished note uses `in-progress-tag` to select In Progress or
Backlog.
- An admitted note carrying `done-tag` enters Done, even if it also carries
`in-progress-tag`.
- Completed ordinary notes remain exempt from the native to-do `done-window`.
- `notes: none` excludes ordinary notes before tag lookup, card construction,
completion checks, sorting, grouping, statistics, and pagination.
- Native to-do behavior is unaffected by `notes`.
### Matrix
- `notes: all` preserves both Skeleton and Eisenhower note behavior.
- Admitted notes carrying `done-tag` remain excluded before quadrant bucketing.
- Other admitted notes use the existing date and tag axes for the selected mode.
- `notes: none` excludes ordinary notes before tag lookup, completion checks,
quadrant bucketing, sorting, grouping, statistics, and pagination.
- Native completed to-dos remain excluded, and incomplete to-do behavior is
unaffected by `notes`.
### Notebook grouping and statistics
- SLICE10 exact-owner grouping remains unchanged.
- A filtered note must not create a notebook group, warning, card count, or
pagination state.
- A group containing only filtered notes is omitted.
- Non-empty descendant groups remain visible even when their ancestors become
empty after filtering.
- `scannedFolders` and `scannedNotes` continue to describe scan work, not admitted
cards; `cardCount`, bucket totals, headings, and pagination use admitted cards.
- Malformed-block warnings are emitted only for items admitted by their type
filter. A malformed ordinary note under `notes: none` produces no warning.
### Rendering and payload
- No new rendering branch is required. Collectors return the same card and
grouped-layout payloads with filtered arrays and totals.
- SLICE8 independent `page-size` expansion state remains per rendered bucket.
- Empty project-only or task-only results use the existing empty-state behavior.
- Card glyphs, styling, drilldown, recurrence, and read-only behavior do not
change.
## Data-access and performance rules
Filtering should occur as early as correctness permits:
```text
scan note metadata/body
|
+-- host note -> exclude
+-- ordinary note + notes:none -> exclude
+-- to-do rejected by todos -> exclude
|
v
parse/admit gtd block -> warnings -> tag lookup -> card -> bucket
```
- Do not request tags for an item rejected by `notes` or `todos`.
- Ordinary notes require bodies to prove explicit `gtd` opt-in when
`notes: all`.
- `todos: gtd-only` requires bodies to determine to-do admission.
- `todos: all` still needs bodies because an optional `gtd` block can override
the to-do card date, title, colours, icon, and text. Do not optimize this away.
- Retain current body fetching whenever either admitted type can use a `gtd`
block. Any no-body optimization must be proven not to alter card overrides,
warnings, scan counts, or host-note handling.
## Architecture
Add a note-specific inclusion type rather than reusing `InclusionMode`, because
`gtd-only` would be redundant: all ordinary notes are already explicitly
`gtd`-only.
```ts
type NoteInclusionMode = "all" | "none";
interface KanbanConfig {
notes: NoteInclusionMode;
// existing fields
}
interface MatrixConfig {
notes: NoteInclusionMode;
// existing fields
}
```
Both collectors should apply the type filter before malformed-block warnings,
tag requests, and card construction. Keep parsing and filtering behavior shared
in intent, but avoid an abstraction that obscures the different kanban and
matrix completion paths.
## Implementation plan
### Phase 1 — Configuration and types
- [x] Add `NoteInclusionMode = "all" | "none"`.
- [x] Add `notes` to `KanbanConfig` and `MatrixConfig`.
- [x] Recognize `notes` in both parsers.
- [x] Parse case-insensitive `all | none`, defaulting to `all`.
- [x] Warn on invalid, empty, null, array, or object values and use `all`.
- [x] Preserve the unknown-option warning for singular `note`.
- [x] Add parser tests for defaults, valid values, normalization, invalid values,
and coexistence with every `todos` mode.
### Phase 2 — Kanban filtering
- [x] Exclude ordinary notes immediately when `config.notes === "none"`.
- [x] Preserve explicit `gtd` opt-in when `config.notes === "all"`.
- [x] Ensure rejected notes trigger no malformed-block warning or tag request.
- [x] Preserve Backlog, In Progress, Done, done-tag precedence, done-window,
sorting, and native to-do behavior.
- [x] Cover project-only, task-only, combined, and fully empty configurations.
- [x] Cover empty, valid, and malformed `gtd` blocks.
### Phase 3 — Matrix filtering
- [x] Exclude ordinary notes immediately when `config.notes === "none"`.
- [x] Preserve explicit `gtd` opt-in when `config.notes === "all"`.
- [x] Ensure rejected notes trigger no malformed-block warning or tag request.
- [x] Preserve done-note exclusion and native completed-to-do exclusion.
- [x] Cover project-only, task-only, combined, and fully empty configurations in
both Skeleton and Eisenhower modes.
- [x] Cover empty, valid, and malformed `gtd` blocks.
### Phase 4 — Grouping, totals, and performance
- [x] Verify single-layout `cardCount` and all bucket totals after filtering.
- [x] Verify exact-owner notebook groups omit groups emptied by filtering.
- [x] Verify non-empty descendants survive filtered empty ancestors.
- [x] Verify filtered notes do not generate group-local warnings.
- [x] Verify each surviving bucket retains independent SLICE8 pagination.
- [x] Assert no tag request occurs for filtered notes.
- [x] Audit `getNotesInFolder` body-field hints without removing to-do `gtd`
overrides or malformed-block warnings.
### Phase 5 — Documentation
- [x] Add `notes: all | none` to README kanban and matrix option tables.
- [x] Document that `all` means all opted-in ordinary `gtd` notes.
- [x] Add project-only, task-only, and combined examples.
- [x] Document independence from `todos`, note completion behavior, grouped-view
effects, and the default-preserving migration story.
- [x] Update SPEC.md configuration, inclusion matrix, collector flow, statistics,
and performance notes.
- [x] Add an unreleased CHANGELOG entry.
### Phase 6 — Automated validation and packaging
- [x] Run focused parser, kanban, matrix, grouping, and field-hint tests
(117 passed).
- [x] Run the complete Jest suite (251 tests across 16 suites).
- [x] Run TypeScript validation and webview JavaScript syntax checking.
- [x] Run whitespace and prohibited-reference audits.
- [x] Build the production JPL.
- [x] Inspect archive contents; SHA-256:
final v1.0.0 artifact
`0b28fb0f5ffcb60d92e017fb0972ecaa456fcc3c24b144891dce7d72dc7d98ef`.
### Phase 7 — Manual Joplin acceptance
- [x] Existing blocks without `notes` still show opted-in project notes.
- [x] Kanban `notes: all` plus `todos: none` shows projects only across Backlog,
In Progress, and Done.
- [x] Kanban `notes: none` plus `todos: all` shows tasks only.
- [x] Kanban `notes: all` plus `todos: all` shows both without duplication.
- [x] Matrix project-only and task-only configurations work in Skeleton mode.
- [x] Matrix project-only and task-only configurations work in Eisenhower mode.
- [x] A plain note without `gtd` remains excluded under `notes: all`.
- [x] A `done` project appears in Kanban Done and remains excluded from matrices.
- [x] Invalid `notes` warns and falls back to `all`; singular `note` warns as
unknown.
- [x] Filtered malformed notes do not produce warnings.
- [x] Single and grouped views have correct headings, totals, empty states,
sorting, pagination, and navigation.
- [x] Record explicit user sign-off separately from automated checks.
## Out of scope
- Allowing ordinary notes without a `gtd` block into kanban or matrix views.
- Adding `gtd-only` as a distinct `notes` value.
- Renaming or changing the existing `todos` option.
- Inferring project/task identity from tags, titles, notebooks, or note content.
- Mutating note types, tags, or completion state from a rendered view.
- Changing calendar inclusion semantics.
- Changing Gantt item admission.
- Persisting filters or pagination state outside the fenced block.
## Acceptance criteria
- Existing kanban and matrix blocks behave identically when `notes` is omitted.
- `notes: all` includes only ordinary notes containing a `gtd` block.
- `notes: none` excludes ordinary notes without affecting native to-dos.
- All `notes` and `todos` combinations behave independently and predictably.
- Filtering happens before tag access, warning creation, bucketing, grouping,
totals, sorting, and pagination.
- SLICE8 batching, SLICE10 grouping, and SLICE11 completion semantics do not
regress.
- Documentation clearly supports project-only, task-only, and combined views.
- Automated validation, production packaging, and explicit manual acceptance
are completed and recorded separately.

174
SLICE13.md Normal file
View File

@ -0,0 +1,174 @@
# SLICE 13 — Remove underlines from kanban and matrix cards
> **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 15 are implemented and validated. Automation and package
inspection passed, and the user confirmed manual Joplin acceptance on
2026-07-28.
## Goal
Remove the distracting underline from every kanban and matrix card title while
preserving the visual distinction for completed items and every existing card
interaction.
The change applies equally to:
- ordinary project notes;
- incomplete native to-dos;
- completed native to-dos in Kanban Done;
- completed ordinary notes in Kanban Done;
- cards in both Skeleton and Eisenhower matrices;
- single and notebook-grouped layouts.
## Confirmed diagnosis
Kanban and matrix cards are rendered by the shared `renderCard` function as:
```html
<div class="gtd-kanban-card gtd-calendar-clickable">
<div class="gtd-kanban-card-title">...</div>
</div>
```
The underline is real and comes from the generic clickable rule:
```css
.gtd-calendar-clickable {
cursor: pointer;
text-decoration: underline;
}
```
Calendar items already suppress that decoration through a calendar-scoped
override. Kanban and matrix cards do not, so the parent decoration propagates
through their title text.
Completed-card strikethrough is separate. `renderCard` applies an inline
`line-through` directly to `.gtd-kanban-card-title` when `card.completed` is
true. Removing the parent underline therefore must not remove Done styling.
## Confirmed design
Add a card-scoped override to the existing `.gtd-kanban-card` rule:
```css
.gtd-kanban-card {
text-decoration: none;
}
```
This selector is deliberately narrow:
- It affects both kanban and matrix because they share `renderCard`.
- It affects both notes and to-dos because both use the same card element.
- It does not change calendar events, unscheduled items, Gantt rows, debug
output, warnings, or other clickable elements.
- It does not change `cursor: pointer`, card borders, hover borders, custom
colours, glyphs, recurrence marks, details, pagination, or click navigation.
- It leaves the completed title's inline `line-through` authoritative.
Do not remove `text-decoration: underline` from the generic
`.gtd-calendar-clickable` rule. Other renderers may rely on that default, and a
global change would make this small visual correction unnecessarily broad.
## Visual state table
| Card | Before | After |
|---|---|---|
| Backlog/In Progress note | Underline | No decoration |
| Backlog/In Progress to-do | Underline | No decoration |
| Done ordinary note | Underline + line-through | Line-through only |
| Done native to-do | Underline + line-through | Line-through only |
| Matrix note or to-do | Underline | No decoration |
Custom foreground colours, including intentionally red text, remain unchanged;
only the underline is removed.
## Accessibility and interaction boundary
Cards remain visually identifiable as interactive controls through their card
container, border, hover-border change, glyph, and pointer cursor. Click-to-open
behavior is unchanged.
This slice does not redesign keyboard semantics or convert cards from `div`
elements to buttons/links. That would be a separate accessibility task with a
larger markup, focus, key handling, and styling surface.
## Implementation plan
### Phase 1 — CSS correction
- [x] Add `text-decoration: none` to `.gtd-kanban-card` in
`src/event-calendar.css`.
- [x] Keep the generic `.gtd-calendar-clickable` underline rule unchanged.
- [x] Keep the existing calendar-scoped decoration override unchanged.
- [x] Make no JavaScript or payload changes.
### Phase 2 — Static and rendering regression audit
- [x] Confirm kanban and matrix still use the shared `.gtd-kanban-card` element.
- [x] Confirm incomplete cards receive no inline text decoration.
- [x] Confirm completed cards still set `line-through` on the title row.
- [x] Confirm custom `fg-colour` and `bg-colour` remain inline card styles.
- [x] Confirm hover details, card-detail modes, click navigation, glyphs, and
recurrence marks use unchanged code paths.
- [x] Confirm single and notebook-grouped layouts use the same renderer.
### Phase 3 — Documentation
- [x] Add an unreleased CHANGELOG entry describing the visual cleanup.
- [x] Update README or SPEC only if existing text claims cards are underlined;
otherwise avoid unnecessary documentation churn.
- [x] Record the screenshot-derived acceptance intent in this file.
### Phase 4 — Automated validation and packaging
- [x] Run the complete Jest suite (251 tests across 16 suites).
- [x] Run TypeScript validation and webview JavaScript syntax checking.
- [x] Run CSS/build, whitespace, and prohibited-reference audits.
- [x] Build the production JPL.
- [x] Inspect the packaged `event-calendar.css` and confirm the scoped override
is present.
- [x] Inspect archive contents; SHA-256:
final v1.0.0 artifact
`0b28fb0f5ffcb60d92e017fb0972ecaa456fcc3c24b144891dce7d72dc7d98ef`.
### Phase 5 — Manual Joplin acceptance
- [x] Backlog project-note titles are not underlined.
- [x] In Progress project-note titles are not underlined.
- [x] Backlog and In Progress to-do titles are not underlined.
- [x] Done note titles show line-through without underline.
- [x] Done to-do titles show line-through without underline.
- [x] Skeleton matrix note and to-do titles are not underlined.
- [x] Eisenhower matrix note and to-do titles are not underlined.
- [x] Custom foreground/background colours still render correctly.
- [x] Hover borders and card-detail behavior still work.
- [x] Clicking every card type still opens its source note.
- [x] Single and notebook-grouped views render consistently.
- [x] Record explicit user sign-off separately from automated checks.
## Out of scope
- Changing the generic clickable style globally.
- Changing calendar, Unscheduled, Gantt, warning, or debug decorations.
- Changing completed-item semantics or removing line-through.
- Altering card colours, borders, spacing, typography, glyphs, or pagination.
- Changing card markup, focus behavior, keyboard behavior, or ARIA semantics.
- Changing kanban/matrix filtering, sorting, grouping, or data collection.
- Bumping the plugin version or performing the v1.0.0 release.
## Acceptance criteria
- No kanban or matrix card title is underlined solely because it is clickable.
- Completed kanban notes and to-dos retain line-through styling.
- The change applies identically to notes, to-dos, matrix modes, and grouped
layouts through the shared card renderer.
- No other clickable view element changes decoration.
- Automated validation, production packaging, and explicit manual acceptance
are completed and recorded separately.

64
SLICE2.md Normal file
View File

@ -0,0 +1,64 @@
# SLICE 2 — `notebook:` option for all gtd block types
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-15). SLICE1 done; all tasks landed; 112/112 tests pass.
## Goal
Every view block (`gtd-calendar`, `gtd-kanban`, `gtd-matrix`, later `gtd-gantt`)
accepts `notebook: <name-or-path-or-id>` to root the folder scan at a specific
notebook, possibly outside the host note's tree. `scope:` then applies relative
to that notebook.
## Design (agreed with user 2026-07-13)
- Accept, in order of resolution:
1. A raw 32-char hex folder id.
2. A `Parent/Child/...` title path (case-insensitive match on titles).
3. A bare title — if unique, use it; if ambiguous, warn and fall back to the
host folder (consistent with the plugin's warn-don't-fail convention).
- Unknown notebook → warning + fall back to host folder.
- Folder titles are needed: extend `DataAdapter.getFolders()` fields to include
`title` (currently id/parent_id only).
## Tasks
- [x] Extend `RawFolder` + `joplinAdapter.getFolders()` with `title`. Done:
`RawFolder.title` is optional (`title?: string`) — folder scoping uses
id/parent_id only, so scope-only test fixtures don't need it; the real
adapter always requests `["id","parent_id","title"]`.
- [x] New `src/Gtd/resolveNotebook.ts` (pure): folders + spec → `{ folderId,
warning }`. Resolution order id → path → unique bare title; any miss warns
and falls back to the host folder. Also exports `parseNotebookOption`
(shared trim/null normaliser used by all three config parsers).
- [x] Add `notebook` key to parseCalendarConfig / parseKanbanConfig /
parseMatrixConfig (`string | null`, default null; "notebook" added to each
knownKeys list).
- [x] Wire in `src/index.ts`: `resolveScanFolder()` helper resolves the notebook
(or returns host folder) and pushes any warning onto `config.warnings`;
all three handlers pass the resolved id to their collector.
- [x] Tests: `src/tests/Gtd/resolveNotebook.test.ts` — resolver (id, unknown id,
unique title, ambiguous title, missing title, path, path-disambiguation,
unresolvable path, empty), `parseNotebookOption`, config parsing for all
three views, and a collector-level integration (resolved notebook reroots
collectEvents' scan). 15 new cases.
- [x] Docs: README option tables (all three views) + SPEC.md §2.1/§2.3/§2.4
(row + example lines). Workspace planning files remain git-excluded.
- [x] Full test run: **112 passed, 112 total** (10 suites) on 2026-07-15.
`npx tsc --noEmit` clean for `src/` (only pre-existing api/ + node_modules
lib noise).
## Resume notes
SLICE2 complete. Files touched:
- `src/Gtd/types.ts` (`RawFolder.title?`, `notebook` on all three configs)
- `src/Gtd/resolveNotebook.ts` (new; resolver + `parseNotebookOption`)
- `src/Gtd/parseCalendarConfig.ts`, `parseKanbanConfig.ts`, `parseMatrixConfig.ts`
(notebook key + knownKeys)
- `src/index.ts` (getFolders title field, `resolveScanFolder`, 3 handler wirings)
- `src/tests/Gtd/resolveNotebook.test.ts` (new)
- `README.md` (3 tables), `SPEC.md` (§2.1 row + 3 example lines)
Design note: notebook resolution lives in `index.ts` (needs the folder tree),
so collectors stay unchanged — they still take a plain `folderId`. When
`notebook:` is set, `getFolders()` is fetched once in the handler and again
inside the collector; acceptable (only on notebook use), could be deduped later
if it matters. Next: SLICE3 (`scope: all`).

75
SLICE3.md Normal file
View File

@ -0,0 +1,75 @@
# SLICE 3 — `scope: all` (every notebook)
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-15). SLICE1 & SLICE2 done; all tasks landed; 120/120
tests pass; full `npm run dist` builds clean.
## Goal
`scope: all` on any view block scans every notebook in the Joplin profile.
User explicitly wants this despite the cost ("very dangerous, yes").
## Design
- `resolveScopedFolderIds` gains an "all" path: return every folder id
(skip BFS entirely). Config parsers map `scope: all` → a sentinel
(e.g. scopeDepth = -1 or a separate `scopeAll: boolean`) — decide during
implementation, keep the type honest.
- Guardrails (agreed):
- Prominent "scanned N notebooks / M notes" footnote when scope is `all`
(stats plumbing already exists in every payload).
- Body-fetch optimization from SLICE1 applies.
- Soft warning in the rendered view when scanned notes exceed ~2000
(advisory only, never blocks).
- `notebook:` + `scope: all` together: `all` wins; warn about the ignored
`notebook:` key.
## Design decisions (as implemented)
- Representation: a separate **`scopeAll: boolean`** on each config (not a
`scopeDepth` sentinel) — `all` is not a tree depth, so an honest flag reads
cleaner and keeps `scopeDepth` meaningful.
- Scope resolution lives in `folderScope.ts`: `resolveScopedFolderIds` gains a
4th `scopeAll = false` param; when true it returns `folders.map(id)` and skips
BFS entirely (root/depth ignored).
- `notebook:` + `scope: all`: parser emits a warning and `all` wins;
`resolveScanFolder` short-circuits to the host folder (root is irrelevant when
every notebook is scanned).
- Soft cap: `index.ts` pushes a ⚠ warning when `scopeAll` and scanned notes
> 2000 (`SCOPE_ALL_SOFT_CAP`), advisory only. Footnote: payloads carry
`scopeAll`; the webview appends `· scope: all (every notebook)` to the existing
stats meta line in all three render paths.
## Tasks
- [x] Parser changes (all three view types) + warnings for conflicts. Done:
`scope: all``scopeAll=true` in calendar/kanban/matrix parsers;
notebook+all conflict warning in each.
- [x] `folderScope.ts` all-folders path + tests. Done (4th param; collectors
pass `config.scopeAll`).
- [x] Footnote + soft-cap warning in webview render paths. Done: `scopeAll` on
payloads + meta-line marker (3 paths); soft-cap warning via `warnings[]`
(rendered as ⚠, no extra webview work — existing warning path).
- [x] Tests: parser, scope resolution, collector with multi-root folders.
`src/tests/Gtd/scopeAll.test.ts` — parser (all three + conflict + no-false-
positive), folderScope scopeAll path + unchanged non-all behaviour, and a
multi-root collector integration proving `all` spans disconnected
notebooks. 8 new cases.
- [x] Docs: README (3 scope rows + calendar caution) + SPEC.md §2.1 row and all
three example blocks, including the performance/guardrail caution.
- [x] Full test run: **120 passed, 120 total** (11 suites) on 2026-07-15.
`npm run dist` builds clean; archive rule confirmed working.
## Resume notes
SLICE3 complete. Files touched:
- `src/Gtd/types.ts` (`scopeAll` on all three configs)
- `src/Gtd/folderScope.ts` (scopeAll param + all-folders path)
- `src/Gtd/parseCalendarConfig.ts`, `parseKanbanConfig.ts`, `parseMatrixConfig.ts`
(scope: all + notebook-conflict warning; kanban/matrix now parse notebook
before the return so the conflict check has it)
- `src/Gtd/collectEvents.ts`, `collectKanban.ts`, `collectMatrix.ts` (pass scopeAll)
- `src/index.ts` (`resolveScanFolder` short-circuit, `SCOPE_ALL_SOFT_CAP` +
`scopeAllSoftCapWarning`, `scopeAll` on 3 payloads)
- `src/gtd-calendar-webview.js` (footnote marker in 3 stats blocks)
- `src/tests/Gtd/scopeAll.test.ts` (new)
- `README.md`, `SPEC.md`
Next: SLICE4.

86
SLICE4.md Normal file
View File

@ -0,0 +1,86 @@
# SLICE 4 — gtd-gantt data layer
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-15). Pure data layer landed; 140/140 tests pass;
`tsc --noEmit` clean. No rendering/index wiring yet — that is SLICE5.
Resolved open question (2026-07-15): dateless/invalid-date gantt items are
**excluded with a warning** (the recorded default) — no Unscheduled strip. If a
strip is wanted later it's an additive change to collectGantt + the payload.
## Goal
Pure data layer for the gantt: item opt-in parsing, project grouping,
collection, config parsing. No rendering yet.
## Design (agreed with user 2026-07-13)
- Items opt in with their own ```gtd-gantt fenced block (separate from ```gtd):
- **Notes** (bars): `project: bluesky`, `begin-date: yyyy-mm-dd`,
`end-date: yyyy-mm-dd` (accept same date formats as resolveDate.ts).
- **To-dos** (milestones): `project: bluesky`; the milestone date is the
to-do's native **due date**. begin/end on a to-do → warning, treated as
milestone anyway.
- Rows/swimlanes are grouped by `project:`.
- **Chart block vs item block disambiguation** (same fence name!): a
`gtd-gantt` block containing a `project:` key is an ITEM declaration;
without `project:` it is the CHART config block. The markdown-it renderer
must apply the same rule (SLICE5). Item blocks render as a small inline
badge ("gantt: bluesky"), not a chart.
- Chart config keys: `title`, `notebook`, `scope`, `filter-project`
(optional; limit to one project), `sort` / `sort-type` (row order within
project), `card-detail` semantics for hover text. Defaults follow the
existing parsers' conventions; invalid values warn + fall back.
- Semantics: completed to-do milestones render checked/struck (consistent
with calendar). Items with unparseable/missing dates → warning +
excluded (a gantt has no Unscheduled section — confirm with user if one
is wanted; see open questions).
- Reuse styling keys? Item gtd-gantt block also accepts `bg-colour`,
`fg-colour`, `title`, `text` mirroring the gtd block.
## Open questions for user (non-blocking, defaults chosen)
- Dateless gantt items: currently planned as warn+exclude. Alternative:
an "Unscheduled" strip like the calendar. DEFAULT: exclude with warning.
## Tasks
- [x] `src/Gtd/ganttBlock.ts``extractGanttBlock` mirrors gtdBlock.ts
(fence regex for `gtd-gantt`, same YAML/empty/malformed handling). Parses
project / begin-date / end-date + styling keys (bg-colour/fg-colour/title/
text). Classification is the caller's job via `block.project` (non-null ⇒
item, null ⇒ chart).
- [x] `src/Gtd/parseGanttConfig.ts` — chart config: title, scope (+ scope: all
+ notebook conflict warning), notebook, filter-project, sort, sort-type
(`begin-date`|`title`|`modified-date`), card-detail. Invalid → warn + default.
- [x] `src/Gtd/collectGantt.ts` — scope (scopeAll-aware) → fetch (always with
body) → item filter (project present, ≠ chart) → filter-project → bar vs
milestone → group by project → sort → compute range. Returns typed
`GanttChart { projects, rangeStart, rangeEnd, warnings, scannedFolders,
scannedNotes, itemCount }`.
Rules: notes → bars (begin+end required & valid, end≥begin else warn+exclude);
to-dos → milestones on their due date (begin/end on a to-do → warning;
no due date → warn+exclude); completed/recurring flags on milestones;
projects merged case-insensitively, output alphabetical.
- [x] Types in `src/Gtd/types.ts` — GanttSortType, GanttItemBlock,
GanttBlockResult, GanttConfig, GanttBar, GanttMilestone, GanttProject,
GanttChart.
- [x] Thorough unit tests — `src/tests/Gtd/gantt.test.ts` (20 cases): block
extraction (chart vs item, styling, Date coercion, malformed), config
(defaults/full/conflict/invalid), collector (grouping+range, ignore
chart/plain/host, bar bad/reversed dates, milestone due-date + begin/end
warning, milestone no-due-date, completed+recurring, filter-project,
begin-date sort + direction, case-insensitive project merge).
- [x] Full test run: **140 passed, 140 total** (12 suites) on 2026-07-15.
`tsc --noEmit` clean for src/.
## Resume notes
SLICE4 complete — pure data layer only. New files:
- `src/Gtd/ganttBlock.ts`, `parseGanttConfig.ts`, `collectGantt.ts`
- gantt types appended to `src/Gtd/types.ts`
- `src/tests/Gtd/gantt.test.ts`
NOT done here (belongs to SLICE5): the markdown-it renderer's chart-vs-item
disambiguation, the `getGantt` message handler in `src/index.ts`, and any
webview rendering. `collectGantt`'s signature mirrors the other collectors
(`adapter, hostNoteId, hostFolderId, config`) so index wiring will look like
handleGetKanban. Item blocks must render as a small inline badge (not a chart) —
that's a SLICE5 renderer concern. Next: SLICE5.

72
SLICE5.md Normal file
View File

@ -0,0 +1,72 @@
# SLICE 5 — gtd-gantt rendering
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-15). All rendering code landed; 140/140 unit tests pass;
`tsc` clean; `npm run dist` builds the `.jpl`. User manually verified in Joplin
desktop. README + SPEC gantt docs added (beyond the original task list, at user
request). No automated front-end tests (per the earlier webview-testing
decision; disambiguation mirrors the unit-tested collectGantt).
## Goal
Render the gantt in the webview: fence interception, message plumbing, DOM, CSS.
## Tasks
- [x] `src/gtd-calendar-renderer.ts`: intercepts `gtd-gantt` fences. A block with
a non-empty `project:` value (cheap regex `GANTT_PROJECT_LINE`, no YAML
lib) → inline `<span class="gtd-gantt-badge">gantt: <project></span>`;
otherwise emits the chart placeholder (`data-block-type="gantt"`). Project
value is HTML-escaped and unquoted. Empty-project alignment matches
collectGantt (empty ⇒ chart).
- [x] `src/index.ts`: `handleGetGantt` + `getGantt` switch case, mirroring
handleGetKanban (parse → selectedNote → resolveScanFolder → collectGantt →
payload with title/cardDetail/scopeAll/configError/warnings+softcap/chart/
stats). No-selectedNote path returns an empty chart.
- [x] `src/gtd-calendar-webview.js`: `renderGantt` + `buildGanttGrid` and helpers
(offsetDays, clampOffset, monthTicks, todayLocalISO, tooltips). Single CSS
grid: label column (sticky) + `repeat(totalDays, 26px)`; month tick header
row; project header rows; bars via `grid-column` from day offsets (bg/fg
colour or `colourFromId` hash); milestones as ◆ markers (done → dimmed +
struck label, ↻ for recurring); today line spanning body rows; click →
openNote; hover text via native `title` (suppressed when card-detail:none);
title/configError/warnings/stats blocks as in other views (stats footnote
carries the scope: all marker).
- [x] `src/event-calendar.css`: `.gtd-gantt-*` styles (badge, scroll container,
grid, sticky corner/labels, ticks, bars, milestones, today line), using
the same rgba/theme-var conventions as existing styles.
- [x] Manual verification in Joplin desktop — CONFIRMED 2026-07-15. Verified
live: item-block badge (`gantt: bluesky`) renders inline (not a chart);
chart renders with title, month tick header, alphabetical project header
(`bluesky (3)`), a note **bar** spanning begin→end (Jul 114), ◆
**milestones** on to-do due dates, and the red **today** line at Jul 15.
Footnote reports item/note/folder counts. Host-note exclusion confirmed
(an item block in the chart's own note is correctly omitted — put items in
separate notes). Unknown-option warning fires correctly for stray keys like
`todos` (gantt has no notes/todos inclusion option).
- [x] Docs (added at user request; not in the original task list): README gantt
section (item vs chart, bar/milestone rules, chart options table) + SPEC.md
§2.5 (dual-role disambiguation rule, item/chart schemas). Screenshots not
committed — verified interactively instead.
## Resume notes
Code complete. Files touched:
- `src/gtd-calendar-renderer.ts` (gantt fence + badge/chart disambiguation)
- `src/index.ts` (handleGetGantt + switch case + imports)
- `src/gtd-calendar-webview.js` (dispatch + renderGantt/buildGanttGrid + helpers)
- `src/event-calendar.css` (.gtd-gantt-* styles)
Design choices worth noting for a resumed session:
- Grid layout is a single `display:grid` with explicit gridRow/gridColumn per
element (label col = 1, day columns = 2..N+1). Bars: `grid-column: off+2 /
endOff+3` (inclusive end). Milestones: `off+2 / span 1`. Today line:
`grid-row: 1 / <rowIndex>` at the today column.
- Hover uses native `title` tooltips (not the calendar's hover-card DOM) — simpler
and reliable; revisit if richer hover cards are wanted. card-detail:none
suppresses tooltips; "always" currently behaves like "hover" (no inline text on
bars) — a possible future refinement, noted but out of scope.
- No automated renderer/webview tests by design (front-end testing deferred, per
the SLICE-boundary discussion). Disambiguation regex mirrors the unit-tested
collectGantt classification.
Next after manual sign-off: SLICE6.

55
SLICE6.md Normal file
View File

@ -0,0 +1,55 @@
# SLICE 6 — Polish, docs, full verification
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-18). Docs, CHANGELOG, roadmap, consistency sweep, and
tests all done. Cross-view end-to-end pass in Joplin signed off by the user:
dashboard note stacking gtd-calendar + gtd-kanban + gtd-matrix (skeleton &
eisenhower) + gtd-gantt all render independently; scope/depth, todos: all,
colour/icon/title/text overrides, recurring ↻, kanban states, matrix quadrants,
gantt bars/milestones (incl. to-do begin/end-ignored warning), dual-role note,
drilldown/hover, and invalid-config fallback warnings all verified. All spot
checks pass. → proceed to SLICE7 (final review / release prep).
Versioning decision (2026-07-15): **split** into 0.6.0 (notebook + scope: all)
and 0.7.0 (gantt). **0.6.0 is functionally COMPLETE** (notebook targeting +
scope: all + body-fetch, verified in the 2026-07-18 E2E pass); 0.7.0 (gantt) is
likewise complete. Note: the actual manifest/package version bump + publish is
the separate release step NOT part of this slice —
current version on disk is still 0.5.0.
## Tasks
- [x] README.md: "Gantt chart" section (item-block + chart options tables) added
(during SLICE5, at user request). `notebook:`/`scope: all` rows in all view
tables + `all` performance caution were landed in SLICE2/SLICE3. Screenshots
not committed (verified interactively).
- [x] SPEC.md: §2.5 gtd-gantt (chart + item schema, dual-role rule) added in
SLICE5; notebook/scope-all semantics in §2.1 (SLICE2/3); §9 roadmap updated
here with SHIPPED v0.6.0 (notebook + scope: all + body-fetch) and v0.7.0
(gantt) sections.
- [x] CHANGELOG.md: 0.7.0 (Gantt) and 0.6.0 (notebook + scope: all) entries added
per the split decision.
- [x] Consistency sweep: verified notebook-conflict warning wording identical
across all 4 parsers; `scope: all` handled in all 4; card-detail/unknown-
option wording consistent; doc default tables match parser defaults.
Pre-existing note (NOT changed — out of scope, avoids behaviour change): the
calendar's `sort-type` uses `modified_date` (underscore) while kanban/matrix/
gantt use `modified-date` (hyphen); this cross-block spelling difference is
already documented in SPEC §9's reconciliation note.
- [x] Full jest run green: **140 passed, 140 total** (12 suites), up from 88 at
the start of this effort.
- [x] End-to-end pass in Joplin desktop across all four view types, including a
dashboard note stacking all four blocks. **Signed off by user 2026-07-18;
all spot checks pass.** publish/*.jpl (built SLICE5) is current — docs-only
changes since don't affect the bundle.
## Resume notes
SLICE6 fully COMPLETE as of 2026-07-18 — E2E signed off by the user (dashboard
note stacking all four view types rendered independently; all spot checks pass).
Files touched here: `CHANGELOG.md` (0.6.0 + 0.7.0 entries), `SPEC.md` (§9 roadmap
SHIPPED sections). README/SPEC gantt schema + notebook/scope docs landed earlier.
Next: SLICE7 (final review / release prep). Release version bump (0.6.0/0.7.0)
and publish are deferred to the separate release step, NOT this
slice — version on disk is still 0.5.0.

58
SLICE7.md Normal file
View File

@ -0,0 +1,58 @@
# SLICE 7 — Release
> **State-saving rule:** update this file immediately after EVERY completed task (tick the checkbox, update Status and Resume notes) and whenever pausing for input — automatically, without being asked. State on disk must always match reality.
## Status
**COMPLETE** (2026-07-18) — v0.7.0 published to npm (registry confirms
`latest: 0.7.0`) and manual sanity-open in Joplin desktop signed off by user.
All seven slices DONE. Nothing outstanding.
**Release-cut decision made with user (2026-07-18): ONE release, tagged 0.7.0.**
Rationale: gantt is woven through all four shared files (73 refs — 33 webview,
19 types.ts, 12 renderer, 9 index.ts), so a gantt-free 0.6.0 would be error-prone
surgery on an intermediate nobody tested; the 2026-07-18 E2E sign-off was on the
combined state. So npm goes 0.5.0 → 0.7.0 (0.6.0 not separately published); the
CHANGELOG keeps BOTH the 0.7.0 (gantt) and 0.6.0 (notebook/scope) entries as a
documentation split. Version bumped to 0.7.0 in src/manifest.json + package.json.
## Tasks
- [x] Decide with user: **ONE release, tagged 0.7.0** (see Status). CHANGELOG keeps
both 0.6.0 + 0.7.0 entries as documentation.
- [x] Bump `version` in BOTH `src/manifest.json` and `package.json` → 0.7.0 (match).
- [x] CHANGELOG.md entry finalized (0.7.0 at top, both feature entries present —
written in SLICE6).
- [x] **Archived existing publish/*.jpl first** — predist (scripts/archive-jpl.sh)
moved it to build-archive/com.victorwiebe...-0.7.0-20260718-115536.jpl
(no overwrite; three 0.5.0 archives preserved). NB the archive filename is
stamped with the *current* package.json version (0.7.0), not the version the
archived jpl was built at — cosmetic, git-ignored dir, left as-is.
- [x] `npm run dist` — GREEN. jest 140/140 first. New publish/*.jpl (156 KB)
embeds version 0.7.0 (verified in publish/*.json).
- [x] Sanity-open the .jpl in Joplin desktop — **DONE** (2026-07-18): user
completed manual visual check, signed off.
Path: publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl
- [x] `npm publish`**DONE** (2026-07-18): user re-logged in and published.
Registry (https://registry.npmjs.org/) confirms `latest: 0.7.0`.
- [x] **Committed + pushed to Gitea** (2026-07-18): commit 187fd34
"v0.7.0: Gantt chart, notebook targeting & profile-wide scope" (30 files,
+2300/-40), pushed 9780d95..187fd34 master->master. Per user: `.npmrc`
(browser=true) was included. workspace planning files and build-archive/ correctly
excluded from the release commit. NB: no git tag created — tagging was abandoned after 0.3.0 (0.4.0
& 0.5.0 untagged); left consistent with recent practice.
- [x] Mark all SLICE files DONE; summarize outcome here. **DONE**
SLICE16 already COMPLETE; SLICE7 now COMPLETE (this file).
## Outcome
2026-07-18: **v0.7.0 shipped.** The full slice effort (notebook targeting,
profile-wide `scope: all`, and the gtd-gantt view) is released as a single
0.7.0 (0.6.0 documented in CHANGELOG but never separately published, per the
one-release decision). Final state:
- Version 0.7.0 in src/manifest.json + package.json (match).
- Committed + pushed to Gitea (commit 187fd34, master).
- `npm run dist` green, 140/140 tests, clean 0.7.0 jpl (embedded version verified).
- **Published to npm — registry confirms `latest: 0.7.0`.**
- Manual sanity-open in Joplin desktop signed off by user.
All seven slices DONE. No follow-ups outstanding.
## Resume notes
Nothing to resume — SLICE17 all COMPLETE, v0.7.0 live on npm.

275
SLICE8.md Normal file
View File

@ -0,0 +1,275 @@
# SLICE 8 — Incremental card limits for kanban and matrix views
> **State-saving rule:** update this file and `TASKS.md` after every completed
> task and whenever work pauses. Automated checks and manual Joplin acceptance
> must be recorded separately.
## Status
**COMPLETE (2026-07-27). All seven phases passed, including automated validation, production packaging, documentation, and user-confirmed manual Joplin acceptance.**
## Goal
Keep large kanban columns and matrix quadrants readable by rendering cards in
configurable batches. Every bucket initially shows 10 cards by default. A local
"List more" control reveals the next batch without changing the source note or
persisting UI state. Reloading the rendered note resets all buckets.
## Confirmed behavior
- Add `page-size:` to both `gtd-kanban` and `gtd-matrix` blocks.
- Default: `10`.
- Valid values: positive integers only.
- Invalid values produce a warning and fall back to 10.
- Apply the limit independently after existing filtering and sorting to:
- Backlog, In Progress, and Done on kanban boards.
- All four quadrants in both matrix modes.
- "List more" reveals the next `page-size` entries in only that bucket.
- Show the control only when hidden entries remain.
- Hovering or focusing the control shows the exact number of additional entries
remaining, for example `13 more entries`.
- Clicking updates the remaining count and removes the control after the final
batch.
- Expansion exists only in the current webview DOM. It is not written to the
note, settings, plugin process, or project data.
- Column/quadrant headings continue to show total card counts, not visible counts.
- Collector results and overall statistics continue to represent all cards.
## Architecture and data flow
```text
gtd-kanban / gtd-matrix YAML
|
v
parseKanbanConfig / parseMatrixConfig
validates page-size -> normalized pageSize
|
v
handleGetKanban / handleGetMatrix
returns pageSize alongside the complete sorted board
|
v
renderKanban / renderMatrix
gives each column or quadrant its own visibleCount
|
v
renderIncrementalCardList
renders slice(0, visibleCount), remaining summary, and List more
```
The collectors (`collectKanban.ts` and `collectMatrix.ts`) must not paginate or
truncate. Keeping pagination in the webview avoids corrupting totals, changing
sort semantics, or requiring another plugin-process request on every click.
## Implementation plan
### Phase 1 — Configuration contract
- [x] Add `pageSize: number` to `KanbanConfig` and `MatrixConfig` in
`src/Gtd/types.ts`.
- [x] Add `page-size` to each parser's known-key list.
- [x] Normalize omitted `page-size` to 10.
- [x] Accept numeric or YAML numeric-string values only when they resolve to a
finite positive integer.
- [x] Warn with consistent wording for zero, negative, fractional, non-numeric,
`NaN`, or infinite values and use 10.
- [x] Add parser tests in `src/tests/Gtd/kanban.test.ts` and
`src/tests/Gtd/matrix.test.ts` for:
- omitted/default value;
- `1`, `10`, and a larger valid override;
- numeric-string compatibility;
- zero, negative, fractional, and non-numeric fallback;
- recognition as a known key (no unknown-option warning).
## Progress log
- 2026-07-27 — Phase 1 complete. Added shared `page-size` normalization and
required `pageSize` fields for kanban/matrix config. Focused validation:
2 suites passed, 59/59 tests. Full validation: 12 suites passed, 164/164
tests. No manual testing applies yet because the value is not sent to or used
by the webview until later phases.
### Phase 2 — Plugin-to-webview payload
- [x] Add `pageSize: config.pageSize` to successful kanban and matrix responses
in `src/index.ts`.
- [x] Add the same normalized value to missing-source-note/error responses so
rendering behavior is structurally consistent.
- [x] Do not change collector arguments, result shapes, sorting, card totals, or
scope behavior.
- 2026-07-27 — Phase 2 complete. Added normalized `pageSize` to successful
and missing-source-note responses for both kanban and matrix handlers. No
collector or rendering changes. Full Jest validation: 12 suites passed,
164/164 tests. `npx tsc --noEmit --skipLibCheck` passed; the unmodified
command remains blocked by unresolved generated Joplin declarations and the
repository's Jest/TypeScript library mismatch. No manual testing applies yet.
### Phase 3 — Shared incremental renderer
- [x] Add one shared helper in `src/gtd-calendar-webview.js`, tentatively
`renderIncrementalCardList(cards, pageSize, detail, contentScriptId)`.
- [x] Keep `visibleCount` in the helper's closure, initialized to
`Math.min(pageSize, cards.length)`.
- [x] Render cards through the existing `renderCard` function so styling and
click-to-open behavior remain unchanged.
- [x] On each click, increase `visibleCount` by `pageSize`, capped at the full
card count, then render only the newly revealed cards or refresh the local
list without affecting other buckets.
- [x] Reuse the helper from `renderColumn` and `matrixQuadrant`; pass
`payload.pageSize || 10` from both top-level renderers as a defensive
fallback for stale payloads.
- [x] Preserve the existing total-count text in every column/quadrant heading.
- 2026-07-27 — Phase 3 complete. Added one incremental card-list helper and
wired it to all three kanban columns and all four matrix quadrants. Each
bucket owns independent closure state, initially renders one batch, appends
one batch per click, removes the basic control when complete, and preserves
total heading/stat counts. Validation: JavaScript syntax and `git diff --check`
passed, `npx tsc --noEmit --skipLibCheck` passed, and 12 Jest suites passed
with 164/164 tests. Full-file formatting was intentionally not applied because
the legacy webview does not match the default formatter and doing so rewrites
roughly 2,000 unrelated lines. Tooltip, accessibility association, and final
CSS remain Phase 4; manual Joplin acceptance is deferred until then.
### Phase 4 — Control, summary popup, and accessibility
- [x] Render "List more" as a real `button` with `type="button"`.
- [x] Place the button outside card elements so its click cannot bubble into a
card's note-open handler.
- [x] Add a dedicated tooltip element whose text is generated from the remaining
count with correct singular/plural wording (`1 more entry`, `N more entries`).
- [x] Associate the button and tooltip with `aria-describedby` using a unique ID.
- [x] Show the tooltip on both `:hover` and `:focus-visible`; do not rely only on
the native `title` attribute.
- [x] Add focused styles in `src/event-calendar.css` for the control, tooltip,
positioning, keyboard focus, and light/dark theme compatibility.
- [x] Update or remove the button and tooltip atomically after every expansion.
- 2026-07-27 — Phase 4 complete. Added a real button, unique tooltip IDs,
`aria-describedby`, exact singular/plural remaining counts, an updating
accessible label, and pointer-hover/keyboard-focus popup behavior. New cards
insert before the control so focus survives intermediate expansion; the
control is removed only after the final batch. Added scoped, theme-aware CSS.
Validation: JavaScript syntax, `npx tsc --noEmit --skipLibCheck`, and
`git diff --check` passed; 12 Jest suites passed with 164/164 tests. Visual
and keyboard behavior still requires manual Joplin acceptance after a current
package is built.
### Phase 5 — Automated verification
The current Jest configuration uses `jest-environment-node` and the webview is a
browser IIFE with no DOM-test harness. This slice will not add a new DOM runtime
solely for one interaction. Automated coverage will therefore focus on the
stable configuration contract, while DOM behavior receives explicit manual
acceptance. If implementation exposes a genuinely reusable pure batching helper
without duplicating browser logic, add focused unit tests for it; otherwise do
not create a test-only abstraction.
- [x] Run focused parser tests for kanban and matrix configuration.
- [x] Run the complete Jest suite and record suite/test totals.
- [x] Run `npm run dist`, which includes the repository's full test/build/package
workflow, and record the produced `.jpl` path.
- [x] Run `git diff --check`.
- [x] Re-run the repository-wide prohibited-reference audit.
- 2026-07-27 — Phase 5 complete. Focused kanban/matrix validation passed
2/2 suites and 59/59 tests; the full suite passed 12/12 suites and 164/164
tests. JavaScript syntax, `npx tsc --noEmit --skipLibCheck`, and
`git diff --check` passed. `npm run dist` archived the previous package to
`build-archive/com.victorwiebe.joplin.plugin.gtd-calendar-0.7.0-20260727-150417.jpl`
and created `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`
(158,720 bytes, manifest version 0.7.0). The JPL tar contains all five expected
runtime files, and its webview contains the `pageSize` and tooltip markers.
Both prohibited-reference audits returned no matches. No browser-only test
abstraction was added because the batching helper remains a DOM-owning closure;
its visual and keyboard behavior stays within explicit manual acceptance.
### Phase 6 — Documentation
- [x] Add `page-size` to the kanban and matrix option tables in README.md.
- [x] Add examples showing the default and a custom batch size.
- [x] Explain that expansion is per bucket, advances one batch per click, and
resets on reload.
- [x] Update SPEC.md with config validation, rendering ownership, and transient
state semantics.
- [x] Add an unreleased SLICE8 entry to CHANGELOG.md without changing the package
version until release scope is decided.
- 2026-07-27 — Phase 6 README work complete. Added `page-size` to both
option tables, an explicit default kanban example, a custom matrix example,
and the per-bucket expansion, remaining-count popup, full-total, and reload
reset behavior. Updated the README test count to the current 164. README diff
validation passed. SPEC.md and CHANGELOG.md were completed after Phase 7
acceptance; Phase 6 and SLICE8 are now complete.
### Phase 7 — Manual Joplin acceptance
**PASSED — USER SIGN-OFF RECEIVED 2026-07-27.** All manual checks below
passed against the Phase 5 package.
Use a test dashboard that includes a kanban, Skeleton matrix, and Eisenhower
matrix. Prepare buckets containing 0, 1, 10, 11, 20, and 21+ cards.
- [x] Confirm 010 cards show no "List more" control.
- [x] Confirm 11 cards show 10 initially and reveal the final card with one click.
- [x] Confirm 21+ cards expand by exactly 10 per click with the default.
- [x] Confirm a custom `page-size` changes both initial and subsequent batches.
- [x] Confirm the hover and keyboard-focus popup always reports the exact number
still hidden.
- [x] Confirm expansion in one column/quadrant does not alter another.
- [x] Confirm multiple blocks in one note maintain independent state.
- [x] Confirm card order, styling, hover detail, recurrence glyphs, completion
styling, and click-to-open behavior remain unchanged.
- [x] Confirm headings and overall statistics retain full totals.
- [x] Confirm reloading the rendered note resets every expanded bucket.
- [x] Confirm invalid `page-size` values warn and render with the default of 10.
- [x] Record user sign-off here; do not mark manual acceptance complete before
confirmation. **Confirmed by user 2026-07-27: all passed.**
- 2026-07-27 — User reported "all passed" for the complete Phase 7 checklist.
## Acceptance criteria
SLICE8 is complete only when:
- Every kanban column and matrix quadrant initially renders no more than its
normalized `page-size`.
- Every click reveals at most one further batch in only the selected bucket.
- The remaining-count popup is exact and usable with pointer and keyboard.
- Fully expanded buckets have no "List more" control.
- Reloading resets expansion.
- Full card collections, ordering, headings, statistics, navigation, styling,
and warnings remain correct.
- Focused tests, the full suite, and the production package build pass.
- Manual Joplin acceptance is explicitly confirmed.
## Files expected to change
- `src/Gtd/types.ts`
- `src/Gtd/parseKanbanConfig.ts`
- `src/Gtd/parseMatrixConfig.ts`
- `src/index.ts`
- `src/gtd-calendar-webview.js`
- `src/event-calendar.css`
- `src/tests/Gtd/kanban.test.ts`
- `src/tests/Gtd/matrix.test.ts`
- `README.md`
- `SPEC.md`
- `CHANGELOG.md`
- `SLICE8.md`
- `TASKS.md`
## Out of scope
- Server-side pagination or lazy fetching from Joplin.
- Persisting expanded state across reloads.
- A global "expand all" control.
- Limits for calendar, unscheduled, or Gantt views.
- Notebook grouping from SLICE10.
- Inclusion of normal note cards from SLICE9.
## Outcome and resume point
SLICE8 is complete. `page-size` is implemented for kanban and matrix views, the
current production JPL is in `publish/`, all 164 automated tests pass, and manual
Joplin acceptance is signed off. Nothing remains in this slice; proceed to SLICE9.

347
SLICE9.md Normal file
View File

@ -0,0 +1,347 @@
# SLICE 9 — Include opted-in note cards in kanban and matrix views
> **State-saving rule:** update this file and `TASKS.md` after every completed
> task and whenever work pauses. Automated checks and manual Joplin acceptance
> must be recorded separately.
## Status
**COMPLETE.** Phases 17 are complete. Automated validation, production packaging, and manual Joplin acceptance all passed.
## Goal
Allow ordinary Joplin notes (`is_todo: 0`) containing a `gtd` fenced block to
appear as cards in kanban and matrix views. Preserve existing to-do behavior and
continue excluding normal notes that have not explicitly opted in.
## Confirmed behavior
- A normal note is eligible only when `extractGtdBlock` reports `found: true`.
- An empty `gtd` block is a valid opt-in, matching existing calendar semantics.
- A malformed `gtd` block still opts the note in and surfaces the existing
warning; fallback card properties come from the source note.
- The existing `todos:` option continues to govern only to-dos. It does not
disable or broaden normal-note inclusion.
- No new `notes:` option is introduced in this slice.
- Kanban placement for normal notes:
- configured in-progress tag -> In Progress;
- otherwise -> Backlog;
- never -> Done.
- Matrix placement for normal notes:
- Eisenhower mode uses the existing important and urgent tags;
- Skeleton mode uses the existing in-progress tag plus urgent tag/date rules.
- A note date comes only from the `gtd` block. To-do date resolution remains
block date first, then `todo_due`.
- Note cards reuse block title, colours, icon, and text; missing values fall back
exactly as existing cards do.
- Normal notes are never completed and never show the recurring-to-do marker,
even if they happen to carry the configured recurrence tag.
- The host kanban/matrix note remains excluded from its own results.
- SLICE8 batching counts note and to-do cards together after final sorting.
## Inclusion decision table
| Item | `gtd` block | `todos:` | Kanban | Matrix |
|---|---:|---|---|---|
| Normal note | absent | any | excluded | excluded |
| Normal note | present/empty/malformed | any | included | included |
| To-do | absent | `gtd-only` | excluded | excluded |
| To-do | absent | `all` | included | included if incomplete |
| To-do | present | `gtd-only` or `all` | included | included if incomplete |
| To-do | any | `none` | excluded | excluded |
| Completed to-do | any included mode | any | Done-window rules | excluded |
## Architecture and data flow
```text
scoped RawNote
|
+-- host note? ------------------------------> exclude
|
+-- ordinary note
| extract gtd block
| absent -> exclude
| found -> build note card -> tags -> bucket
|
+-- to-do
existing todos/completion rules
-> extract block -> tags -> build card -> bucket
combined bucket -> existing sort -> SLICE8 incremental rendering
```
Collection remains responsible for eligibility, card normalization, tags,
bucketing, sorting, warnings, and totals. The webview should not need separate
rendering logic for note cards.
## Implementation plan
### Phase 1 — Card model and shared builder
- [x] Add an explicit card discriminator to `KanbanCard` in `src/Gtd/types.ts`,
preferably `isTodo: boolean`, so completion/recurrence rendering does not
infer item kind indirectly.
- [x] Extract duplicated kanban/matrix card construction into a small shared
helper only if doing so reduces real duplication without changing public
collector contracts.
- [x] For normal notes set:
- `isTodo: false`;
- `completed: false` and `completedTime: 0`;
- `isRecurring: false`;
- date via `resolveEventDate(note, block)`, which correctly ignores `todo_due`
for normal notes.
- [x] For to-dos preserve current completion and recurrence behavior and set
`isTodo: true`.
- [x] Update `renderCard` only as needed to choose a neutral note glyph when a
normal note has no custom icon; retain current checkbox glyphs for to-dos.
- [x] Decide and document the neutral fallback glyph during implementation using
an existing project-compatible symbol; do not alter custom icons.
### Phase 1 completion record
- Added `isTodo` to the shared card model and centralized kanban/matrix card
normalization in `buildKanbanCard`.
- Normal-note cards are forced incomplete and non-recurring; to-do completion,
recurrence, due-date, styling, and override behavior remain unchanged.
- The neutral fallback is `📄`, matching the existing calendar note glyph.
Custom icons still take precedence, and absent `isTodo` values retain the
legacy checkbox fallback for compatibility.
- Focused validation: 3 suites and 63 tests passed.
- Full validation: 13 suites and 168 tests passed.
- TypeScript validation and webview JavaScript syntax checks passed.
- No manual Joplin test is required for Phase 1 because normal-note collection
is not enabled until later phases.
### Phase 2 — Kanban eligibility and bucketing
- [x] Refactor the `collectKanban.ts` loop so host exclusion occurs first, then
item-kind-specific eligibility.
- [x] For to-dos, preserve `todos: none`, `gtd-only`, and `all` behavior exactly.
- [x] For normal notes, call `extractGtdBlock` and exclude only when no block is
found.
- [x] Surface malformed-block warnings for included notes with the existing
message format.
- [x] Fetch tags only after an item is eligible.
- [x] Bucket completed to-dos into Done using the existing done-window cutoff.
- [x] Bucket incomplete to-dos and normal notes by the configured in-progress
tag; untagged eligible notes go to Backlog.
- [x] Sort the combined note/to-do arrays with the existing configured sorter.
### Phase 2 completion record
- Kanban eligibility now branches by item kind after host-note exclusion.
- Ordinary notes require a found `gtd` block; to-dos retain the existing
`none`, `gtd-only`, and `all` rules.
- Eligible note cards use existing warning, tag, bucketing, sorting, styling,
count, and navigation paths. They can enter Backlog or In Progress, never Done.
- Tag lookup occurs only after eligibility is established.
- Focused validation: 2 suites and 38 tests passed.
- Full validation: 13 suites and 174 tests passed.
- TypeScript, whitespace, and prohibited-reference checks passed.
- Phase 4 still must request bodies in `todos: none` mode before opted-in notes
work with that setting against the Joplin data adapter.
- No manual Joplin test is required yet; mixed-view acceptance remains Phase 7.
### Phase 3 — Matrix eligibility and bucketing
- [x] Apply the same host, item-kind, and shortcode eligibility split in
`collectMatrix.ts`.
- [x] Preserve completed-to-do exclusion.
- [x] Allow eligible normal notes through because they have no completion state.
- [x] Fetch tags only after eligibility is established.
- [x] Reuse the current Eisenhower tag axes without note-specific exceptions.
- [x] Reuse the current Skeleton active/due-soon rules; dateless normal notes
naturally fall into the not-due-soon column unless marked urgent.
- [x] Sort combined note/to-do quadrant arrays with the existing sorter.
### Phase 3 completion record
- Matrix eligibility now branches by item kind after host-note exclusion.
- Completed to-dos remain excluded; ordinary notes require a found `gtd` block
and are not excluded by to-do completion fields.
- Eligible notes reuse the existing Eisenhower and Skeleton axes, sorting,
warnings, tags, styling, totals, and navigation paths.
- `todos: none` continues to exclude only to-dos at the collector level.
- Tag lookup occurs only after eligibility is established.
- Focused validation: 2 suites and 37 tests passed.
- Full validation: 13 suites and 176 tests passed.
- TypeScript, webview syntax, whitespace, and prohibited-reference checks passed.
- Phase 4 still must request bodies in `todos: none` mode for the real Joplin
adapter; the collector contract itself is covered.
- No manual Joplin test is required yet; mixed-view acceptance remains Phase 7.
### Phase 4 — Body-fetch contract
Normal-note eligibility can be known only by reading the body. Consequently,
kanban and matrix collectors must request bodies for every scanned folder even
when `todos: none`. The previous `needsBody = config.todos !== "none"`
optimization is no longer valid for these two views.
- [x] Set `includeBody: true` for kanban and matrix folder-note fetches.
- [x] Update collector comments so they no longer claim `todos: none` guarantees
an empty board.
- [x] Update `src/tests/Gtd/fieldsHint.test.ts` to expect body fetches in all
kanban/matrix modes.
- [x] Leave calendar and Gantt body-fetch behavior unchanged.
- [x] Document this intentional performance tradeoff in SPEC.md: explicit note
opt-in requires body inspection, but tag requests remain limited to
eligible items.
### Phase 4 completion record
- Kanban and matrix now request bodies for every in-scope note, including under
`todos: none`, so ordinary-note `gtd` opt-in can always be evaluated.
- Calendar body-fetch behavior remains unchanged; Gantt code was not modified.
- Tag requests remain deferred until an item passes eligibility.
- `fieldsHint.test.ts` now records the new board contract in all to-do modes.
- SPEC.md documents the intentional performance tradeoff.
- Focused validation: 3 suites and 76 tests passed.
- Full validation: 13 suites and 176 tests passed.
- TypeScript, webview syntax, whitespace, and prohibited-reference checks passed.
- No manual Joplin test is required for this data-fetch contract; mixed-view
acceptance remains Phase 7.
### Phase 5 — Automated tests
#### Kanban
- [x] Include a normal note with a valid block.
- [x] Include a normal note with an empty block.
- [x] Include a malformed-block note and retain its warning.
- [x] Exclude a normal note without a block.
- [x] Exclude the host note even when it has a block.
- [x] Place tagged notes in In Progress and untagged notes in Backlog.
- [x] Prove normal notes never enter Done and never become recurring.
- [x] Preserve all `todos:` modes and done-window behavior.
- [x] Verify mixed sorting for due date, title, and modified date.
#### Matrix
- [x] Cover all four Eisenhower quadrants with normal notes.
- [x] Cover Skeleton active/inactive and due-soon/not-due-soon combinations.
- [x] Cover dateless and explicitly urgent normal notes.
- [x] Include empty and malformed blocks; exclude absent blocks and the host.
- [x] Preserve completed-to-do exclusion and all `todos:` modes.
- [x] Verify mixed sorting for due date, title, and modified date.
#### Shared/integration contracts
- [x] Update existing `KanbanCard` fixtures for the item discriminator.
- [x] Verify tags are not requested for ineligible plain notes or excluded to-dos.
- [x] Verify `cardCount` includes all eligible note and to-do cards once.
- [x] Verify SLICE8 works on the combined ordered arrays without renderer changes.
- [x] Run focused kanban, matrix, and fields-hint suites.
- [x] Run the complete Jest suite and record suite/test totals.
- [x] Run `npm run dist` and record the produced `.jpl` path.
- [x] Run `git diff --check` and the prohibited-reference audit.
### Phase 5 completion record
- Added comprehensive mixed note/to-do coverage for valid, empty, malformed,
absent, and host-note blocks.
- Covered kanban placement, both matrix modes, all to-do modes, completion and
recurrence invariants, source IDs, warnings, and exact card totals.
- Verified mixed due-date, title, and modified-date sorting.
- Verified `page-size: 1` does not truncate complete collector arrays or totals;
existing SLICE8 rendering consumes those combined arrays unchanged.
- Focused validation: 4 suites and 89 tests passed.
- Full validation: 13 suites and 185 tests passed.
- TypeScript validation passed.
- Production build passed: `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`
(155 KB; SHA-256
`7257bb2bdbb8340539cdad3f88a2ac5f8b55bfa960816f16e472e047311db1cc`).
- `git diff --check` and the prohibited-reference audit passed.
- No manual Joplin acceptance was performed; that remains Phase 7.
### Phase 6 — Documentation
- [x] Update README.md kanban and matrix sections with a normal-note example.
- [x] State clearly that normal notes require a `gtd` block and that `todos:`
controls only to-dos.
- [x] Document kanban placement, both matrix placement modes, note date rules,
malformed/empty block behavior, and fallback glyph behavior.
- [x] Update SPEC.md inclusion tables, collection flow, card model, and body-fetch
tradeoff.
- [x] Add an unreleased SLICE9 entry to CHANGELOG.md without changing the package
version until release scope is decided.
### Phase 6 completion record
- README.md now documents ordinary-note opt-in, kanban placement, both matrix
modes, note dates, `todos:` isolation, empty/malformed blocks, fallback
glyphs, custom icons, and clickable source-note behavior.
- SPEC.md now records the inclusion table, shared card model, collection flow,
rendering rules, body-fetch tradeoff, and completed unreleased feature.
- CHANGELOG.md includes the unreleased SLICE9 feature and validation results
without changing the package version.
- The strict prohibited-reference and contradictory-wording audits passed.
### Phase 7 — Manual Joplin acceptance
Use mixed views containing ordinary notes, incomplete to-dos, completed to-dos,
empty blocks, malformed blocks, and plain notes.
**Acceptance package:** `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`
(SHA-256
`7257bb2bdbb8340539cdad3f88a2ac5f8b55bfa960816f16e472e047311db1cc`).
**Accepted:** the user reported all manual checks passed on 2026-07-27.
- [x] Confirm only opted-in normal notes appear.
- [x] Confirm empty blocks opt in and malformed blocks warn without disappearing.
- [x] Confirm normal-note Backlog/In Progress placement on kanban.
- [x] Confirm normal notes never appear in Done.
- [x] Confirm all four quadrants in both matrix modes behave as specified.
- [x] Confirm block title/date/colour/icon/text overrides render on normal notes.
- [x] Confirm neutral note glyph and custom-icon precedence.
- [x] Confirm normal notes do not show completion or recurrence styling.
- [x] Confirm clicking note cards opens the correct source note.
- [x] Confirm `todos: none`, `gtd-only`, and `all` still affect only to-dos.
- [x] Confirm SLICE8 limits and remaining counts include the mixed card total.
- [x] Confirm sorting, warnings, headings, statistics, and reload behavior.
- [x] Record user sign-off here; do not mark manual acceptance complete before
confirmation.
## Acceptance criteria
SLICE9 is complete only when:
- Every normal note with a found `gtd` block appears exactly once in its expected
bucket, while every plain normal note remains excluded.
- Existing to-do inclusion, completion, recurrence, done-window, and matrix rules
remain unchanged.
- Note styling, dates, navigation, warnings, totals, and sorting are correct.
- Mixed arrays obey SLICE8 batching without special renderer branches.
- Focused tests, the full suite, and the production package build pass.
- Manual Joplin acceptance is explicitly confirmed.
## Files expected to change
- `src/Gtd/types.ts`
- `src/Gtd/collectKanban.ts`
- `src/Gtd/collectMatrix.ts`
- Optional shared card-builder module if justified by implementation
- `src/gtd-calendar-webview.js`
- `src/tests/Gtd/kanban.test.ts`
- `src/tests/Gtd/matrix.test.ts`
- `src/tests/Gtd/fieldsHint.test.ts`
- `README.md`
- `SPEC.md`
- `CHANGELOG.md`
- `SLICE9.md`
- `TASKS.md`
## Out of scope
- Including normal notes that lack a `gtd` block.
- Adding a general `notes: all` or `notes: none` option.
- Giving normal notes a completion workflow.
- Editing notes or tags from a kanban/matrix card.
- Changing calendar or Gantt inclusion behavior.
- Child-notebook grouping from SLICE10.
## Dependency and resume point
Implement after SLICE8. Start with collector tests and the explicit card-kind
model, then change eligibility/body fetching. Finish automated regression checks
before documentation and manual acceptance.

493
SPEC.md Normal file
View File

@ -0,0 +1,493 @@
# GTD Calendar — Project Specification
*Status: living document, current through v1.0.0. Sections 15 describe the shipped design; §9 tracks what is still ahead.*
A fork of [WeMakeMachines/joplin-plugin-event-calendar](https://github.com/WeMakeMachines/joplin-plugin-event-calendar) (MIT) that inverts the plugin's data model: instead of events living as YAML inside the calendar note, the plugin's views are **populated by real Joplin notes and todos** in the surrounding folder tree, with clickable drilldown to the source note. What started as a single calendar view has grown into four composable views—calendar, kanban, priorities matrix, and Gantt—sharing one data layer.
**Package name:** `joplin-plugin-gtd-calendar` (npm)
**Manifest id:** `com.victorwiebe.joplin.plugin.gtd-calendar`
---
## 1. Concept
A note anywhere in a folder contains a ` ```gtd-calendar ` fenced block. That block renders as a Day/Week/Month calendar populated by notes and todos in scope. Items opt in (by default) via a ` ```gtd ` fenced block in their body. Clicking a calendar event opens the underlying note.
Dateless items are not lost — they collect in an **Unscheduled** section below the calendar grid, functioning as a GTD next-actions / someday-maybe bucket.
---
## 2. Schema Reference
### 2.1 The `gtd-calendar` block (lives in the calendar note)
```
```gtd-calendar
view: month # day | week | month | month-grid (default: day)
title: Editorial Schedule
scope: this-folder # this-folder | children | <integer depth> | all (default: this-folder)
notebook: Work/Ideas # notebook name | Parent/Child path | folder id (default: this note's folder)
notes: gtd-only # gtd-only | all | none (default: gtd-only)
todos: gtd-only # gtd-only | all | none (default: gtd-only)
sort: asc # asc | desc (default: asc)
sort-type: title # title | modified_date (default: title)
```
| Key | Values | Default | Notes |
|---|---|---|---|
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`), `month-grid` (also `grid`) | `day` | Grouping granularity. `day`/`week`/`month` render the compact tile strip spanning all events; `month-grid` renders a classic 7-column grid of the entire *current* calendar month with weekday headers, dimmed adjacent-month days, today highlighted, and clickable event chips in each day cell. Events outside the displayed month are counted in a footnote. |
| `title` | string | none | Rendered as a heading above the calendar. |
| `scope` | `this-folder`, `children`, an integer, or `all` | `this-folder` | `children` = unlimited recursion into subfolders. An integer *n* = this folder plus *n* levels of descendants (`0``this-folder`). `all` = **every notebook in the profile**, ignoring the root folder and tree position entirely — an explicit, deliberately expensive opt-in for a global dashboard. Guardrails: the stats footnote reports scanned notebooks/notes and is marked `scope: all`; a soft ⚠ warning appears when more than ~2000 notes are scanned (advisory, never blocks). `notebook:` is ignored under `scope: all` (with a warning). Documentation cautions that broad scopes scan every note body in the tree. |
| `notebook` | notebook title, `Parent/Child` title path, or 32-char folder id | this note's folder | Roots the folder scan at a chosen notebook, possibly outside the host note's tree; `scope` then applies relative to it. Resolution order: (1) a raw folder id; (2) a `Parent/Child/…` case-insensitive title path; (3) a bare title, used only if unique. Any miss — unknown id, unresolvable path, ambiguous or absent title — appends a ⚠ warning and falls back to the host note's own folder (warn-don't-fail). |
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes participate. `all` includes notes without a `gtd` block (they receive default styling and are Unscheduled unless... see §3.2). |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same semantics for todos. |
| `sort` | `asc`, `desc` | `asc` | Order of events **within** each day/week/month grouping and within Unscheduled. Groupings themselves always run chronologically. |
| `sort-type` | `title`, `modified_date` | `title` | Field used by `sort`. `modified_date` maps to Joplin's `updated_time`. |
| `unscheduled-todos` | `yes`/`no` (also `true`/`false`, `show`/`hide`) | `yes` | Whether the Unscheduled **to-dos** sub-section is shown. |
| `unscheduled-notes` | `yes`/`no` (also `true`/`false`, `show`/`hide`) | `yes` | Whether the Unscheduled **notes** sub-section is shown. Independent of `unscheduled-todos` — lets a dashboard split the two lists across stacked calendar blocks instead of duplicating both. |
Unscheduled items (no resolvable date) are split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or toggled off.
Unknown keys are ignored with a warning. A malformed block renders an inline warning above the calendar rather than failing silently.
### 2.2 The `gtd` block (lives in participating notes/todos)
```
```gtd
date: 2026-07-04 # required for notes to be scheduled; optional override for todos
bg-colour: teal # any CSS color value
fg-colour: white
icon: 🦴
title: Override title # optional; defaults to the note's own title
text: Hover text # optional; tooltip detail, as upstream
```
| Key | Applies to | Behavior |
|---|---|---|
| `date` | notes | Required for the item to appear **in the grid**; without it the note goes to Unscheduled. Accepts `yyyy-mm-dd` (and `mm-dd-yyyy` for upstream parity). |
| `date` | todos | Optional. If present, **overrides** `todo_due` (explicit beats implicit). |
| `bg-colour` / `bgColor` | both | Event tile background. Both spellings accepted; `bg-colour` is canonical for this fork. |
| `fg-colour` | both | Event tile text color (new vs. upstream). |
| `icon` | both | Emoji/short string, as upstream. Defaults: type indicator (see §4). |
| `title` | both | Overrides the note title on the tile. |
| `text` | both | Tooltip body. |
An **empty** ` ```gtd ` block is valid and meaningful: it is the opt-in signal with all defaults.
### 2.3 The `gtd-kanban` block (lives in the board note)
```
```gtd-kanban
title: Editorial Board
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
notebook: Work/Ideas # same semantics as gtd-calendar (default: this note's folder)
notes: all # all | none (default: all; gtd notes only)
todos: gtd-only # gtd-only | all | none (default: gtd-only)
sort-type: due-date # due-date | title | modified-date (default: due-date)
sort: asc # asc | desc (default: asc)
in-progress-tag: in-progress # default: "in-progress"
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)
```
Three columns are always shown: **Backlog**, **In Progress**, and **Done**.
Ordinary notes participate only when their body contains a `gtd` block; empty
and malformed blocks both opt in, with malformed YAML producing a warning and
fallback card values. `notes: all` admits those opted-in ordinary notes while
`notes: none` excludes them; it never admits a note without a `gtd` block. The
`notes` and `todos` options apply independently.
Bucketing: completion wins regardless of progress tags. Native to-dos use
`todo_completed`; opted-in ordinary notes use `done-tag`. Incomplete items
carrying `in-progress-tag` enter In Progress; everything else enters Backlog.
The done tag neither completes a native to-do nor opts in a plain note.
`done-window` filters native completed to-dos only; completed notes remain in
Done because no completion timestamp is inferred from `updated_time`. Sorting
is per-column across the combined note/to-do array.
Under `due-date`, dateless cards sort after dated cards regardless of direction.
`page-size` must be a positive integer; invalid values warn and fall back to
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.
### 2.4 The `gtd-matrix` block (lives in the matrix note)
```
```gtd-matrix
mode: skeleton # skeleton | eisenhower (default: skeleton)
title: Priorities
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
notebook: Work/Ideas # same semantics as gtd-calendar (default: this note's folder)
notes: all # all | none; gtd notes only (default: all)
todos: gtd-only
sort-type: due-date
sort: asc
urgent-tag: urgent # default: "urgent"
important-tag: important # Eisenhower mode only; default: "important"
in-progress-tag: in-progress # Skeleton mode only; default: "in-progress"
done-tag: done # note completion in both modes; default: "done"
urgent-window: 3 # Skeleton mode only, days; default: 3
card-detail: hover
page-size: 10 # positive integer; per quadrant (default: 10)
```
A 2×2 grid with labelled axes. It includes incomplete to-dos admitted by
`todos:` plus unfinished ordinary notes admitted by `notes:`. `notes: all`
still requires an explicit `gtd` block; `notes: none` excludes all notes. Completed
to-dos and notes carrying `done-tag` are excluded before either mode buckets
them, so done wins over active, urgent, and important tags. Native to-dos ignore
`done-tag` and remain governed by `todo_completed`. The board is internally
positional; each mode supplies its own axis and quadrant labels.
**`mode: skeleton`** (default) — rows = active (`in-progress-tag`, the same tag the kanban uses); columns = due soon (resolved card date within `urgent-window` days, overdue included, or the `urgent-tag` as a manual override for dateless items). Quadrants: **Do Next** (active + due soon), **Scheduled** (active, not due soon — includes dateless in-progress work), **On Deck** (not active + due soon — the early-warning quadrant), **Backlog** (neither). Designed to complement the calendar and kanban directly: same in-progress tag, dates drive urgency instead of a second opinion-tag, and nothing is ever condemned as "Eliminate" — a Backlog item is simply low priority. In-progress + `urgent-tag` + no date resolves to Do Next (the urgent tag is an explicit override, so it wins over the absence of a date).
**`mode: eisenhower`** — the classic: rows = `important-tag`; columns = `urgent-tag`. Quadrants: Do First (both), Schedule (important only), Delegate (urgent only), Eliminate (neither).
A warning is raised if `urgent-tag` and `important-tag` resolve to the same tag (Eisenhower mode only — the axes would collapse). `page-size` has the same validation and transient behavior as the kanban, applied independently to each quadrant after sorting. Read-only.
### 2.5 The `gtd-gantt` block (dual-role: chart **and** item declaration)
Unlike the other views, gantt items opt in through their **own** `gtd-gantt` block rather than the shared `gtd` block, and every item carries a `project:` that groups items into swimlanes. The same fence name therefore serves two roles, disambiguated by a single rule:
> **A `gtd-gantt` block containing a non-empty `project:` key is an ITEM declaration; one without is the CHART config block.**
The markdown-it content script applies this rule with a cheap line scan (no YAML lib): an item block renders in place as a small inline badge (`gantt: <project>`) and is never drawn as a chart; a chart block emits the usual webview placeholder. `collectGantt` applies the identical rule when scanning (empty/whitespace project ⇒ treated as a chart block, i.e. not an item).
**Item block** (lives in any participating note/todo):
```
```gtd-gantt
project: bluesky # REQUIRED — the swimlane; its presence marks this an item
begin-date: 2026-07-01 # notes only — bar span start (yyyy-mm-dd or mm-dd-yyyy)
end-date: 2026-07-14 # notes only — bar span end
bg-colour: teal # optional styling, mirroring the gtd block
fg-colour: white
title: Design phase # optional; defaults to the note title
text: Hover detail # optional; tooltip
```
```
| Key | Applies to | Behavior |
|---|---|---|
| `project` | both | Required. Groups the item into a swimlane (case-insensitive; first-seen casing is the display name). Empty/absent ⇒ this is a chart block, not an item. |
| `begin-date` / `end-date` | notes → bars | Both required and valid, `end-date``begin-date`, else the item is **excluded with a warning**. |
| (native due date) | todos → milestones | A to-do becomes a ◆ **milestone** on its own `todo_due`. `begin-date`/`end-date` on a to-do are ignored (warning). No due date ⇒ excluded with a warning. |
| `bg-colour`/`fg-colour`/`title`/`text` | both | As the gtd block. Bars default to the stable pastel hash when no colour is set. |
Completed to-do milestones render dimmed and struck through; recurring to-dos get the ↻ mark. There is **no Unscheduled section** — an item with no usable date is simply excluded (the warning explains why).
**Chart block** (lives where the timeline is drawn):
```
```gtd-gantt
title: Roadmap
scope: children # this-folder | children | N | all — same semantics as gtd-calendar
notebook: Work/Ideas # same semantics as gtd-calendar; ignored under scope: all
filter-project: bluesky # optional — limit to a single project (case-insensitive)
sort-type: begin-date # begin-date | title | modified-date (default: begin-date)
sort: asc # asc | desc (default: asc)
card-detail: hover # hover | always | none (default: hover)
```
```
There is deliberately **no `notes`/`todos` inclusion option**: items opt in solely via their `project:` block, so those keys are unknown here (and warn as such). Projects render alphabetically; within each project, rows sort by `sort-type`/`sort`. The renderer draws a month tick header, a "today" line when today falls within the chart's date envelope, and scrolls horizontally for wide ranges. The host note is always excluded from its own scan (as with every view). Bars and milestones are clickable (open the source note); hover detail uses a native tooltip, suppressed by `card-detail: none`. Read-only.
---
## 3. Data Model Rules
### 3.1 Date resolution
1. **Todos:** `todo_due` from the Joplin API. A `date:` in the `gtd` block overrides it.
2. **Notes:** `date:` from the `gtd` block only. No fallback to created/updated time — those are not event dates and would produce misleading calendars.
3. **No resolvable date** → the calendar renders the item in **Unscheduled**; kanban and matrix retain it in the appropriate dateless bucket.
### 3.2 Inclusion matrix
An item appears on the calendar iff its type's mode admits it:
| Mode | Has `gtd` block | No `gtd` block |
|---|---|---|
| `gtd-only` (default) | ✅ included | ❌ excluded |
| `all` | ✅ included | ✅ included (default styling) |
| `none` | ❌ excluded | ❌ excluded |
The calendar note itself is always excluded from results, as is any note whose only `gtd-calendar` block makes it a calendar (a note may, however, be both an event and contain a calendar if it has both block types — edge case, supported, documented).
Kanban and matrix use a deliberately asymmetric inclusion contract:
| Item | Has `gtd` block | No `gtd` block |
|---|---|---|
| Ordinary note with `notes: all` (default) | Included | Excluded |
| Ordinary note with `notes: none` | Excluded | Excluded |
| To-do with `todos: gtd-only` | Included | Excluded |
| To-do with `todos: all` | Included | Included |
| To-do with `todos: none` | Excluded | Excluded |
An empty or malformed `gtd` block counts as found. Malformed admitted blocks
warn but do not disappear. The independent `notes` and `todos` filters run
before warnings and tag lookup. Each kanban/matrix host note is excluded before
eligibility and tag lookup.
### 3.3 Completed todos
**Calendar:** completed to-dos render with strikethrough and a checked indicator.
**Kanban:** completed to-dos enter Done when inside the configured done window;
opted-in ordinary notes carrying `done-tag` also enter Done without timestamp
filtering. **Matrix:** completed to-dos and done-tag notes are excluded. Ordinary
notes never become recurring.
---
## 4. Rendering
- **Calendar** reuses upstream's grouping/renderer architecture (`DayGrouping`/`WeekGrouping`/`MonthGrouping` + DOM renderers + CSS asset), including the empty-grouping placeholders and current-date highlight, plus the fork's own `month-grid` (a 7-column grid of the entire current month).
- **Fixed upstream bug:** `Calendar/index.ts` imported `WeekGrouping` from `Month/MonthGrouping`, so the week view silently grouped by month. Corrected in the fork.
- **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.
- **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.
---
## 5. Architecture
Upstream is a single synchronous markdown-it content script with no data API access. The fork is a three-part plugin serving four view fence types (`gtd-calendar`, `gtd-kanban`, `gtd-matrix`, and `gtd-gantt`) through the same pipeline:
```
┌─────────────────────────────┐
│ 1. Markdown-it content │ Intercepts the four view fence types
│ script (renderer) │ and parses their YAML configuration,
│ │ emits a placeholder <div> tagged with
│ │ data-block-type and the config as a
│ │ data attribute.
└──────────────┬──────────────┘
│ (rendered HTML)
┌──────────────▼──────────────┐
│ 2. Webview asset script │ Finds placeholders, calls
│ (runs in rendered note) │ webviewApi.postMessage(config) with
│ │ getEvents / getKanban / getMatrix / getGantt
│ │ depending on block type. Builds the
│ │ calendar / board / matrix / Gantt DOM from
│ │ returned payload. Click → postMessage
│ │ ({type: "openNote", noteId}).
└──────────────┬──────────────┘
│ postMessage / onMessage
┌──────────────▼──────────────┐
│ 3. Main plugin process │ joplin.contentScripts.onMessage routes
│ (index.ts) │ by message type to a handler per block
│ │ type, each of which:
│ │ • resolves current note's folder
│ │ (joplin.workspace.selectedNote)
│ │ • builds folder tree, applies scope
│ │ (src/Gtd/folderScope.ts)
│ │ • fetches notes/todos, paginated
│ │ • parses ```gtd blocks from bodies
│ │ (src/Gtd/gtdBlock.ts)
│ │ • fetches tag titles per eligible item
│ │ (getNoteTagTitles) for recurrence /
│ │ in-progress / urgent / important
│ │ detection
│ │ • applies the inclusion matrix, date
│ │ resolution, and view-specific
│ │ bucketing (collectEvents.ts /
│ │ collectKanban.ts / collectMatrix.ts /
│ │ collectGantt.ts)
│ │ • handles openNote via
│ │ joplin.commands.execute
└─────────────────────────────┘
```
The calendar, kanban, and matrix collectors share `folderScope.ts`,
`gtdBlock.ts`, and `resolveDate.ts`. Kanban and matrix also share
`buildKanbanCard.ts`, which normalizes the explicit `isTodo` discriminator,
source ID, title/style overrides, resolved date, completion state, recurrence
state, and modification time.
Their collection flow is: resolve scoped folders → exclude the host note →
evaluate item-kind eligibility → parse and warn on the `gtd` block → fetch
tags only for eligible items → build a normalized card → bucket → sort the
combined note/to-do arrays. Complete arrays and totals cross the plugin boundary;
SLICE8 batching remains transient webview state.
### Performance notes
- Folder tree and note list fetched per render request; paginated API calls (`page`/`has_more`).
- Body scanning is limited to items in scope. Calendar may omit bodies when both
item inclusion modes are `none`. Kanban and matrix always request bodies,
including when `todos: none`, because ordinary notes opt in through a `gtd`
block and `todos:` governs only to-dos. This is an intentional performance
tradeoff: block discovery requires the body, while tag-title requests remain
limited to items that pass host, type, completion, and shortcode eligibility.
For large scopes, pre-filter with Joplin's search API before fetching full
bodies; cache by `updated_time` if needed — not yet required at observed
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.
### Platform caveats
- Desktop is the target. Mobile plugin support is partial and this plugin's core mechanisms (custom fence rendering, webview↔plugin messaging) are not expected to work there; documented explicitly in the README. Underlying to-dos still sync and appear in Joplin's native mobile views. The "Embed Search" plugin pairing remains a documented pattern for cross-scope aggregation generally, not specifically a mobile workaround.
---
## 6. Implementation Phases
1. **Phase 0 — Scaffolding.** Fork, rename (manifest id, plugin name, file names), build pipeline verified (`npm install && npm run dist`), upstream week-view bug fixed, upstream tests green.
2. **Phase 1 — Plumbing.** Placeholder rendering for `gtd-calendar` fences; webview ⇄ plugin message round-trip proven with a hardcoded payload.
3. **Phase 2 — Data layer.** Folder tree + scope resolution; note/todo fetching; `gtd` block parsing; inclusion matrix; date resolution; unit tests for all of it (the upstream Jest setup carries over).
4. **Phase 3 — Rendering.** Wire real data into the reused renderers; Unscheduled section; type glyphs; fg-colour support; sort options.
5. **Phase 4 — Drilldown & polish.** Click-to-open; error boxes for malformed YAML; docs/README; scope-performance warnings.
---
## 7. Decision Log / Open Items
| # | Decision | Status |
|---|---|---|
| D1 | Defaults: `notes: gtd-only`, `todos: gtd-only` | ⚠️ **Confirm** — earlier discussion said todos are *always* included (i.e. `todos: all` default); the final message specified `gtd-only` as default for both. Spec follows the final message. |
| D2 | `gtd` `date:` overrides `todo_due` (vs. emitting a warning) | Adopted; revisit if confusing in practice. |
| D3 | Completed todos: shown struck-through in v1; `completed:` option reserved | Proposed, unconfirmed. |
| D4 | `sort`/`sort-type` act within groupings only; grid is always chronological | Confirmed. |
| D5 | Colour key spelling: `bg-colour`/`fg-colour` canonical, `bgColor` accepted for upstream parity | Proposed. |
| D6 | Future options parked: `completed:`, tag-based filtering, week-start day, live refresh | Backlog. |
---
## 8. Licensing & Attribution
Upstream is MIT (© Franco Speziali / WeMakeMachines). The fork retains the MIT license, preserves the original copyright notice, and credits upstream in the README per standard practice.
---
## 9. Roadmap (post-1.0 / post-launch)
Recorded direction, not commitments. Each gets a focused design pass before work begins; real-world use of the shipped plugin may reorder this.
### v0.2.0 — Repeating to-dos (visualisation) — SHIPPED · strip horizon — NOT YET BUILT
Recurrence itself is delegated to an existing community plugin (open-environment approach; we don't reimplement it). Candidates evaluated: BeatLink's original *Repeating To-Dos* (unmaintained), the *[Revived]* fork (maintained, GitHub issues only), and *Repeating To-dos v2* by TheScriptingGuy (`com.github.TheScriptingGuy.joplin-plugin-repeating-todos`, best-documented). All share the same mechanic: on completion the to-do's alarm date is reset to the next occurrence and the to-do is unmarked — so at any moment a recurring to-do is a **single** to-do at its next due date, with the recurrence rule stored in the note body.
- **Option A (shipped in 0.2.0):** detect recurrence and mark those events with a ↻ glyph. *Implementation note:* v2 of the plugin stores recurrence in Joplin's `userData` (not the note body) and maintains a `recurring` **tag** as its query index — so detection is by tag presence on to-dos (constant `RECURRING_TAG`), not body-scanning. Cleaner than the originally-planned body parse, and reuses the tag-fetching the kanban work will need. No series projection — the to-do shows at its next due date.
- **Option B (in pocket):** parse the recurrence rule and project all future occurrences across the visible window. Deferred because the body format has proven to be a moving target across forks; promote to a 0.2.x only if Option A's single-occurrence view proves insufficient, and target a specific fork's format at that time.
- **README:** recommend the maintained fork (v2 first, Revived as fallback); warn about the known "recurrence block injected into all notes" failure mode; note coexistence (their plugin moves the anchor, ours draws the marker).
**Configurable strip horizon.** `day`/`week`/`month` strip views currently span first-event to last-event (plus today). Add a horizon option to `gtd-calendar` (working name `weeks:` or `horizon:`, default ~8 weeks forward) controlling how far ahead the strip extends — relevant both generally and for how many future occurrences Option B would draw. **`month-grid` is explicitly excluded**: it always shows the entire current calendar month, by design, and that is not configurable.
*Note: multiple `gtd-calendar` blocks in one note already work as of 0.1.0 (each fence renders independently) — e.g. a "week ahead" strip plus a "month grid" in a single dashboard note. No work required; document as a recommended pattern.*
### v0.3.0 — Kanban board (read-only) — SHIPPED
Decision: a **second block type within this plugin**, not a separate plugin. Rationale: a kanban needs the entire existing data layer (`folderScope`, `collectEvents` inclusion matrix, `getNoteTagTitles`, config parsing, recurrence detection, webview messaging); a separate plugin would duplicate all of it or require a shared-library split whose overhead isn't justified for "three tag-driven columns." One plugin also serves the intended use — a calendar and a kanban in one dashboard note — with a single install and a unified config/tag vocabulary.
**The `gtd-kanban` block.** Parallel to `gtd-calendar`; lives in its own note or stacked beneath a calendar (multiple blocks already render independently).
```
```gtd-kanban
title: Editorial Board
scope: this-folder # this-folder | children | N — same semantics as gtd-calendar
todos: gtd-only # gtd-only (default) | all | none
sort-type: due-date # due-date (default) | title | modified-date
sort: asc # asc | desc
in-progress-tag: in-progress # configurable; default "in-progress"
card-detail: hover # hover (default) | always | none
done-window: 7 # days; integer or "all"; default 7
```
**Columns (always three, always shown even when empty):** Backlog · In Progress · Done.
**Bucketing rules:**
- **Done wins.** Any to-do with `todo_completed > 0`*Done*, regardless of tags.
- Else, carrying the in-progress tag → *In Progress*.
- Else → *Backlog*.
- **Backlog shows everything** uncompleted and untagged — dated or not, near or far. (Acknowledged: can grow long; compact cards mitigate.)
**Sorting (within each column):**
- `sort-type: due-date | title | modified-date`, with `sort: asc | desc`.
- Under `due-date`, to-dos **without** a due date sort *after* all dated ones.
**Done column window:** shows only to-dos completed within `done-window` days (default 7); `all` shows the full history. Prevents unbounded growth.
**Cards:** compact by default—title, resolved date, optional recurrence mark for to-dos, and click-to-open. `card-detail` controls the hover panel: `hover` (default), `always` (details inline), `none` (no panel). Ordinary notes containing a `gtd` block now participate under the same placement and styling rules.
**Read-only in 0.3.0:** reflects state, never mutates it. Proves columns/bucketing cheaply before write-back.
**Architecture:** reuses the calendar's pipeline. New `gtd-kanban` markdown-it fence → placeholder → webview requests data → plugin runs scope + inclusion + tag fetch (already built) + bucketing → returns three columns → webview renders. `IN_PROGRESS_TAG` constant mirrors `RECURRING_TAG`, exposed via `in-progress-tag:` config from the start.
**`sort-type` vocabulary reconciliation:** the calendar block's `sort-type` (currently `title | modified_date`) is unified toward `due-date | title | modified-date` across both blocks so they read consistently. For the calendar, `due-date` is accepted but falls back to chronological where less meaningful. (Note the spelling shift `modified_date``modified-date` for cross-block consistency; the old spelling stays accepted as an alias to avoid breaking existing calendars.)
### v0.4.0 — Eisenhower Matrix (`gtd-matrix` block) — SHIPPED
### v0.5.0 — Skeleton Matrix (`mode:` on gtd-matrix) — SHIPPED
Real-world feedback: Eisenhower's opinion axes (and "Eliminate" in particular) fit triage, not a vault of already-committed work. The **Skeleton Matrix** replaces them with two derived axes the system already maintains: rows = active (`in-progress-tag`, shared with the kanban); columns = due soon (`urgent-window` days, default 3, overdue included; `urgent` tag as manual override — in-progress + urgent + dateless is Do Next by design). Quadrants: Do Next / Scheduled / On Deck / Backlog; dateless active work → Scheduled; nothing is ever "Eliminate". Implemented as `mode: skeleton | eisenhower` on the same block — positional MatrixBoard (topLeft…bottomRight), labels supplied per mode via payload, shared rendering. Skeleton is the default (it matches the plugin's own tag/date vocabulary); classic Eisenhower preserved via `mode: eisenhower`.
A fourth display block: a 2×2 prioritisation matrix, tag-driven like the kanban. Chosen ahead of drag-and-drop deliberately: it is on the **views axis** (another rendering of tagged to-dos, reusing the whole data layer, read-only) rather than the **authoring axis** (vault mutation), so it deepens what the plugin already is without committing to write-back.
- **Quadrants** from two configurable tags (`urgent-tag:` default `urgent`; `important-tag:` default `important`):
- urgent + important → **Do First**
- important only → **Schedule**
- urgent only → **Delegate**
- neither → **Eliminate** (the default bucket for untagged to-dos — a deliberately pointed default)
- **Completed to-dos are excluded entirely** — the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live.
- Incomplete to-dos plus ordinary notes containing a `gtd` block; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as kanban and the same compact clickable cards.
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
### v0.6.0 — Notebook targeting & profile-wide scope — SHIPPED
Two scope-axis additions shared across all four view blocks (see §2.1 / §2.5 for the normative schema):
- **`notebook:`** roots the folder scan at a chosen notebook rather than the host note's own folder, so a dashboard note can live anywhere and aggregate a different tree. Resolution order: raw 32-char folder id → `Parent/Child` case-insensitive title path → unique bare title. Any miss (unknown id, unresolvable path, ambiguous or absent title) warns and falls back to the host folder (warn-don't-fail). `scope` applies relative to the resolved notebook. Implemented as a pure `resolveNotebook.ts` (folders + spec → id + warning) plus a shared `parseNotebookOption`; resolution lives in `index.ts` (needs the folder tree), collectors stay unchanged.
- **`scope: all`** scans every notebook in the profile, ignoring root and depth — an explicit, deliberately expensive opt-in ("very dangerous, yes"). Represented as a `scopeAll` flag (not a `scopeDepth` sentinel); `folderScope` returns all folder ids when set. Guardrails: the stats footnote reports scan size and is marked `scope: all`; a soft ⚠ appears past ~2000 scanned notes (advisory, never blocks). `notebook:` + `scope: all``all` wins, with a warning.
- **Body-fetch contract:** calendar retains its safe inclusion-mode optimization and Gantt retains its item-block behavior. Kanban and matrix now always request bodies because ordinary-note opt-in must be discovered even under `todos: none`; tag requests remain eligibility-gated.
### v0.7.0 — Gantt chart (`gtd-gantt` block) — SHIPPED
A fifth view: a project-grouped timeline, on the **views axis** like the matrix (another read-only rendering of existing notes/to-dos), not the authoring axis. Unlike the other blocks, gantt items opt in through their **own** `gtd-gantt` block carrying a `project:`, and the same fence name is disambiguated by that key (item vs chart — see §2.5). Notes become bars (`begin-date``end-date`), to-dos become ◆ milestones on their due date; grouped into per-project swimlanes, no Unscheduled section (dateless/invalid items are excluded with a warning). Data layer is pure and unit-tested (`ganttBlock.ts`, `parseGanttConfig.ts`, `collectGantt.ts`); rendering is a CSS-grid timeline (month ticks, today line, horizontal scroll, click-to-open). Item blocks render in place as a small `gantt: <project>` badge. Read-only. Open follow-ups (deferred): richer hover cards (currently native tooltips), `card-detail: always` inline text on bars, an optional Unscheduled strip.
### Unreleased — Incremental kanban/matrix card limits — COMPLETE
Kanban columns and matrix quadrants now render in configurable `page-size`
batches (default 10). Parsing is shared and rejects zero, negative, fractional,
non-numeric, or non-finite values with a warning and fallback to 10. Collectors
remain complete and unchanged; normalized `pageSize` crosses the plugin payload
boundary, while the webview owns independent transient expansion state for every
bucket. **List more** reveals one batch, reports the exact remaining count through
an accessible hover/focus tooltip, preserves focus between intermediate clicks,
and disappears at completion. Reload resets all expansion. Automated validation
passed at 164 tests and manual Joplin acceptance passed 2026-07-27.
### Unreleased — Opted-in note cards for kanban/matrix — COMPLETE
Ordinary notes containing a `gtd` block now participate in kanban and matrix
views. Empty and malformed blocks opt in; malformed YAML warns and falls back to
source-note values. Notes use `gtd` dates only, share title/colour/icon/text
overrides, render with 📄 when no icon is supplied, and never become completed or
recurring. `todos:` continues to govern only to-dos. Kanban places notes in
Backlog or In Progress; both matrix modes reuse their existing tag/date axes.
Body inspection is always enabled for these two collectors, while tag lookup
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
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
**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.
**Create-to-do from kanban.** A "+" affordance per column creates a to-do with that column's state (In Progress column → in-progress tag; Backlog → no tag). Easier than create-from-calendar: the column *is* the metadata, no date geometry. Open: target notebook (leaf dashboards obvious, cockpit murky), trigger reliability in the rendered viewer.
**Create-to-do from calendar.** Click/affordance on a day cell creates a to-do due that day. Open questions: trigger mechanism in the read-only viewer (double-click/right-click may be claimed by Joplin; per-cell "+" affordance likelier), target notebook rule, whether created to-dos get an empty gtd block so they appear under `todos: gtd-only`. Prototype the trigger before designing the rest.

191
TASKS.md Normal file
View File

@ -0,0 +1,191 @@
# TASKS — SLICE8 through SLICE10
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
manual Joplin acceptance recorded separately.
## Overall status
- [x] SLICE8 complete — incremental card limits
- [x] SLICE9 complete — opted-in note cards
- [ ] SLICE10 complete — child-notebook boards and matrices
Implementation order: SLICE8, then SLICE9, then SLICE10.
## SLICE8 — Incremental card limits for kanban and matrix views
### Configuration and types
- [x] Add normalized `page-size` fields to `KanbanConfig` and `MatrixConfig` in
`src/Gtd/types.ts`.
- [x] Recognize `page-size` in `parseKanbanConfig.ts` and
`parseMatrixConfig.ts`.
- [x] Default `page-size` to 10.
- [x] Accept positive integer overrides.
- [x] Warn and fall back to 10 for zero, negative, fractional, or non-numeric
values.
- [x] Add parser tests for defaults, valid overrides, and invalid fallbacks.
- [x] Send normalized `pageSize` in successful and missing-source-note kanban
and matrix responses.
- [x] Preserve collector arguments, result shapes, sorting, totals, and scope
behavior while adding the payload field.
### Rendering and interaction
- [x] Add reusable incremental-list behavior to
`src/gtd-calendar-webview.js`.
- [x] Initially render at most `page-size` cards in each kanban column.
- [x] Initially render at most `page-size` cards in each matrix quadrant.
- [x] Add a separate "List more" control to every bucket with hidden cards.
- [x] Reveal the next `page-size` cards in only the clicked bucket.
- [x] Update the remaining-entry count after every click.
- [x] Remove the control when no hidden cards remain.
- [x] Reset all expansion state when the rendered note reloads.
- [x] Keep complete collection results and total-card statistics; apply limits
only while rendering.
### Accessibility and styling
- [x] Style the button and remaining-count popup in `src/event-calendar.css`.
- [x] Show the exact remaining-entry count on pointer hover.
- [x] Make the same summary available on keyboard focus.
- [x] Ensure activating "List more" never triggers a card's note-open action.
### Tests, documentation, and acceptance
- [x] Test empty buckets and buckets smaller than one batch.
- [x] Test buckets containing exactly one batch.
- [x] Test one card beyond a batch boundary.
- [x] Test repeated expansion and final-control removal.
- [x] Test independent expansion across columns, quadrants, and stacked blocks.
- [x] Document `page-size` and transient expansion in README.md.
- [x] Document the behavior and data-flow boundary in SPEC.md.
- [x] Add the feature to CHANGELOG.md.
- [x] Run the full automated test suite and record command/results.
- [x] Run the production package build and record command/artifact.
- [x] Manually verify kanban with more than 20 cards in one column in Joplin.
- [x] Manually verify Skeleton and Eisenhower matrices with more than 20 cards
in one quadrant in Joplin.
- [x] Confirm sorting, card navigation, styling, warnings, and statistics remain
unchanged.
- [x] Mark SLICE8 complete in `SLICE8.md` and this file.
## SLICE9 — Include opted-in note cards in kanban and matrix views
### Collection and types
- [x] Update kanban collection to inspect ordinary notes (`is_todo: 0`).
- [x] Include an ordinary note only when its body contains a `gtd` block.
- [x] Keep ordinary notes without a `gtd` block excluded.
- [x] Keep the host view note excluded even when it contains a `gtd` block.
- [x] Preserve current `todos:` behavior for to-do notes.
- [x] Place eligible normal notes with the configured in-progress tag in the
kanban In Progress column.
- [x] Place other eligible normal notes in the kanban Backlog column.
- [x] Never place normal notes in the kanban Done column.
- [x] Apply existing urgent/important tag bucketing to normal notes in an
Eisenhower matrix.
- [x] Apply existing date bucketing to normal notes in a Skeleton matrix.
- [x] Reuse `gtd` title, date, colour, icon, and text overrides on note cards.
- [x] Ensure normal notes are never marked completed or recurring.
- [x] Update card types to represent notes and to-dos accurately.
- [x] Fetch note bodies wherever opted-in notes may be collected while retaining
only safe body-fetch optimizations.
### Tests
- [x] Test kanban inclusion for a note containing a valid `gtd` block.
- [x] Test kanban exclusion for a plain note.
- [x] Test kanban Backlog and In Progress placement for note cards.
- [x] Test host-note exclusion and malformed `gtd` block warnings.
- [x] Test that current to-do and Done behavior is unchanged.
- [x] Test note-card bucketing in Skeleton matrix mode.
- [x] Test note-card bucketing in Eisenhower matrix mode.
- [x] Test completed-to-do exclusion remains unchanged in matrices.
- [x] Test mixed note/to-do sorting and click-to-open payloads.
- [x] Test that SLICE8 limits use the combined note and to-do card count.
### Documentation and acceptance
- [x] Document note-card opt-in rules and examples in README.md.
- [x] Update SPEC.md with mixed note/to-do behavior.
- [x] Add the feature to CHANGELOG.md.
- [x] Run the full automated test suite and record command/results.
- [x] Run the production package build and record command/artifact.
- [x] Manually verify mixed note/to-do kanban views in Joplin.
- [x] Manually verify mixed note/to-do views in both matrix modes in Joplin.
- [x] Confirm existing to-do filtering, completion, sorting, styling, warnings,
and navigation do not regress.
- [x] Mark SLICE9 complete in `SLICE9.md` and this file.
## SLICE10 — Separate child-notebook kanban and matrix views
### Scope and folder grouping
- [x] Preserve explicit `scope: children` plus `group: notebook` intent in
normalized kanban and matrix configuration.
- [x] Keep numeric scope, `this-folder`, and `scope: all` semantics unchanged.
- [x] Add a shared folder-tree helper that returns notebook IDs, titles, paths,
and parent relationships in deterministic tree/path order.
- [x] Handle nested notebooks and duplicate notebook names correctly.
- [x] Create one result group for cards owned directly by the root notebook.
- [x] Create one result group for cards owned directly by each descendant
notebook.
- [x] Ensure every in-scope card appears in exactly one notebook group.
- [x] Omit empty notebook groups.
- [x] Preserve one overall empty-state presentation when all groups are empty.
### Payloads and rendering
- [x] Add named per-notebook kanban and matrix groups to collection result types.
- [x] Retain compatible single-view results unless both `scope: children` and
`group: notebook` are selected.
- [x] Provide aggregate and per-view statistics without double-counting notes or
cards.
- [x] Preserve warnings at the appropriate overall or notebook-view level.
- [x] Update plugin-to-webview messages and handlers for grouped results.
- [x] Render a complete kanban or matrix component for every non-empty group.
- [x] Use the full notebook path as each view heading.
- [x] Give every generated view independent SLICE8 expansion state.
- [x] Apply the configured `page-size` independently within every view bucket.
- [x] Group SLICE9 note cards by owning notebook in the same way as to-do cards.
### Tests
- [x] Test preservation of explicit `children` plus `group: notebook` intent.
- [x] Test deterministic folder ordering, nested paths, duplicate names, and
empty groups.
- [x] Test that kanban cards appear exactly once in their owning notebook group.
- [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 that ungrouped `children`, numeric scope, `this-folder`, and
`scope: all` still produce one view with unchanged behavior.
### Documentation and acceptance
- [x] Document `scope: children` plus `group: notebook` multi-view behavior
in README.md.
- [x] Add a parent/child/grandchild notebook example to README.md or SPEC.md.
- [x] Update architecture and payload behavior in SPEC.md.
- [x] Add the feature to CHANGELOG.md.
- [x] Run the full automated test suite and record command/results.
- [x] Run the production package build and record command/artifact.
- [x] Manually verify a parent notebook containing multiple children and
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.
## 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] 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 SLICE810 reflect the
final implementation state.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "joplin-plugin-gtd-calendar",
"version": "2.2.1",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "joplin-plugin-gtd-calendar",
"version": "2.2.1",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"date-fns": "^2.29.3",

View File

@ -1,8 +1,9 @@
{
"name": "joplin-plugin-gtd-calendar",
"version": "2.2.1",
"version": "1.0.0",
"scripts": {
"test": "jest",
"predist": "bash scripts/archive-jpl.sh",
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
"prepare": "npm run dist",
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force",

38
scripts/archive-jpl.sh Normal file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# Archive existing publish/*.jpl builds before a new `npm run dist`.
#
# Never overwrite a previous .jpl. This moves each current
# publish/*.jpl into build-archive/ with a version + timestamp suffix so a
# fresh build can't clobber the last one. No-op when no .jpl is present.
#
# The archive lives at repo root (NOT under publish/) deliberately: webpack's
# buildMain step runs `fs.removeSync(publishDir)` and would wipe an in-publish
# archive, and `package.json` "files": ["publish"] would ship it to npm.
# build-archive/ is git-ignored and outside both.
#
# Wired as the `predist` npm script, so it runs automatically before every
# `npm run dist`. Safe to run manually too.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
publish_dir="$repo_root/publish"
archive_dir="$repo_root/build-archive"
shopt -s nullglob
jpls=("$publish_dir"/*.jpl)
if [ ${#jpls[@]} -eq 0 ]; then
echo "archive-jpl: no publish/*.jpl to archive."
exit 0
fi
version="$(node -p "require('$repo_root/package.json').version")"
timestamp="$(date +%Y%m%d-%H%M%S)"
mkdir -p "$archive_dir"
for jpl in "${jpls[@]}"; do
base="$(basename "$jpl" .jpl)"
dest="$archive_dir/${base}-${version}-${timestamp}.jpl"
mv "$jpl" "$dest"
echo "archive-jpl: moved $(basename "$jpl") -> build-archive/$(basename "$dest")"
done

View File

@ -5,7 +5,7 @@ import {
RawNote,
RECURRING_TAG,
} from "./types";
import resolveEffectiveFolderScope from "./effectiveFolderScope";
import resolveScopedFolderIds from "./folderScope";
import extractGtdBlock from "./gtdBlock";
import { resolveEventDate } from "./resolveDate";
@ -35,15 +35,12 @@ export default async function collectEvents(
const warnings: string[] = [];
const folders = await adapter.getFolders();
const effectiveScope = resolveEffectiveFolderScope(
const folderIds = resolveScopedFolderIds(
folders,
calendarFolderId,
config.scopeDepth,
config.scopeAll,
config.excludeNotebooks
config.scopeAll
);
const folderIds = effectiveScope.folderIds;
warnings.push(...effectiveScope.warnings);
let scannedNotes = 0;
const events: GtdEvent[] = [];

View File

@ -6,7 +6,7 @@ import {
KanbanLayout,
NotebookKanbanGroup,
} from "./types";
import resolveEffectiveFolderScope from "./effectiveFolderScope";
import resolveScopedFolderIds from "./folderScope";
import resolveScopedFolderMetadata from "./folderTree";
import extractGtdBlock from "./gtdBlock";
import buildKanbanCard from "./buildKanbanCard";
@ -42,23 +42,17 @@ export default async function collectKanban(
const warnings: string[] = [];
const folders = await adapter.getFolders();
const effectiveScope = resolveEffectiveFolderScope(
folders,
kanbanFolderId,
config.scopeDepth,
config.scopeAll,
config.excludeNotebooks
);
warnings.push(...effectiveScope.warnings);
const effectiveIds = new Set(effectiveScope.folderIds);
const folderMetadata = config.groupByNotebook
? resolveScopedFolderMetadata(folders, kanbanFolderId).filter((folder) =>
effectiveIds.has(folder.id)
)
? resolveScopedFolderMetadata(folders, kanbanFolderId)
: [];
const folderIds = config.groupByNotebook
? folderMetadata.map((folder) => folder.id)
: effectiveScope.folderIds;
: resolveScopedFolderIds(
folders,
kanbanFolderId,
config.scopeDepth,
config.scopeAll
);
let scannedNotes = 0;
const backlog: KanbanCard[] = [];

View File

@ -6,7 +6,7 @@ import {
MatrixLayout,
NotebookMatrixGroup,
} from "./types";
import resolveEffectiveFolderScope from "./effectiveFolderScope";
import resolveScopedFolderIds from "./folderScope";
import resolveScopedFolderMetadata from "./folderTree";
import extractGtdBlock from "./gtdBlock";
import buildKanbanCard from "./buildKanbanCard";
@ -27,9 +27,11 @@ export interface MatrixResult {
* - notes and todos filters apply independently.
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
* prioritisation view; completions live in the kanban's Done column.
* - Eisenhower uses its two axis tags.
* - Skeleton uses exclusive workflow destinations with urgent > in-progress
* > on-deck precedence and Backlog as the untagged default.
* - Quadrants from the two axis tags:
* urgent + important -> Do First
* important only -> Schedule
* urgent only -> Delegate
* neither -> Eliminate (default bucket for untagged)
*
* The matrix note itself is always excluded.
*/
@ -38,28 +40,22 @@ export default async function collectMatrix(
matrixNoteId: string,
matrixFolderId: string,
config: MatrixConfig,
_now: Date = new Date()
now: Date = new Date()
): Promise<MatrixResult> {
const warnings: string[] = [];
const folders = await adapter.getFolders();
const effectiveScope = resolveEffectiveFolderScope(
folders,
matrixFolderId,
config.scopeDepth,
config.scopeAll,
config.excludeNotebooks
);
warnings.push(...effectiveScope.warnings);
const effectiveIds = new Set(effectiveScope.folderIds);
const folderMetadata = config.groupByNotebook
? resolveScopedFolderMetadata(folders, matrixFolderId).filter((folder) =>
effectiveIds.has(folder.id)
)
? resolveScopedFolderMetadata(folders, matrixFolderId)
: [];
const folderIds = config.groupByNotebook
? folderMetadata.map((folder) => folder.id)
: effectiveScope.folderIds;
: resolveScopedFolderIds(
folders,
matrixFolderId,
config.scopeDepth,
config.scopeAll
);
let scannedNotes = 0;
const topLeft: KanbanCard[] = [];
@ -74,6 +70,9 @@ export default async function collectMatrix(
});
}
// Skeleton mode: a date on or before this ISO threshold is "due soon".
const soonThreshold = isoDaysFromNow(now, config.urgentWindow);
// Bodies preserve explicit note opt-in and optional to-do card overrides.
// When both types are disabled, metadata alone is sufficient for scan totals.
const includeBody = config.notes !== "none" || config.todos !== "none";
@ -118,17 +117,13 @@ export default async function collectMatrix(
topRow = tags.includes(config.importantTag);
leftColumn = tags.includes(config.urgentTag);
} else {
// Skeleton is a four-state tag workflow. Precedence makes
// overlapping legacy tags deterministic until the next move.
if (tags.includes(config.urgentTag)) {
topRow = true; leftColumn = true;
} else if (tags.includes(config.inProgressTag)) {
topRow = true; leftColumn = false;
} else if (tags.includes(config.onDeckTag)) {
topRow = false; leftColumn = true;
} else {
topRow = false; leftColumn = false;
}
// Skeleton. Rows: active (in-progress) / not.
// Columns: due soon (urgent tag overrides; else date within
// the window, overdue included) / not.
topRow = tags.includes(config.inProgressTag);
leftColumn =
tags.includes(config.urgentTag) ||
(card.date !== null && card.date <= soonThreshold);
}
if (topRow && leftColumn) targetBoard.topLeft.push(card);
@ -193,6 +188,18 @@ function countBoard(board: MatrixBoard): number {
);
}
/** ISO yyyy-mm-dd for local `now` plus `days`. */
function isoDaysFromNow(now: Date, days: number): string {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
return (
String(d.getFullYear()).padStart(4, "0") +
"-" +
String(d.getMonth() + 1).padStart(2, "0") +
"-" +
String(d.getDate()).padStart(2, "0")
);
}
function sortColumn(cards: KanbanCard[], config: MatrixConfig): void {
const direction = config.sort === "desc" ? -1 : 1;

View File

@ -1,43 +0,0 @@
import resolveScopedFolderIds from "./folderScope";
import { resolveNotebookReference } from "./resolveNotebook";
import { RawFolder } from "./types";
export interface EffectiveFolderScope {
folderIds: string[];
excludedFolderIds: Set<string>;
warnings: string[];
}
/** Resolve positive scope, then subtract every valid excluded notebook tree. */
export default function resolveEffectiveFolderScope(
folders: RawFolder[],
rootFolderId: string,
depth: number,
scopeAll: boolean,
excludeNotebooks: string[]
): EffectiveFolderScope {
const positiveIds = resolveScopedFolderIds(folders, rootFolderId, depth, scopeAll);
const excludedFolderIds = new Set<string>();
const warnings: string[] = [];
for (const spec of excludeNotebooks) {
const resolved = resolveNotebookReference(folders, spec);
if (!resolved.folderId) {
warnings.push(`exclude-notebook: ${resolved.problem} (nothing excluded)`);
continue;
}
for (const folderId of resolveScopedFolderIds(
folders,
resolved.folderId,
Infinity
)) {
excludedFolderIds.add(folderId);
}
}
return {
folderIds: positiveIds.filter((folderId) => !excludedFolderIds.has(folderId)),
excludedFolderIds,
warnings,
};
}

View File

@ -14,7 +14,7 @@ export default function resolveScopedFolderIds(
depth: number,
scopeAll = false
): string[] {
if (scopeAll) return [...new Set(folders.map((folder) => folder.id))];
if (scopeAll) return folders.map((folder) => folder.id);
const childrenByParent = new Map<string, string[]>();
for (const folder of folders) {
@ -24,7 +24,6 @@ export default function resolveScopedFolderIds(
}
const inScope: string[] = [rootFolderId];
const visited = new Set<string>([rootFolderId]);
let frontier = [rootFolderId];
let level = 0;
@ -32,8 +31,6 @@ export default function resolveScopedFolderIds(
const next: string[] = [];
for (const folderId of frontier) {
for (const childId of childrenByParent.get(folderId) || []) {
if (visited.has(childId)) continue;
visited.add(childId);
inScope.push(childId);
next.push(childId);
}

View File

@ -1,136 +0,0 @@
const YAML = require("yaml");
import extractGtdBlock from "./gtdBlock";
import resolveEffectiveFolderScope from "./effectiveFolderScope";
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(resolveEffectiveFolderScope(
folders,
scanFolderId,
config.scopeDepth,
config.scopeAll,
config.excludeNotebooks
).folderIds);
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.");
}
}

View File

@ -1,113 +0,0 @@
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));
}
export async function ensureNoteTag(
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;
}
export async function removeNoteTag(
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 removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
if (note.todo_completed > 0) {
await adapter.setTodoCompleted(noteId, 0);
changed = true;
}
if (destination === "inProgress") {
changed =
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
} else {
changed =
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) ||
changed;
}
}
} else if (destination === "done") {
changed = (await ensureNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
} else if (destination === "inProgress") {
changed =
(await ensureNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
} else {
changed = (await removeNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
changed =
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
}
return { note, changed };
}

View File

@ -7,20 +7,20 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels {
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
topLeft: "Do First",
topRight: "Schedule",
bottomLeft: "Delegate",
bottomRight: "Eliminate",
},
};
}
// skeleton
return {
columns: ["Priority", "Current / backlog"],
rows: ["Now", "Next / later"],
columns: ["Due soon", "Not due soon"],
rows: ["Active", "Not active"],
quadrants: {
topLeft: "Urgent",
topRight: "In Progress",
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},

View File

@ -1,135 +0,0 @@
const YAML = require("yaml");
import extractGtdBlock from "./gtdBlock";
import resolveEffectiveFolderScope from "./effectiveFolderScope";
import { moveMatrixItem } from "./matrixMutation";
import { isMatrixDestination } from "./moveProtocol";
import parseMatrixConfig from "./parseMatrixConfig";
import resolveNotebook from "./resolveNotebook";
import {
DataAdapter,
MoveCardResult,
MoveMatrixCardIntent,
MutationAdapter,
RawNote,
RawTag,
} from "./types";
export interface MatrixMoveHost {
id: string;
parentId: string;
}
class StaleMatrixMoveError extends Error {}
function result(
status: "stale" | "error",
viewInstanceId: string,
message: string
): MoveCardResult {
return { status, viewInstanceId, message };
}
function validId(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validateFreshEligibility(
note: RawNote,
tags: RawTag[],
host: MatrixMoveHost,
folderIds: Set<string>,
config: ReturnType<typeof parseMatrixConfig>
): void {
if (note.id === host.id) throw new StaleMatrixMoveError("The dashboard note cannot be moved.");
if (!folderIds.has(note.parent_id)) throw new StaleMatrixMoveError("The card is no longer in scope.");
if (!note.is_todo && config.notes === "none") {
throw new StaleMatrixMoveError("The card is no longer admitted by notes.");
}
const block = extractGtdBlock(note.body);
if (note.is_todo) {
if (config.todos === "none") throw new StaleMatrixMoveError("The card is no longer admitted by todos.");
if (config.todos === "gtd-only" && !block.found) {
throw new StaleMatrixMoveError("The to-do no longer contains a gtd block.");
}
if (note.todo_completed > 0) {
throw new StaleMatrixMoveError("The to-do is now complete and no longer belongs in the matrix.");
}
} else {
if (!block.found) throw new StaleMatrixMoveError("The note no longer contains a gtd block.");
const titles = tags.map((tag) => (tag.title || "").trim().toLowerCase());
if (titles.includes(config.doneTag)) {
throw new StaleMatrixMoveError("The note is now complete and no longer belongs in the matrix.");
}
}
}
export async function validateAndMoveMatrixCard(
intent: unknown,
host: MatrixMoveHost | null,
readAdapter: DataAdapter,
mutationAdapter: MutationAdapter,
logDiagnostic: (error: unknown) => void = () => undefined
): Promise<MoveCardResult> {
const candidate = intent as Partial<MoveMatrixCardIntent> | null;
const viewInstanceId = validId(candidate?.viewInstanceId)
? candidate.viewInstanceId
: "unknown";
const allowedKeys = new Set([
"type", "view", "hostNoteId", "cardId", "destination", "rawConfig", "viewInstanceId",
]);
if (
!candidate || Array.isArray(candidate) ||
Object.keys(candidate).some((key) => !allowedKeys.has(key)) ||
candidate.type !== "moveMatrixCard" || candidate.view !== "matrix" ||
!validId(candidate.hostNoteId) || !validId(candidate.cardId) ||
!validId(candidate.viewInstanceId) || typeof candidate.rawConfig !== "string" ||
!isMatrixDestination(candidate.destination)
) {
return result("error", viewInstanceId, "Invalid matrix move request.");
}
if (!host || candidate.hostNoteId !== host.id) {
return result("stale", viewInstanceId, "The rendered dashboard is no longer current.");
}
let parsed: unknown;
try {
parsed = YAML.parse(candidate.rawConfig || "") || {};
} catch (_parseError) {
return result("stale", viewInstanceId, "The matrix configuration is no longer valid.");
}
const config = parseMatrixConfig(parsed);
if (!config.editable) {
return result("error", viewInstanceId, "This matrix is read-only.");
}
try {
const folders = await readAdapter.getFolders();
let scanFolderId = host.parentId;
if (!config.scopeAll && config.notebook) {
scanFolderId = resolveNotebook(folders, config.notebook, host.parentId).folderId;
}
const folderIds = new Set(resolveEffectiveFolderScope(
folders,
scanFolderId,
config.scopeDepth,
config.scopeAll,
config.excludeNotebooks
).folderIds);
const outcome = await moveMatrixItem(
mutationAdapter,
candidate.cardId,
candidate.destination,
config,
(note, tags) => validateFreshEligibility(note, tags, host, folderIds, config)
);
return { status: "success", viewInstanceId, changed: outcome.changed };
} catch (moveError) {
if (moveError instanceof StaleMatrixMoveError ||
(moveError instanceof Error && moveError.message === "The card no longer exists.")) {
return result("stale", viewInstanceId, (moveError as Error).message);
}
logDiagnostic(moveError);
return result("error", viewInstanceId, "Could not apply the move. Refresh and try again.");
}
}

View File

@ -1,81 +0,0 @@
import { ensureNoteTag, removeNoteTag } from "./kanbanMutation";
import {
MatrixDestination,
MatrixMode,
MutationAdapter,
RawNote,
} from "./types";
export interface MatrixMutationConfig {
mode: MatrixMode;
urgentTag: string;
importantTag: string;
inProgressTag: string;
onDeckTag: string;
doneTag: string;
}
export interface MatrixMutationOutcome {
note: RawNote;
changed: boolean;
}
const TARGETS: Record<Exclude<MatrixDestination, "done">, { urgent: boolean; important: boolean }> = {
topLeft: { urgent: true, important: true },
topRight: { urgent: false, important: true },
bottomLeft: { urgent: true, important: false },
bottomRight: { urgent: false, important: false },
};
/** Apply the fixed Eisenhower tag state without changing any note fields. */
export async function moveMatrixItem(
adapter: MutationAdapter,
noteId: string,
destination: MatrixDestination,
config: MatrixMutationConfig,
validateFreshState?: (note: RawNote, tags: Array<{ id: string; title: string }>) => void
): Promise<MatrixMutationOutcome> {
const note = await adapter.getNote(noteId);
if (!note) throw new Error("The card no longer exists.");
const noteTags = await adapter.getNoteTags(noteId);
if (validateFreshState) validateFreshState(note, noteTags);
let changed = false;
if (destination === "done") {
if (note.is_todo) {
if (note.todo_completed <= 0) {
await adapter.setTodoCompleted(noteId, Date.now());
changed = true;
}
} else {
changed = (await ensureNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
}
changed =
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
return { note, changed };
}
const target = TARGETS[destination];
if (config.mode === "skeleton") {
const targetTag: Record<Exclude<MatrixDestination, "done">, string | null> = {
topLeft: config.urgentTag,
topRight: config.inProgressTag,
bottomLeft: config.onDeckTag,
bottomRight: null,
};
for (const tag of [config.urgentTag, config.inProgressTag, config.onDeckTag]) {
changed = tag === targetTag[destination]
? (await ensureNoteTag(adapter, noteId, tag, noteTags)) || changed
: (await removeNoteTag(adapter, noteId, tag, noteTags)) || changed;
}
return { note, changed };
}
changed = target.urgent
? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed
: (await removeNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed;
changed = target.important
? (await ensureNoteTag(adapter, noteId, config.importantTag, noteTags)) || changed
: (await removeNoteTag(adapter, noteId, config.importantTag, noteTags)) || changed;
return { note, changed };
}

View File

@ -1,24 +0,0 @@
import { KanbanDestination, MatrixDestination } 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);
}
const MATRIX_DESTINATIONS: MatrixDestination[] = [
"topLeft",
"topRight",
"bottomLeft",
"bottomRight",
"done",
];
export function isMatrixDestination(value: unknown): value is MatrixDestination {
return MATRIX_DESTINATIONS.includes(value as MatrixDestination);
}

View File

@ -1,8 +1,5 @@
import { CalendarConfig, InclusionMode } from "./types";
import {
parseExcludeNotebookOption,
parseNotebookOption,
} from "./resolveNotebook";
import { parseNotebookOption } from "./resolveNotebook";
const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
day: "day",
@ -31,7 +28,6 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
"title",
"scope",
"notebook",
"exclude-notebook",
"notes",
"todos",
"sort",
@ -81,10 +77,6 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
// notebook (id / title / Parent/Child path); resolved later against the
// folder tree. Empty → null (host folder). Ignored under scope: all.
const notebook = parseNotebookOption(input.notebook);
const excludeNotebooks = parseExcludeNotebookOption(
input["exclude-notebook"],
warnings
);
if (scopeAll && notebook) {
warnings.push(
`"notebook" is ignored when scope: all (scanning every notebook)`
@ -137,7 +129,6 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
scopeDepth,
scopeAll,
notebook,
excludeNotebooks,
notes,
todos,
sort,

View File

@ -6,10 +6,7 @@ import {
KanbanSortType,
NoteInclusionMode,
} from "./types";
import {
parseExcludeNotebookOption,
parseNotebookOption,
} from "./resolveNotebook";
import { parseNotebookOption } from "./resolveNotebook";
import parsePageSize from "./pageSize";
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
@ -29,7 +26,6 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
"scope",
"group",
"notebook",
"exclude-notebook",
"notes",
"todos",
"sort-type",
@ -38,7 +34,6 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
"done-tag",
"card-detail",
"page-size",
"editable",
"done-window",
];
for (const key of Object.keys(input)) {
@ -85,10 +80,6 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
}
const notebook = parseNotebookOption(input.notebook);
const excludeNotebooks = parseExcludeNotebookOption(
input["exclude-notebook"],
warnings
);
if (scopeAll && notebook) {
warnings.push(
`"notebook" is ignored when scope: all (scanning every notebook)`
@ -194,26 +185,12 @@ 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,
scopeAll,
groupByNotebook,
notebook,
excludeNotebooks,
notes,
todos,
sortType,
@ -222,7 +199,6 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
doneTag,
cardDetail,
pageSize,
editable,
doneWindow,
warnings,
};

View File

@ -5,15 +5,11 @@ import {
MatrixMode,
URGENT_TAG,
IMPORTANT_TAG,
ON_DECK_TAG,
IN_PROGRESS_TAG,
DONE_TAG,
NoteInclusionMode,
} from "./types";
import {
parseExcludeNotebookOption,
parseNotebookOption,
} from "./resolveNotebook";
import { parseNotebookOption } from "./resolveNotebook";
import parsePageSize from "./pageSize";
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
@ -34,19 +30,17 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
"scope",
"group",
"notebook",
"exclude-notebook",
"notes",
"todos",
"sort-type",
"sort",
"urgent-tag",
"important-tag",
"on-deck-tag",
"in-progress-tag",
"done-tag",
"urgent-window",
"card-detail",
"page-size",
"editable",
];
for (const key of Object.keys(input)) {
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
@ -103,10 +97,6 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
}
const notebook = parseNotebookOption(input.notebook);
const excludeNotebooks = parseExcludeNotebookOption(
input["exclude-notebook"],
warnings
);
if (scopeAll && notebook) {
warnings.push(
`"notebook" is ignored when scope: all (scanning every notebook)`
@ -164,14 +154,13 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
// axis tags (configurable; lowercased for tag comparison)
const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG);
const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG);
const onDeckTag = parseTag(input["on-deck-tag"], ON_DECK_TAG);
if (urgentTag === importantTag) {
warnings.push(
`urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate`
);
}
// skeleton workflow tags
// skeleton mode: in-progress tag + urgent window
const inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG);
const doneTag = parseTag(input["done-tag"], DONE_TAG);
if (doneTag === inProgressTag) {
@ -180,6 +169,16 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
);
}
let urgentWindow = 3;
if (input["urgent-window"] !== undefined) {
const value = input["urgent-window"];
if (Number.isInteger(Number(value)) && Number(value) >= 0) {
urgentWindow = Number(value);
} else {
warnings.push(`Invalid urgent-window "${value}" (using 3)`);
}
}
// card-detail
let cardDetail: MatrixConfig["cardDetail"] = "hover";
if (input["card-detail"] !== undefined) {
@ -195,25 +194,6 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
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")`);
}
}
const editableTags = mode === "skeleton"
? [urgentTag, inProgressTag, onDeckTag]
: [urgentTag, importantTag];
if (editable && new Set(editableTags).size !== editableTags.length) {
warnings.push(`editable ${mode === "skeleton" ? "Skeleton workflow" : "Eisenhower axis"} tags must be distinct (editing disabled)`);
editable = false;
}
return {
mode,
title,
@ -221,19 +201,17 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
scopeAll,
groupByNotebook,
notebook,
excludeNotebooks,
notes,
todos,
sortType,
sort,
urgentTag,
importantTag,
onDeckTag,
inProgressTag,
doneTag,
urgentWindow,
cardDetail,
pageSize,
editable,
warnings,
};
}

View File

@ -7,11 +7,6 @@ export interface ResolvedNotebook {
warning: string | null;
}
export interface NotebookReferenceResolution {
folderId: string | null;
problem: string | null;
}
const HEX_ID = /^[0-9a-f]{32}$/i;
/**
@ -24,79 +19,6 @@ export function parseNotebookOption(value: any): string | null {
return trimmed.length > 0 ? trimmed : null;
}
/** Normalize scalar/list `exclude-notebook:` input while retaining valid entries. */
export function parseExcludeNotebookOption(
value: any,
warnings: string[]
): string[] {
if (value === undefined || value === null) return [];
const values = Array.isArray(value) ? value : [value];
const result: string[] = [];
for (let index = 0; index < values.length; index += 1) {
const entry = values[index];
if (
typeof entry !== "string" &&
typeof entry !== "number" &&
typeof entry !== "boolean"
) {
warnings.push(
`Invalid exclude-notebook entry ${index + 1} (expected a notebook name, path, or id)`
);
continue;
}
const trimmed = String(entry).trim();
if (!trimmed) {
warnings.push(`Invalid exclude-notebook entry ${index + 1} (empty value ignored)`);
continue;
}
result.push(trimmed);
}
return result;
}
/** Resolve a notebook reference without applying the positive-root fallback. */
export function resolveNotebookReference(
folders: RawFolder[],
spec: string
): NotebookReferenceResolution {
const trimmed = (spec || "").trim();
if (!trimmed) return { folderId: null, problem: "is empty" };
if (HEX_ID.test(trimmed)) {
const match = folders.find((f) => f.id.toLowerCase() === trimmed.toLowerCase());
return match
? { folderId: match.id, problem: null }
: { folderId: null, problem: `no notebook with id "${trimmed}"` };
}
const segments = trimmed
.split("/")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
if (segments.length > 1) {
const resolved = resolvePath(folders, segments);
if (resolved.length === 1) return { folderId: resolved[0], problem: null };
return resolved.length === 0
? { folderId: null, problem: `path "${trimmed}" not found` }
: {
folderId: null,
problem: `path "${trimmed}" is ambiguous (${resolved.length} matches)`,
};
}
const wanted = trimmed.toLowerCase();
const matches = folders.filter((folder) =>
(folder.title || "").toLowerCase() === wanted
);
if (matches.length === 1) return { folderId: matches[0].id, problem: null };
return matches.length === 0
? { folderId: null, problem: `no notebook named "${trimmed}"` }
: {
folderId: null,
problem: `"${trimmed}" is ambiguous (${matches.length} notebooks share that name — qualify it as a Parent/Child path)`,
};
}
/**
* Resolve a `notebook:` option to a folder id, per SPEC.md §2.
*
@ -116,11 +38,52 @@ export default function resolveNotebook(
): ResolvedNotebook {
const trimmed = (spec || "").trim();
if (!trimmed) return { folderId: hostFolderId, warning: null };
const resolution = resolveNotebookReference(folders, trimmed);
if (resolution.folderId) return { folderId: resolution.folderId, warning: null };
// 1. Raw folder id.
if (HEX_ID.test(trimmed)) {
const match = folders.find((f) => f.id.toLowerCase() === trimmed.toLowerCase());
if (match) return { folderId: match.id, warning: null };
return {
folderId: hostFolderId,
warning: `notebook: no notebook with id "${trimmed}" (using this note's folder)`,
};
}
// 2. Path (Parent/Child/...). A single segment with no slash falls through
// to the bare-title branch below.
const segments = trimmed
.split("/")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (segments.length > 1) {
const resolved = resolvePath(folders, segments);
if (resolved.length === 1) return { folderId: resolved[0], warning: null };
if (resolved.length === 0) {
return {
folderId: hostFolderId,
warning: `notebook: path "${trimmed}" not found (using this note's folder)`,
};
}
return {
folderId: hostFolderId,
warning: `notebook: path "${trimmed}" is ambiguous (${resolved.length} matches — using this note's folder)`,
};
}
// 3. Bare title — anywhere in the tree, if unique.
const wanted = trimmed.toLowerCase();
const matches = folders.filter((f) => (f.title || "").toLowerCase() === wanted);
if (matches.length === 1) return { folderId: matches[0].id, warning: null };
if (matches.length === 0) {
return {
folderId: hostFolderId,
warning: `notebook: no notebook named "${trimmed}" (using this note's folder)`,
};
}
return {
folderId: hostFolderId,
warning: `notebook: ${resolution.problem} (using this note's folder)`,
warning: `notebook: "${trimmed}" is ambiguous (${matches.length} notebooks share that name — qualify it as a Parent/Child path; using this note's folder)`,
};
}

View File

@ -15,8 +15,6 @@ export interface CalendarConfig {
* Ignored when `scopeAll` is true.
*/
notebook: string | null;
/** Notebook roots whose complete descendant trees are removed from scope. */
excludeNotebooks: string[];
notes: InclusionMode;
todos: InclusionMode;
sort: "asc" | "desc";
@ -105,22 +103,6 @@ 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.
@ -153,8 +135,6 @@ export interface KanbanConfig {
groupByNotebook: boolean;
/** Notebook to root the scan at (id/title/path); null = host folder. */
notebook: string | null;
/** Notebook roots whose complete descendant trees are removed from scope. */
excludeNotebooks: string[];
notes: NoteInclusionMode;
todos: InclusionMode;
sortType: KanbanSortType;
@ -165,32 +145,11 @@ 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;
@ -235,7 +194,6 @@ export type KanbanLayout =
*/
export const URGENT_TAG = "urgent";
export const IMPORTANT_TAG = "important";
export const ON_DECK_TAG = "on-deck";
export type MatrixMode = "skeleton" | "eisenhower";
@ -250,52 +208,31 @@ export interface MatrixConfig {
groupByNotebook: boolean;
/** Notebook to root the scan at (id/title/path); null = host folder. */
notebook: string | null;
/** Notebook roots whose complete descendant trees are removed from scope. */
excludeNotebooks: string[];
notes: NoteInclusionMode;
todos: InclusionMode;
sortType: KanbanSortType;
sort: "asc" | "desc";
urgentTag: string;
importantTag: string;
/** Skeleton mode: tag marking work queued to start next. */
onDeckTag: string;
/** Skeleton mode: tag marking active work (shared with the kanban). */
inProgressTag: string;
/** Tag that marks an opted-in ordinary note complete. */
doneTag: string;
/** Skeleton mode: days ahead within which a due date counts as "due soon". */
urgentWindow: number;
cardDetail: "hover" | "always" | "none";
/** Cards initially shown, and revealed per "List more" action. */
pageSize: number;
/** Effective editable state; requires distinct workflow/axis tags. */
editable: boolean;
warnings: string[];
}
export type MatrixDestination =
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight"
| "done";
export interface MoveMatrixCardIntent {
type: "moveMatrixCard";
view: "matrix";
hostNoteId: string;
cardId: string;
destination: MatrixDestination;
rawConfig: string;
viewInstanceId: string;
}
/**
* The four matrix quadrants, positional (row x column):
* topLeft topRight
* bottomLeft bottomRight
*
* The modes share positional destinations while their labels and tag semantics
* remain mode-specific.
* Eisenhower: Do First / Schedule / Delegate / Eliminate.
* Skeleton: Do Next / Scheduled / On Deck / Backlog.
*/
export interface MatrixBoard {
topLeft: KanbanCard[];

View File

@ -389,7 +389,6 @@
}
.gtd-kanban-card {
position: relative;
border: 1px solid rgba(128, 128, 128, 0.4);
border-radius: 4px;
padding: 0.35em 0.5em;
@ -400,134 +399,6 @@
font-size: 0.85em;
}
.gtd-kanban-card-controls {
position: absolute;
top: 0.2em;
right: 0.25em;
z-index: 4;
display: flex;
flex-direction: column;
gap: 0.15em;
align-items: stretch;
}
.gtd-kanban-drag-handle,
.gtd-kanban-move-disclosure {
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-move-disclosure {
height: 1.05em;
cursor: pointer;
}
.gtd-kanban-drag-handle:active {
cursor: grabbing;
}
.gtd-kanban-card-editable {
min-height: 3.2em;
padding-right: 2.4em;
}
.gtd-kanban-card-editable:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.gtd-kanban-move-disclosure:focus-visible,
.gtd-kanban-move-menu-item:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.gtd-kanban-move-menu {
position: absolute;
top: calc(100% + 0.2em);
right: 0;
z-index: 30;
min-width: 9em;
padding: 0.25em;
border: 1px solid rgba(128, 128, 128, 0.5);
border-radius: 4px;
background-color: var(--joplin-background-color, white);
color: var(--joplin-color, black);
box-shadow: 0 3px 9px rgba(0, 0, 0, 0.28);
}
.gtd-kanban-move-menu-item {
display: block;
width: 100%;
padding: 0.3em 0.45em;
border: 0;
border-radius: 2px;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.86em;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.gtd-kanban-move-menu-item:hover:not(:disabled),
.gtd-kanban-move-menu-item:focus-visible {
background-color: rgba(128, 128, 128, 0.2);
}
.gtd-kanban-move-menu-item:disabled {
opacity: 0.45;
cursor: default;
}
.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-matrix-drop-target {
outline: 1px dashed rgba(80, 120, 200, 0.8);
outline-offset: -3px;
}
.gtd-matrix-drop-active {
outline: 3px solid rgba(50, 120, 220, 0.95);
background-color: rgba(50, 120, 220, 0.12);
}
.gtd-kanban-pending .gtd-kanban-drag-handle,
.gtd-kanban-pending .gtd-kanban-move-disclosure,
.gtd-matrix-pending .gtd-kanban-drag-handle,
.gtd-matrix-pending .gtd-kanban-move-disclosure {
pointer-events: none;
opacity: 0.45;
}
.gtd-kanban-move-status,
.gtd-matrix-move-status {
margin: 0.25em 0;
font-size: 0.85em;
}
.gtd-kanban-card:hover {
border-color: rgba(20, 20, 20, 0.9);
}

View File

@ -18,7 +18,7 @@
".gtd-calendar-placeholder:not([data-gtd-processed])"
);
placeholders.forEach(function (el, placeholderIndex) {
placeholders.forEach(function (el) {
el.setAttribute("data-gtd-processed", "true");
const contentScriptId = el.getAttribute("data-content-script-id");
@ -26,10 +26,6 @@
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" ||
@ -52,19 +48,10 @@
? "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) {
@ -72,9 +59,9 @@
return;
}
if (blockType === "kanban") {
renderKanban(el, payload, contentScriptId, renderContext);
renderKanban(el, payload, contentScriptId);
} else if (blockType === "matrix") {
renderMatrix(el, payload, contentScriptId, renderContext);
renderMatrix(el, payload, contentScriptId);
} else if (blockType === "gantt") {
renderGantt(el, payload, contentScriptId);
} else {
@ -478,11 +465,7 @@
// Kanban board (gtd-kanban block)
// ------------------------------------------------------------------
function renderKanban(el, payload, contentScriptId, renderContext) {
if (renderContext && renderContext.openMoveMenu) {
renderContext.openMoveMenu.close(false);
renderContext.openMoveMenu = null;
}
function renderKanban(el, payload, contentScriptId) {
el.innerHTML = "";
const wrapper = document.createElement("div");
@ -520,37 +503,17 @@
};
const detail = payload.cardDetail || "hover";
const pageSize = payload.pageSize || 10;
const interaction = payload.editable && payload.hostNoteId && renderContext
? {
view: "kanban",
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, interaction)
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId)
);
});
} else {
const board = layout.kind === "single" ? layout.board : emptyBoard;
wrapper.appendChild(
renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction)
renderKanbanBoard(board, pageSize, detail, contentScriptId)
);
}
@ -569,18 +532,9 @@
}
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, interaction) {
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) {
const section = document.createElement("section");
section.className = "gtd-notebook-view";
const heading = document.createElement("h4");
@ -589,43 +543,37 @@
section.appendChild(heading);
renderViewWarnings(section, group.warnings);
section.appendChild(
renderKanbanBoard(group.board, pageSize, detail, contentScriptId, interaction)
renderKanbanBoard(group.board, pageSize, detail, contentScriptId)
);
return section;
}
function renderKanbanBoard(board, pageSize, detail, contentScriptId, interaction) {
function renderKanbanBoard(board, pageSize, detail, contentScriptId) {
const columns = document.createElement("div");
columns.className = "gtd-kanban-columns";
const boardInteraction = interaction
? Object.assign({}, interaction, { board: columns })
: null;
columns.appendChild(
renderColumn("Backlog", "backlog", board.backlog, pageSize, detail, contentScriptId, boardInteraction)
renderColumn("Backlog", board.backlog, pageSize, detail, contentScriptId)
);
columns.appendChild(
renderColumn(
"In Progress",
"inProgress",
board.inProgress,
pageSize,
detail,
contentScriptId,
boardInteraction
contentScriptId
)
);
columns.appendChild(
renderColumn("Done", "done", board.done, pageSize, detail, contentScriptId, boardInteraction)
renderColumn("Done", board.done, pageSize, detail, contentScriptId)
);
return columns;
}
function renderColumn(title, destination, cards, pageSize, detail, contentScriptId, interaction) {
function renderColumn(title, cards, pageSize, detail, contentScriptId) {
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";
@ -633,14 +581,14 @@
column.appendChild(header);
column.appendChild(
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
);
return column;
}
let nextListMoreTooltipId = 1;
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination) {
function renderIncrementalCardList(cards, pageSize, detail, contentScriptId) {
const list = document.createElement("div");
list.className = "gtd-kanban-cards";
@ -685,7 +633,7 @@
cards.length
);
for (let index = visibleCount; index < nextVisibleCount; index += 1) {
const card = renderCard(cards[index], detail, contentScriptId, interaction, destination);
const card = renderCard(cards[index], detail, contentScriptId);
if (moreControl) list.insertBefore(card, moreControl);
else list.appendChild(card);
}
@ -712,55 +660,12 @@
return list;
}
function renderCard(card, detail, contentScriptId, interaction, sourceDestination) {
function renderCard(card, detail, contentScriptId) {
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 controls = document.createElement("div");
controls.className = "gtd-kanban-card-controls";
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();
});
(interaction.view === "matrix" ? installMatrixPointerDrag : installKanbanPointerDrag)(
handle,
el,
card,
sourceDestination,
interaction,
function () { suppressOpenUntil = Date.now() + 500; }
);
controls.appendChild(handle);
const disclosure = (interaction.view === "matrix" ? renderMatrixMoveMenu : renderKanbanMoveMenu)(
card,
sourceDestination,
interaction,
controls
);
el.appendChild(controls);
if (interaction.context.restoreFocusCardId === card.id) {
setTimeout(function () {
if (!document.contains(disclosure)) return;
disclosure.focus();
interaction.context.restoreFocusCardId = null;
}, 0);
}
}
const titleRow = document.createElement("div");
titleRow.className = "gtd-kanban-card-title";
@ -793,517 +698,12 @@
}
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;
}
let nextKanbanMoveMenuId = 1;
function renderKanbanMoveMenu(card, source, interaction, controls) {
const context = interaction.context;
const menuId = "gtd-kanban-move-menu-" + nextKanbanMoveMenuId++;
const disclosure = document.createElement("button");
disclosure.type = "button";
disclosure.className = "gtd-kanban-move-disclosure";
disclosure.textContent = "▾";
disclosure.disabled = context.pending;
disclosure.setAttribute("aria-label", "Move “" + card.title + "” to…");
disclosure.setAttribute("aria-expanded", "false");
disclosure.setAttribute("aria-controls", menuId);
disclosure.setAttribute("aria-haspopup", "menu");
const menu = document.createElement("div");
menu.id = menuId;
menu.className = "gtd-kanban-move-menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
const enabledItems = [];
[
["backlog", "Backlog"],
["inProgress", "In Progress"],
["done", "Done"],
].forEach(function (entry) {
const item = document.createElement("button");
item.type = "button";
item.className = "gtd-kanban-move-menu-item";
item.setAttribute("role", "menuitem");
item.setAttribute("data-kanban-destination", entry[0]);
item.setAttribute("data-current-destination", entry[0] === source ? "true" : "false");
item.textContent = entry[1];
item.disabled = entry[0] === source || context.pending;
if (!item.disabled) enabledItems.push(item);
item.addEventListener("click", function (event) {
event.stopPropagation();
if (item.disabled || context.pending) return;
close(false);
context.restoreFocusCardId = card.id;
submitKanbanMove(card, entry[0], interaction);
});
menu.appendChild(item);
});
function outsidePointer(event) {
if (!controls.contains(event.target)) close(false);
}
function close(restoreFocus) {
if (menu.hidden) return;
menu.hidden = true;
disclosure.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", outsidePointer);
if (context.openMoveMenu && context.openMoveMenu.menu === menu) {
context.openMoveMenu = null;
}
if (restoreFocus && document.contains(disclosure)) disclosure.focus();
}
function open() {
if (context.pending || !menu.hidden) return;
if (context.openMoveMenu) context.openMoveMenu.close(false);
menu.hidden = false;
disclosure.setAttribute("aria-expanded", "true");
context.openMoveMenu = { menu: menu, close: close };
document.addEventListener("pointerdown", outsidePointer);
if (enabledItems.length > 0) enabledItems[0].focus();
}
disclosure.addEventListener("click", function (event) {
event.stopPropagation();
if (menu.hidden) open();
else close(true);
});
disclosure.addEventListener("keydown", function (event) {
event.stopPropagation();
if (event.key === "ArrowDown") {
event.preventDefault();
open();
}
});
menu.addEventListener("click", function (event) { event.stopPropagation(); });
menu.addEventListener("keydown", function (event) {
event.stopPropagation();
const index = enabledItems.indexOf(document.activeElement);
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
const next = (index + delta + enabledItems.length) % enabledItems.length;
enabledItems[next].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
enabledItems[event.key === "Home" ? 0 : enabledItems.length - 1].focus();
} else if (event.key === "Tab") {
close(false);
}
});
controls.appendChild(disclosure);
controls.appendChild(menu);
return disclosure;
}
function renderMatrixMoveMenu(card, source, interaction, controls) {
const context = interaction.context;
const menuId = "gtd-matrix-move-menu-" + nextKanbanMoveMenuId++;
const disclosure = document.createElement("button");
disclosure.type = "button";
disclosure.className = "gtd-kanban-move-disclosure";
disclosure.textContent = "▾";
disclosure.disabled = context.pending;
disclosure.setAttribute("aria-label", "Move “" + card.title + "” to…");
disclosure.setAttribute("aria-expanded", "false");
disclosure.setAttribute("aria-controls", menuId);
disclosure.setAttribute("aria-haspopup", "menu");
const menu = document.createElement("div");
menu.id = menuId;
menu.className = "gtd-kanban-move-menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
const enabledItems = [];
[
["topLeft", interaction.labels.topLeft],
["topRight", interaction.labels.topRight],
["bottomLeft", interaction.labels.bottomLeft],
["bottomRight", interaction.labels.bottomRight],
["done", "Done"],
].forEach(function (entry) {
const item = document.createElement("button");
item.type = "button";
item.className = "gtd-kanban-move-menu-item";
item.setAttribute("role", "menuitem");
item.setAttribute("data-matrix-destination", entry[0]);
item.setAttribute("data-current-destination", entry[0] === source ? "true" : "false");
item.textContent = entry[1];
item.disabled = entry[0] === source || context.pending;
if (!item.disabled) enabledItems.push(item);
item.addEventListener("click", function (event) {
event.stopPropagation();
if (item.disabled || context.pending) return;
close(false);
context.restoreFocusCardId = card.id;
submitMatrixMove(card, entry[0], interaction);
});
menu.appendChild(item);
});
function outsidePointer(event) {
if (!controls.contains(event.target)) close(false);
}
function close(restoreFocus) {
if (menu.hidden) return;
menu.hidden = true;
disclosure.setAttribute("aria-expanded", "false");
document.removeEventListener("pointerdown", outsidePointer);
if (context.openMoveMenu && context.openMoveMenu.menu === menu) context.openMoveMenu = null;
if (restoreFocus && document.contains(disclosure)) disclosure.focus();
}
function open() {
if (context.pending || !menu.hidden) return;
if (context.openMoveMenu) context.openMoveMenu.close(false);
menu.hidden = false;
disclosure.setAttribute("aria-expanded", "true");
context.openMoveMenu = { menu: menu, close: close };
document.addEventListener("pointerdown", outsidePointer);
if (enabledItems.length > 0) enabledItems[0].focus();
}
disclosure.addEventListener("click", function (event) {
event.stopPropagation();
if (menu.hidden) open(); else close(true);
});
disclosure.addEventListener("keydown", function (event) {
event.stopPropagation();
if (event.key === "ArrowDown") { event.preventDefault(); open(); }
});
menu.addEventListener("click", function (event) { event.stopPropagation(); });
menu.addEventListener("keydown", function (event) {
event.stopPropagation();
const index = enabledItems.indexOf(document.activeElement);
if (event.key === "Escape") { event.preventDefault(); close(true); }
else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
enabledItems[(index + delta + enabledItems.length) % enabledItems.length].focus();
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
enabledItems[event.key === "Home" ? 0 : enabledItems.length - 1].focus();
} else if (event.key === "Tab") close(false);
});
controls.appendChild(disclosure);
controls.appendChild(menu);
return disclosure;
}
function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
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 installMatrixPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
let state = null;
const threshold = 6;
function clearTargets() {
interaction.board.querySelectorAll(".gtd-matrix-drop-target, .gtd-matrix-drop-active")
.forEach(function (quadrant) {
quadrant.classList.remove("gtd-matrix-drop-target", "gtd-matrix-drop-active");
});
}
function cancel(event) {
if (!state) return;
if (event && event.pointerId !== undefined && event.pointerId !== state.pointerId) return;
clearTargets();
cardEl.classList.remove("gtd-kanban-card-dragging");
document.removeEventListener("keydown", onKeyDown);
state = null;
}
function onKeyDown(event) {
if (event.key === "Escape" && state) { suppressOpen(); cancel(); }
}
handle.addEventListener("pointerdown", function (event) {
if (interaction.context.pending || event.button !== 0) return;
state = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dragging: false, target: null };
handle.setPointerCapture(event.pointerId);
document.addEventListener("keydown", onKeyDown);
event.stopPropagation();
});
handle.addEventListener("pointermove", function (event) {
if (!state || event.pointerId !== state.pointerId) return;
const distance = Math.hypot(event.clientX - state.x, event.clientY - state.y);
if (!state.dragging && distance < threshold) return;
if (!state.dragging) {
state.dragging = true;
suppressOpen();
cardEl.classList.add("gtd-kanban-card-dragging");
interaction.board.querySelectorAll(".gtd-matrix-quadrant").forEach(function (quadrant) {
if (quadrant.getAttribute("data-matrix-destination") !== source) quadrant.classList.add("gtd-matrix-drop-target");
});
}
interaction.board.querySelectorAll(".gtd-matrix-drop-active").forEach(function (quadrant) {
quadrant.classList.remove("gtd-matrix-drop-active");
});
const hit = document.elementFromPoint(event.clientX, event.clientY);
const target = hit && hit.closest ? hit.closest(".gtd-matrix-quadrant") : null;
state.target = target && interaction.board.contains(target) &&
target.getAttribute("data-matrix-destination") !== source ? target : null;
if (state.target) state.target.classList.add("gtd-matrix-drop-active");
if (event.clientY < 40) window.scrollBy(0, -16);
else if (event.clientY > window.innerHeight - 40) window.scrollBy(0, 16);
event.preventDefault();
});
handle.addEventListener("pointerup", function (event) {
if (!state || event.pointerId !== state.pointerId) return;
const destination = state.target && state.target.getAttribute("data-matrix-destination");
const shouldMove = state.dragging && destination && destination !== source;
cancel(event);
if (shouldMove) submitMatrixMove(card, destination, interaction);
});
handle.addEventListener("pointercancel", cancel);
handle.addEventListener("lostpointercapture", cancel);
}
function submitKanbanMove(card, destination, interaction) {
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");
if (context.openMoveMenu) context.openMoveMenu.close(false);
interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = true; });
webviewApi.postMessage(context.contentScriptId, {
type: "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-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) {
control.disabled = control.getAttribute("data-current-destination") === "true";
});
});
}
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 submitMatrixMove(card, destination, interaction) {
const context = interaction.context;
if (context.pending) return;
context.pending = true;
context.statusMessage = "Moving “" + card.title + "”…";
context.statusError = false;
showMatrixStatus(interaction.root, context.statusMessage, false);
interaction.root.classList.add("gtd-matrix-pending");
if (context.openMoveMenu) context.openMoveMenu.close(false);
interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = true; });
webviewApi.postMessage(context.contentScriptId, {
type: "moveMatrixCard",
view: "matrix",
hostNoteId: interaction.hostNoteId,
cardId: card.id,
destination: destination,
rawConfig: context.rawConfig,
viewInstanceId: context.viewInstanceId,
}).then(function (result) {
if (!result || result.viewInstanceId !== context.viewInstanceId) return;
const succeeded = result.status === "success";
context.statusMessage = result.status === "success"
? "Moved “" + card.title + "”."
: result.message || "The move could not be completed.";
context.statusError = result.status !== "success";
return refreshMatrix(interaction.root, context).then(function () {
if (succeeded) scheduleMatrixRefresh(interaction.root, context);
});
}).catch(function () {
context.statusMessage = "The move could not be completed. Refresh and try again.";
context.statusError = true;
showMatrixStatus(interaction.root, context.statusMessage, true);
return refreshMatrix(interaction.root, context);
}).finally(function () {
context.pending = false;
interaction.root.classList.remove("gtd-matrix-pending");
interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) {
control.disabled = control.getAttribute("data-current-destination") === "true";
});
});
}
function showMatrixStatus(root, message, isError) {
let status = root.querySelector(".gtd-matrix-move-status");
if (!status) {
status = document.createElement("p");
const wrapper = root.querySelector(".gtd-matrix");
if (wrapper) wrapper.insertBefore(status, wrapper.firstChild);
}
status.className = isError
? "gtd-matrix-move-status gtd-calendar-error"
: "gtd-matrix-move-status";
status.textContent = message;
status.setAttribute("role", "status");
status.setAttribute("aria-live", isError ? "assertive" : "polite");
status.setAttribute("aria-atomic", "true");
}
function refreshMatrix(root, context) {
const sequence = ++context.requestSequence;
return webviewApi.postMessage(context.contentScriptId, {
type: "getMatrix",
rawConfig: context.rawConfig,
viewInstanceId: context.viewInstanceId,
}).then(function (payload) {
if (sequence !== context.requestSequence || !payload ||
payload.viewInstanceId !== context.viewInstanceId) return;
renderMatrix(root, payload, context.contentScriptId, context);
});
}
// Joplin can briefly return the pre-mutation folder/tag state immediately
// after a successful write. Follow the responsive first refresh with one
// settled refresh so moved and completed cards reliably leave their old slot.
function scheduleMatrixRefresh(root, context) {
if (context.postMoveRefreshTimer) clearTimeout(context.postMoveRefreshTimer);
context.postMoveRefreshTimer = setTimeout(function () {
context.postMoveRefreshTimer = null;
refreshMatrix(root, context).catch(function () {
// The move already succeeded and the first refresh already rendered;
// a transient follow-up read failure should not turn it into an error.
});
}, 400);
}
function formatCardDate(dateISO) {
const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, {
@ -1316,11 +716,7 @@
// Eisenhower matrix (gtd-matrix block)
// ------------------------------------------------------------------
function renderMatrix(el, payload, contentScriptId, renderContext) {
if (renderContext && renderContext.openMoveMenu) {
renderContext.openMoveMenu.close(false);
renderContext.openMoveMenu = null;
}
function renderMatrix(el, payload, contentScriptId) {
el.innerHTML = "";
const wrapper = document.createElement("div");
@ -1358,38 +754,17 @@
board: payload.board || emptyBoard,
};
const labels = payload.labels || {
columns: ["Priority", "Current / backlog"],
rows: ["Now", "Next / later"],
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Urgent",
topRight: "In Progress",
bottomLeft: "On Deck",
bottomRight: "Backlog",
topLeft: "Do First",
topRight: "Schedule",
bottomLeft: "Delegate",
bottomRight: "Eliminate",
},
};
const detail = payload.cardDetail || "hover";
const pageSize = payload.pageSize || 10;
const interaction = payload.editable && payload.hostNoteId && renderContext
? {
view: "matrix",
context: renderContext,
hostNoteId: payload.hostNoteId,
root: el,
labels: labels.quadrants,
}
: null;
if (renderContext && renderContext.statusMessage) {
const status = document.createElement("p");
status.className = renderContext.statusError
? "gtd-matrix-move-status gtd-calendar-error"
: "gtd-matrix-move-status";
status.textContent = renderContext.statusMessage;
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
status.setAttribute("aria-atomic", "true");
wrapper.appendChild(status);
}
if (layout.kind === "notebooks" && layout.groups.length > 0) {
layout.groups.forEach(function (group) {
@ -1399,8 +774,7 @@
labels,
pageSize,
detail,
contentScriptId,
interaction
contentScriptId
)
);
});
@ -1412,8 +786,7 @@
labels,
pageSize,
detail,
contentScriptId,
interaction
contentScriptId
)
);
}
@ -1433,15 +806,6 @@
}
el.appendChild(wrapper);
if (renderContext && renderContext.restoreFocusCardId) {
setTimeout(function () {
if (!renderContext.restoreFocusCardId) return;
const fallback = el.querySelector(".gtd-matrix-move-status") || wrapper;
fallback.setAttribute("tabindex", "-1");
fallback.focus();
renderContext.restoreFocusCardId = null;
}, 0);
}
}
function renderNotebookMatrixGroup(
@ -1449,8 +813,7 @@
labels,
pageSize,
detail,
contentScriptId,
interaction
contentScriptId
) {
const section = document.createElement("section");
section.className = "gtd-notebook-view";
@ -1465,8 +828,7 @@
labels,
pageSize,
detail,
contentScriptId,
interaction
contentScriptId
)
);
return section;
@ -1477,15 +839,11 @@
labels,
pageSize,
detail,
contentScriptId,
interaction
contentScriptId
) {
// Axis header row: blank corner + column labels
const grid = document.createElement("div");
grid.className = "gtd-matrix-grid";
const boardInteraction = interaction
? Object.assign({}, interaction, { board: grid })
: null;
grid.appendChild(matrixAxisCell(""));
grid.appendChild(matrixAxisCell(labels.columns[0]));
@ -1499,9 +857,7 @@
"do-first",
pageSize,
detail,
contentScriptId,
boardInteraction,
"topLeft"
contentScriptId
)
);
grid.appendChild(
@ -1511,9 +867,7 @@
"schedule",
pageSize,
detail,
contentScriptId,
boardInteraction,
"topRight"
contentScriptId
)
);
@ -1525,9 +879,7 @@
"delegate",
pageSize,
detail,
contentScriptId,
boardInteraction,
"bottomLeft"
contentScriptId
)
);
grid.appendChild(
@ -1537,9 +889,7 @@
"eliminate",
pageSize,
detail,
contentScriptId,
boardInteraction,
"bottomRight"
contentScriptId
)
);
@ -1568,13 +918,10 @@
kind,
pageSize,
detail,
contentScriptId,
interaction,
destination
contentScriptId
) {
const quadrant = document.createElement("div");
quadrant.className = "gtd-matrix-quadrant gtd-matrix-" + kind;
quadrant.setAttribute("data-matrix-destination", destination);
const header = document.createElement("div");
header.className = "gtd-kanban-column-header";
@ -1582,7 +929,7 @@
quadrant.appendChild(header);
quadrant.appendChild(
renderIncrementalCardList(cards, pageSize, detail, contentScriptId, interaction, destination)
renderIncrementalCardList(cards, pageSize, detail, contentScriptId)
);
return quadrant;
}

View File

@ -13,20 +13,12 @@ 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 { validateAndMoveMatrixCard } from "./Gtd/matrixMoveHandler";
import resolveNotebook from "./Gtd/resolveNotebook";
import {
emptyKanbanLayout,
emptyMatrixLayout,
} from "./Gtd/layoutPayload";
import {
DataAdapter,
MutationAdapter,
RawFolder,
RawNote,
RawTag,
} from "./Gtd/types";
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
@ -69,47 +61,6 @@ 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
@ -183,12 +134,8 @@ joplin.plugins.register({
return handleGetEvents(message);
case "getKanban":
return handleGetKanban(message);
case "moveKanbanCard":
return handleMoveKanbanCard(message);
case "getMatrix":
return handleGetMatrix(message);
case "moveMatrixCard":
return handleMoveMatrixCard(message);
case "getGantt":
return handleGetGantt(message);
case "openNote":
@ -201,49 +148,6 @@ 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 handleMoveMatrixCard(message: unknown) {
let host: { id: string; parentId: string } | null = null;
try {
const note = await joplin.workspace.selectedNote();
if (note) host = { id: note.id, parentId: note.parent_id };
} catch (hostError) {
console.warn("gtd-matrix: could not validate move host", hostError);
}
const moveResult = await validateAndMoveMatrixCard(
message,
host,
joplinAdapter,
joplinMutationAdapter,
(error) => console.warn("gtd-matrix: move failed", error)
);
if (moveResult.status !== "success") {
console.warn("gtd-matrix: move rejected", moveResult.status, moveResult.message);
}
return moveResult;
}
async function handleGetEvents(message: { rawConfig?: string }) {
// 1. Parse the gtd-calendar config.
let rawParsed: any = {};
@ -318,10 +222,7 @@ async function handleGetEvents(message: { rawConfig?: string }) {
};
}
async function handleGetKanban(message: {
rawConfig?: string;
viewInstanceId?: string;
}) {
async function handleGetKanban(message: { rawConfig?: string }) {
let rawParsed: any = {};
let configError: string | null = null;
try {
@ -351,9 +252,6 @@ async function handleGetKanban(message: {
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.",
@ -377,9 +275,6 @@ async function handleGetKanban(message: {
title: config.title,
cardDetail: config.cardDetail,
pageSize: config.pageSize,
editable: config.editable,
hostNoteId: sourceNote.id,
viewInstanceId: message.viewInstanceId || null,
scopeAll: config.scopeAll,
configError,
warnings: [
@ -396,10 +291,7 @@ async function handleGetKanban(message: {
};
}
async function handleGetMatrix(message: {
rawConfig?: string;
viewInstanceId?: string;
}) {
async function handleGetMatrix(message: { rawConfig?: string }) {
let rawParsed: any = {};
let configError: string | null = null;
try {
@ -429,9 +321,6 @@ async function handleGetMatrix(message: {
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 matrix.",
@ -456,9 +345,6 @@ async function handleGetMatrix(message: {
title: config.title,
cardDetail: config.cardDetail,
pageSize: config.pageSize,
editable: config.editable,
hostNoteId: sourceNote.id,
viewInstanceId: message.viewInstanceId || null,
scopeAll: config.scopeAll,
configError,
warnings: [

View File

@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
"app_min_version": "2.7",
"version": "2.2.1",
"version": "1.0.0",
"name": "GTD Calendar",
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
"author": "Victor Wiebe",

View File

@ -1,178 +0,0 @@
import collectEvents from "../../Gtd/collectEvents";
import collectKanban from "../../Gtd/collectKanban";
import collectMatrix from "../../Gtd/collectMatrix";
import resolveEffectiveFolderScope from "../../Gtd/effectiveFolderScope";
import parseCalendarConfig from "../../Gtd/parseCalendarConfig";
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
const folders: RawFolder[] = [
{ id: "work", parent_id: "", title: "Work" },
{ id: "ibm", parent_id: "work", title: "IBM" },
{ id: "ibm-child", parent_id: "ibm", title: "Projects" },
{ id: "archive-work", parent_id: "work", title: "Archive" },
{ id: "personal", parent_id: "", title: "Personal" },
{ id: "archive-personal", parent_id: "personal", title: "Archive" },
];
function note(id: string, parentId: string): RawNote {
return {
id,
title: id,
parent_id: parentId,
is_todo: 1,
todo_due: 0,
todo_completed: 0,
updated_time: 0,
body: "```gtd\n```",
};
}
function adapter(calls: string[]): DataAdapter {
return {
getFolders: async () => folders,
getNotesInFolder: async (folderId) => {
calls.push(folderId);
return [note(`note-${folderId}`, folderId)];
},
getNoteTagTitles: async () => [],
};
}
describe("exclude-notebook config parsing", () => {
test.each([
["calendar", parseCalendarConfig],
["kanban", parseKanbanConfig],
["matrix", parseMatrixConfig],
] as const)("%s accepts omitted, scalar, and list forms", (_name, parse) => {
expect(parse({}).excludeNotebooks).toEqual([]);
expect(parse({ "exclude-notebook": " IBM " }).excludeNotebooks).toEqual([
"IBM",
]);
const config = parse({ "exclude-notebook": [" IBM ", "Archive", "IBM"] });
expect(config.excludeNotebooks).toEqual(["IBM", "Archive", "IBM"]);
expect(config.warnings).toEqual([]);
});
test.each([parseCalendarConfig, parseKanbanConfig, parseMatrixConfig])(
"retains valid list entries and warns in input order",
(parse) => {
const config = parse({
"exclude-notebook": ["IBM", " ", { title: "Archive" }, ["Personal"]],
});
expect(config.excludeNotebooks).toEqual(["IBM"]);
expect(config.warnings).toEqual([
"Invalid exclude-notebook entry 2 (empty value ignored)",
"Invalid exclude-notebook entry 3 (expected a notebook name, path, or id)",
"Invalid exclude-notebook entry 4 (expected a notebook name, path, or id)",
]);
}
);
});
describe("effective notebook scope", () => {
test("subtracts complete, overlapping trees from scope: all", () => {
const result = resolveEffectiveFolderScope(
folders,
"work",
0,
true,
["IBM", "Work/IBM/Projects", "IBM"]
);
expect(result.folderIds).toEqual([
"work",
"archive-work",
"personal",
"archive-personal",
]);
expect([...result.excludedFolderIds].sort()).toEqual(["ibm", "ibm-child"]);
expect(result.warnings).toEqual([]);
});
test("resolves entries independently and warns without fallback", () => {
const result = resolveEffectiveFolderScope(
folders,
"work",
Infinity,
false,
["Archive", "Nope", "Personal"]
);
expect(result.folderIds).toEqual(["work", "ibm", "archive-work", "ibm-child"]);
expect(result.warnings).toEqual([
'exclude-notebook: "Archive" is ambiguous (2 notebooks share that name — qualify it as a Parent/Child path) (nothing excluded)',
'exclude-notebook: no notebook named "Nope" (nothing excluded)',
]);
});
test("supports paths, root exclusion, empty scope, and cycle safety", () => {
expect(
resolveEffectiveFolderScope(folders, "work", Infinity, false, ["Work"]).folderIds
).toEqual([]);
const cyclic: RawFolder[] = [
{ id: "a", parent_id: "b", title: "A" },
{ id: "b", parent_id: "a", title: "B" },
];
expect(
resolveEffectiveFolderScope(cyclic, "a", Infinity, false, ["b"]).folderIds
).toEqual([]);
});
test("accepts a 32-character folder id", () => {
const id = "0123456789abcdef0123456789abcdef";
const idFolders = [
...folders,
{ id, parent_id: "personal", title: "Secret" },
];
const result = resolveEffectiveFolderScope(
idFolders,
"work",
0,
true,
[id.toUpperCase()]
);
expect(result.folderIds).not.toContain(id);
expect(result.warnings).toEqual([]);
});
});
describe("collector exclusion integration", () => {
test.each([
["calendar", async (data: DataAdapter) => collectEvents(data, "host", "work", parseCalendarConfig({ scope: "all", notes: "all", todos: "all", "exclude-notebook": "IBM" }))],
["kanban", async (data: DataAdapter) => collectKanban(data, "host", "work", parseKanbanConfig({ scope: "all", todos: "all", "exclude-notebook": "IBM" }))],
["matrix", async (data: DataAdapter) => collectMatrix(data, "host", "work", parseMatrixConfig({ scope: "all", todos: "all", mode: "eisenhower", "exclude-notebook": "IBM" }))],
] as const)("%s never fetches excluded folders", async (_name, collect) => {
const calls: string[] = [];
const result = await collect(adapter(calls));
expect(calls).toEqual(["work", "archive-work", "personal", "archive-personal"]);
expect(result.scannedFolders).toBe(4);
expect(result.scannedNotes).toBe(4);
});
test.each([
["kanban", async (data: DataAdapter) => collectKanban(data, "host", "work", parseKanbanConfig({ scope: "children", group: "notebook", todos: "all", "exclude-notebook": "IBM" }))],
["matrix", async (data: DataAdapter) => collectMatrix(data, "host", "work", parseMatrixConfig({ scope: "children", group: "notebook", todos: "all", mode: "eisenhower", "exclude-notebook": "IBM" }))],
] as const)("%s grouped layout omits the excluded tree", async (_name, collect) => {
const calls: string[] = [];
const result = await collect(adapter(calls));
expect(calls).toEqual(["work", "archive-work"]);
expect(result.layout.kind).toBe("notebooks");
if (result.layout.kind !== "notebooks") throw new Error("grouped expected");
expect(result.layout.groups.map((group) => group.folderId)).toEqual([
"work",
"archive-work",
]);
});
test("an entirely excluded calendar scope uses an empty result", async () => {
const calls: string[] = [];
const result = await collectEvents(
adapter(calls),
"host",
"work",
parseCalendarConfig({ "exclude-notebook": "Work" })
);
expect(calls).toEqual([]);
expect(result).toMatchObject({ events: [], scannedFolders: 0, scannedNotes: 0 });
});
});

View File

@ -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,26 +20,11 @@ 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" }]) {

View File

@ -1,157 +0,0 @@
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("rejects a card in an excluded notebook tree without writes", async () => {
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
const result = await validateAndMoveKanbanCard(
intent({ rawConfig: "editable: yes\nscope: children\nexclude-notebook: Child" }),
host, read, mutation
);
expect(result).toMatchObject({ status: "stale" });
expect(writes).toEqual([]);
});
test.each([
[card({ is_todo: 0 }), "editable: yes\nnotes: none\ntodos: all"],
[card({ is_todo: 1, body: "plain" }), "editable: yes\nnotes: all\ntodos: gtd-only"],
[card({ is_todo: 1 }), "editable: yes\nnotes: all\ntodos: none"],
])("rechecks type filters and opt-in on fresh data", async (current, rawConfig) => {
const { read, mutation, writes } = setup(current);
const result = await validateAndMoveKanbanCard(intent({ rawConfig }), host, read, mutation);
expect(result.status).toBe("stale");
expect(writes).toEqual([]);
});
test("returns a safe stale result for API failures", async () => {
const { read, mutation, writes } = setup();
read.getFolders = async () => { throw new Error("private diagnostic"); };
const result = await validateAndMoveKanbanCard(intent(), host, read, mutation);
expect(result).toEqual({
status: "error",
viewInstanceId: "view-1",
message: "Could not apply the move. Refresh and try again.",
});
expect(writes).toEqual([]);
});
});

View File

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

View File

@ -1,6 +1,5 @@
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
import collectMatrix from "../../Gtd/collectMatrix";
import matrixLabels from "../../Gtd/matrixLabels";
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// ---------------------------------------------------------------------------
@ -8,28 +7,6 @@ import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// ---------------------------------------------------------------------------
describe("parseMatrixConfig", () => {
test("both modes use constructive labels while preserving distinct axes", () => {
expect(matrixLabels("eisenhower")).toEqual({
columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"],
quadrants: {
topLeft: "Do Next",
topRight: "Scheduled",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
});
expect(matrixLabels("skeleton")).toEqual({
columns: ["Priority", "Current / backlog"],
rows: ["Now", "Next / later"],
quadrants: {
topLeft: "Urgent",
topRight: "In Progress",
bottomLeft: "On Deck",
bottomRight: "Backlog",
},
});
});
test("applies SPEC defaults", () => {
const c = parseMatrixConfig({});
expect(c).toMatchObject({
@ -42,42 +19,15 @@ describe("parseMatrixConfig", () => {
sort: "asc",
urgentTag: "urgent",
importantTag: "important",
onDeckTag: "on-deck",
inProgressTag: "in-progress",
doneTag: "done",
urgentWindow: 3,
cardDetail: "hover",
pageSize: 10,
editable: false,
});
expect(c.warnings).toHaveLength(0);
});
test("editable is strict and requires distinct destination tags", () => {
expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true);
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false);
for (const value of ["", "true", true, null, ["yes"], { value: "yes" }]) {
const config = parseMatrixConfig({ mode: "eisenhower", editable: value });
expect(config.editable).toBe(false);
expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`);
}
expect(parseMatrixConfig({ editable: "yes" }).editable).toBe(true);
const equalAxes = parseMatrixConfig({
mode: "eisenhower",
editable: "yes",
"urgent-tag": "Axis",
"important-tag": "axis",
});
expect(equalAxes.editable).toBe(false);
expect(equalAxes.warnings).toContain(
"editable Eisenhower axis tags must be distinct (editing disabled)"
);
const equalWorkflow = parseMatrixConfig({ editable: "yes", "on-deck-tag": "URGENT" });
expect(equalWorkflow.editable).toBe(false);
});
test("parses notes independently and rejects invalid or singular forms", () => {
expect(parseMatrixConfig({ notes: " NONE " }).notes).toBe("none");
for (const value of ["", "gtd-only", null, ["all"], { mode: "all" }]) {
@ -196,7 +146,7 @@ function makeAdapter(
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
describe("collectMatrix — quadrant bucketing", () => {
test("all four Eisenhower quadrants route correctly; untagged lands in Backlog", async () => {
test("all four quadrants route correctly; untagged lands in Eliminate", async () => {
const notes = [
makeNote({
id: "both",
@ -564,7 +514,7 @@ describe("collectMatrix — Skeleton mode", () => {
return new Date(y, m - 1, d, 12).getTime();
}
test("the four tag destinations: Urgent / In Progress / On Deck / Backlog", async () => {
test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => {
const notes = [
// active + due within window -> Do Next
makeNote({
@ -589,9 +539,8 @@ describe("collectMatrix — Skeleton mode", () => {
];
const result = await collectMatrix(
makeAdapter(folders, notes, {
donext: ["urgent"],
donext: ["in-progress"],
sched: ["in-progress"],
ondeck: ["on-deck"],
}),
"matrix-note",
"board",
@ -604,10 +553,10 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]);
});
test("urgent takes precedence over overlapping workflow tags", async () => {
test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => {
const notes = [makeNote({ id: "hot", is_todo: 0 })];
const result = await collectMatrix(
makeAdapter(folders, notes, { hot: ["on-deck", "in-progress", "urgent"] }),
makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }),
"matrix-note",
"board",
parseMatrixConfig({ todos: "all" }),
@ -616,7 +565,7 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]);
});
test("urgent tag alone routes to Urgent", async () => {
test("urgent tag alone (no date, not active) -> On Deck", async () => {
const notes = [makeNote({ id: "flag", is_todo: 0 })];
const result = await collectMatrix(
makeAdapter(folders, notes, { flag: ["urgent"] }),
@ -625,10 +574,10 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({ todos: "all" }),
NOW
);
expect(result.board.topLeft.map((c) => c.id)).toEqual(["flag"]);
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["flag"]);
});
test("native due dates do not affect Skeleton routing", async () => {
test("overdue counts as due soon", async () => {
const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })];
const result = await collectMatrix(
makeAdapter(folders, notes),
@ -637,10 +586,10 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({ todos: "all" }),
NOW
);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["late"]);
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["late"]);
});
test("in-progress work routes to In Progress", async () => {
test("dateless in-progress work -> Scheduled (active, no clock)", async () => {
const notes = [makeNote({ id: "wip" })];
const result = await collectMatrix(
makeAdapter(folders, notes, { wip: ["in-progress"] }),
@ -652,7 +601,7 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]);
});
test("future dates do not affect Skeleton routing", async () => {
test("urgent-window boundary: exactly N days out is due soon; N+1 is not", async () => {
const notes = [
makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days
makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4
@ -661,13 +610,14 @@ describe("collectMatrix — Skeleton mode", () => {
makeAdapter(folders, notes),
"matrix-note",
"board",
parseMatrixConfig({ todos: "all" }),
parseMatrixConfig({ todos: "all", "urgent-window": 3 }),
NOW
);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["edge", "past-edge"]);
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["past-edge"]);
});
test("gtd date override does not affect Skeleton routing", async () => {
test("gtd date override affects due-soon just like a due date", async () => {
const body = "```gtd\ndate: 2026-06-16\n```";
const notes = [makeNote({ id: "g", body: body })];
const result = await collectMatrix(
@ -677,7 +627,7 @@ describe("collectMatrix — Skeleton mode", () => {
parseMatrixConfig({}),
NOW
);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["g"]);
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]);
});
});
@ -821,12 +771,11 @@ describe("collectMatrix — notebook groups", () => {
makeNote({ id: "bl", parent_id: "child", is_todo: 0 }),
makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
];
const tags = mode === "skeleton" ? {
"root-left": ["on-deck"], tl: ["urgent"],
tr: ["in-progress"], bl: ["on-deck"],
} : {
"root-left": ["urgent"], tl: ["urgent", "important"],
tr: ["important"], bl: ["urgent"],
const tags = {
"root-left": ["urgent"],
tl: ["urgent", "important", "in-progress"],
tr: ["important", "in-progress"],
bl: ["urgent"],
};
const result = await collectMatrix(
makeAdapter(tree, notes, tags),

View File

@ -1,168 +0,0 @@
import { validateAndMoveMatrixCard } from "../../Gtd/matrixMoveHandler";
import { DataAdapter, MutationAdapter, RawFolder, RawNote, RawTag } from "../../Gtd/types";
const folders: RawFolder[] = [
{ id: "board", parent_id: "", title: "Board" },
{ id: "child", parent_id: "board", title: "Child" },
{ id: "outside", parent_id: "", title: "Outside" },
];
function card(overrides: Partial<RawNote> = {}): RawNote {
return {
id: "card", title: "Card", parent_id: "board", is_todo: 0,
todo_due: 0, todo_completed: 0, updated_time: 0, body: "```gtd\n```",
...overrides,
};
}
function setup(current: RawNote | null = card(), attached: RawTag[] = []) {
const writes: string[] = [];
const read: DataAdapter = {
getFolders: async () => folders,
getNotesInFolder: async () => [],
getNoteTagTitles: async () => [],
};
const mutation: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => attached.slice(),
getAllTags: async () => [
{ id: "urgent", title: "urgent" },
{ id: "important", title: "important" },
],
createTag: async (title) => ({ id: title, title }),
attachTag: async (tagId) => { writes.push(`attach:${tagId}`); },
detachTag: async (tagId) => { writes.push(`detach:${tagId}`); },
setTodoCompleted: async (_noteId, completedTime) => {
writes.push(`complete:${completedTime > 0}`);
},
};
return { read, mutation, writes };
}
function intent(overrides: Record<string, unknown> = {}) {
return {
type: "moveMatrixCard", view: "matrix", hostNoteId: "host", cardId: "card",
destination: "topLeft",
rawConfig: "mode: eisenhower\neditable: yes\nnotes: all\ntodos: all",
viewInstanceId: "view-1", ...overrides,
};
}
const host = { id: "host", parentId: "board" };
describe("validateAndMoveMatrixCard", () => {
test("accepts a constrained editable Eisenhower move", async () => {
const { read, mutation, writes } = setup();
await expect(validateAndMoveMatrixCard(intent(), host, read, mutation)).resolves.toEqual({
status: "success", viewInstanceId: "view-1", changed: true,
});
expect(writes).toEqual(["attach:urgent", "attach:important"]);
});
test.each([
{}, intent({ view: "kanban" }), intent({ destination: "diagonal" }),
intent({ cardId: "" }), intent({ rawConfig: { editable: "yes" } }),
intent({ patch: { tags: ["urgent"] } }),
])("rejects malformed or expanded input without writes", async (request) => {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(request, host, read, mutation)).status).toBe("error");
expect(writes).toEqual([]);
});
test.each([
["", "error"],
["mode: eisenhower\neditable: no", "error"],
["mode: skeleton\neditable: no", "error"],
["mode: eisenhower\neditable: yes\nurgent-tag: same\nimportant-tag: SAME", "error"],
["editable: [yes", "stale"],
] as const)("rejects invalid or non-editable config", async (rawConfig, status) => {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(intent({ rawConfig }), host, read, mutation)).status).toBe(status);
expect(writes).toEqual([]);
});
test("accepts an editable Skeleton destination", async () => {
const { read, mutation, writes } = setup();
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: "mode: skeleton\neditable: yes\nnotes: all\ntodos: all",
destination: "bottomLeft",
}), host, read, mutation);
expect(moveResult.status).toBe("success");
expect(writes).toEqual(["attach:on-deck"]);
});
test.each(["skeleton", "eisenhower"] as const)(
"accepts Done for an editable %s matrix",
async (mode) => {
const { read, mutation, writes } = setup(card({ is_todo: 1 }));
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: `mode: ${mode}\neditable: yes\nnotes: all\ntodos: all`,
destination: "done",
}), host, read, mutation);
expect(moveResult.status).toBe("success");
expect(writes).toEqual(["complete:true"]);
}
);
test("requires the selected host identity", async () => {
for (const selected of [null, { id: "other", parentId: "board" }]) {
const { read, mutation, writes } = setup();
expect((await validateAndMoveMatrixCard(intent(), selected, read, mutation)).status).toBe("stale");
expect(writes).toEqual([]);
}
});
test.each([
[card({ id: "host" }), [], "dashboard"],
[card({ parent_id: "outside" }), [], "scope"],
[card({ body: "plain" }), [], "gtd block"],
[card({ is_todo: 1, todo_completed: 99 }), [], "complete"],
[card(), [{ id: "done", title: "DONE" }], "complete"],
] as Array<[RawNote, RawTag[], string]>)("rejects stale item state", async (current, tags, message) => {
const { read, mutation, writes } = setup(current, tags);
const moveResult = await validateAndMoveMatrixCard(intent(), host, read, mutation);
expect(moveResult).toMatchObject({ status: "stale" });
expect((moveResult as any).message).toContain(message);
expect(writes).toEqual([]);
});
test("resolves notebook and children scope from fresh folders", async () => {
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: "mode: eisenhower\neditable: yes\nnotebook: Board\nscope: children",
}), host, read, mutation);
expect(moveResult.status).toBe("success");
expect(writes).toEqual(["attach:urgent", "attach:important"]);
});
test("rejects a card in an excluded notebook tree without writes", async () => {
const { read, mutation, writes } = setup(card({ parent_id: "child" }));
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: "mode: eisenhower\neditable: yes\nscope: children\nexclude-notebook: Child",
}), host, read, mutation);
expect(moveResult).toMatchObject({ status: "stale" });
expect(writes).toEqual([]);
});
test.each([
[card({ is_todo: 0 }), "mode: eisenhower\neditable: yes\nnotes: none\ntodos: all"],
[card({ is_todo: 1, body: "plain" }), "mode: eisenhower\neditable: yes\nnotes: all\ntodos: gtd-only"],
[card({ is_todo: 1 }), "mode: eisenhower\neditable: yes\nnotes: all\ntodos: none"],
])("rechecks filters and opt-in", async (current, rawConfig) => {
const { read, mutation, writes } = setup(current);
expect((await validateAndMoveMatrixCard(intent({ rawConfig }), host, read, mutation)).status).toBe("stale");
expect(writes).toEqual([]);
});
test("returns a safe error and logs API diagnostics", async () => {
const { read, mutation, writes } = setup();
const diagnostics: unknown[] = [];
read.getFolders = async () => { throw new Error("private detail"); };
expect(await validateAndMoveMatrixCard(intent(), host, read, mutation, (e) => diagnostics.push(e))).toEqual({
status: "error", viewInstanceId: "view-1",
message: "Could not apply the move. Refresh and try again.",
});
expect(diagnostics).toHaveLength(1);
expect(writes).toEqual([]);
});
});

View File

@ -1,159 +0,0 @@
import { moveMatrixItem } from "../../Gtd/matrixMutation";
import { MatrixDestination, MutationAdapter, RawNote, RawTag } from "../../Gtd/types";
function note(isTodo: boolean): RawNote {
return {
id: "card", title: "Card", parent_id: "folder", is_todo: isTodo ? 1 : 0,
todo_due: 123, todo_completed: 0, updated_time: 456, body: "```gtd\n```",
};
}
function fakeAdapter(current: RawNote | null, attached: RawTag[], all?: RawTag[]) {
const calls: string[] = [];
const adapter: MutationAdapter = {
getNote: async () => current,
getNoteTags: async () => attached.slice(),
getAllTags: async () => (all || [
{ id: "u", title: "urgent" }, { id: "i", title: "important" },
]).slice(),
createTag: async (title) => { calls.push(`create:${title}`); return { id: title, title }; },
attachTag: async (tagId) => { calls.push(`attach:${tagId}`); },
detachTag: async (tagId) => { calls.push(`detach:${tagId}`); },
setTodoCompleted: async (_noteId, completedTime) => {
calls.push(`complete:${completedTime > 0}`);
},
};
return { adapter, calls };
}
const states: Array<{
destination: MatrixDestination;
urgent: boolean;
important: boolean;
}> = [
{ destination: "topLeft", urgent: true, important: true },
{ destination: "topRight", urgent: false, important: true },
{ destination: "bottomLeft", urgent: true, important: false },
{ destination: "bottomRight", urgent: false, important: false },
];
const config = {
mode: "eisenhower" as const,
urgentTag: "urgent", importantTag: "important",
inProgressTag: "in-progress", onDeckTag: "on-deck", doneTag: "done",
};
describe("moveMatrixItem", () => {
for (const isTodo of [false, true]) {
for (const source of states) {
for (const target of states) {
test(`${isTodo ? "to-do" : "note"}: ${source.destination} -> ${target.destination}`, async () => {
const tags: RawTag[] = [];
if (source.urgent) tags.push({ id: "u", title: "urgent" });
if (source.important) tags.push({ id: "i", title: "important" });
const { adapter, calls } = fakeAdapter(note(isTodo), tags);
const result = await moveMatrixItem(adapter, "card", target.destination, config);
const expected: string[] = [];
if (source.urgent !== target.urgent) expected.push(`${target.urgent ? "attach" : "detach"}:u`);
if (source.important !== target.important) expected.push(`${target.important ? "attach" : "detach"}:i`);
expect(calls).toEqual(expected);
expect(result.changed).toBe(source.destination !== target.destination);
expect(result.note).toMatchObject({
is_todo: isTodo ? 1 : 0, todo_due: 123, todo_completed: 0,
});
});
}
}
}
test("uses custom tag names, existing case variants, and deterministic duplicates", async () => {
const { adapter, calls } = fakeAdapter(note(false), [
{ id: "z", title: "FIRE" }, { id: "a", title: "fire" },
{ id: "keep", title: "keep-me" },
], [{ id: "m", title: "MATTERS" }]);
await moveMatrixItem(adapter, "card", "topRight", {
...config, urgentTag: "fire", importantTag: "matters",
});
expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]);
});
test("creates only a missing required tag", async () => {
const { adapter, calls } = fakeAdapter(note(true), [], []);
await moveMatrixItem(adapter, "card", "bottomLeft", config);
expect(calls).toEqual(["create:urgent", "attach:urgent"]);
});
test.each(["skeleton", "eisenhower"] as const)(
"%s Done completes a native to-do and removes its in-progress tag",
async (mode) => {
const { adapter, calls } = fakeAdapter(note(true), [
{ id: "p", title: "in-progress" }, { id: "u", title: "urgent" },
]);
await moveMatrixItem(adapter, "card", "done", { ...config, mode });
expect(calls).toEqual(["complete:true", "detach:p"]);
}
);
test.each(["skeleton", "eisenhower"] as const)(
"%s Done applies the configured done tag to an ordinary note",
async (mode) => {
const { adapter, calls } = fakeAdapter(note(false), [
{ id: "p", title: "in-progress" },
], [{ id: "d", title: "Finished" }]);
await moveMatrixItem(adapter, "card", "done", {
...config, mode, doneTag: "finished",
});
expect(calls).toEqual(["attach:d", "detach:p"]);
}
);
test.each([
["topLeft", "urgent"], ["topRight", "in-progress"],
["bottomLeft", "on-deck"], ["bottomRight", null],
] as Array<[MatrixDestination, string | null]>)
("Skeleton %s normalizes the three workflow tags", async (destination, expected) => {
const { adapter, calls } = fakeAdapter(note(false), [
{ id: "u", title: "urgent" }, { id: "i", title: "in-progress" },
{ id: "o", title: "on-deck" },
], [
{ id: "u", title: "urgent" }, { id: "i", title: "in-progress" },
{ id: "o", title: "on-deck" },
]);
await moveMatrixItem(adapter, "card", destination, { ...config, mode: "skeleton" });
const keptId = expected === "urgent" ? "u" : expected === "in-progress" ? "i" : expected === "on-deck" ? "o" : null;
expect(calls).toEqual(["u", "i", "o"].filter((id) => id !== keptId).map((id) => `detach:${id}`));
});
test("fresh validation can reject completed or newly filtered cards before writes", async () => {
for (const current of [note(true), note(false)]) {
current.todo_completed = current.is_todo ? 100 : 0;
const { adapter, calls } = fakeAdapter(current, []);
await expect(moveMatrixItem(adapter, "card", "topLeft", config, () => {
throw new Error("card is no longer eligible");
})).rejects.toThrow("no longer eligible");
expect(calls).toEqual([]);
}
});
test("surfaces partial failures without compensating writes", async () => {
const { adapter, calls } = fakeAdapter(note(false), [], [
{ id: "u", title: "urgent" }, { id: "i", title: "important" },
]);
adapter.attachTag = async (tagId) => {
calls.push(`attach:${tagId}`);
if (tagId === "i") throw new Error("important attach failed");
};
await expect(moveMatrixItem(adapter, "card", "topLeft", config)).rejects.toThrow(
"important attach failed"
);
expect(calls).toEqual(["attach:u", "attach:i"]);
});
test("fails before writes when the card disappeared", async () => {
const { adapter, calls } = fakeAdapter(null, []);
await expect(moveMatrixItem(adapter, "card", "topLeft", config)).rejects.toThrow(
"no longer exists"
);
expect(calls).toEqual([]);
});
});

View File

@ -1,29 +0,0 @@
import { isKanbanDestination, isMatrixDestination } 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);
}
);
});
describe("matrix move protocol", () => {
test.each(["topLeft", "topRight", "bottomLeft", "bottomRight", "done"])(
"accepts fixed destination %s",
(destination) => expect(isMatrixDestination(destination)).toBe(true)
);
test.each(["", "urgent", "diagonal", null, 1, { tags: ["urgent"] }])(
"rejects arbitrary destination %p",
(destination) => expect(isMatrixDestination(destination)).toBe(false)
);
});

View File

@ -1,75 +0,0 @@
import { readFileSync } from "fs";
import { resolve } from "path";
const webview = readFileSync(
resolve(process.cwd(), "src/gtd-calendar-webview.js"),
"utf8"
);
const css = readFileSync(resolve(process.cwd(), "src/event-calendar.css"), "utf8");
describe("Slice 14a compact move-menu rendering contract", () => {
test("removes the permanent select and renders a disclosure-controlled menu", () => {
expect(webview).not.toContain("gtd-kanban-move-select");
expect(webview).toContain("gtd-kanban-move-disclosure");
expect(webview).toContain('setAttribute("aria-expanded", "false")');
expect(webview).toContain('setAttribute("aria-controls", menuId)');
expect(webview).toContain('setAttribute("aria-haspopup", "menu")');
expect(webview).toContain('setAttribute("role", "menu")');
expect(webview).toContain('setAttribute("role", "menuitem")');
});
test("retains fixed destinations, current-state disabling, and keyboard dismissal", () => {
for (const destination of ["backlog", "inProgress", "done"]) {
expect(webview).toContain(`["${destination}",`);
}
expect(webview).toContain('item.disabled = entry[0] === source');
expect(webview).toContain('event.key === "Escape"');
expect(webview).toContain('event.key === "ArrowDown"');
expect(webview).toContain('event.key === "ArrowUp"');
expect(webview).toContain("outsidePointer");
});
test("uses a compact overlay menu beneath the control stack", () => {
expect(css).toContain(".gtd-kanban-card-controls");
expect(css).toContain(".gtd-kanban-move-menu");
expect(css).toContain("position: absolute");
expect(css).toContain("top: calc(100% + 0.2em)");
expect(css).not.toContain(".gtd-kanban-move-select");
});
});
describe("Slice 15 editable matrix interaction contract", () => {
test("renders matrix controls only from the effective editable payload", () => {
expect(webview).toContain("payload.editable && payload.hostNoteId && renderContext");
expect(webview).toContain('view: "matrix"');
expect(webview).toContain("renderMatrixMoveMenu");
expect(webview).toContain("installMatrixPointerDrag");
});
test("uses fixed matrix destinations and the supplied display labels", () => {
for (const destination of ["topLeft", "topRight", "bottomLeft", "bottomRight"]) {
expect(webview).toContain(`["${destination}", interaction.labels.${destination}]`);
expect(webview).toContain(`"${destination}"`);
}
expect(webview).toContain('["done", "Done"]');
expect(webview).toContain('data-matrix-destination');
});
test("posts a scoped matrix move and refreshes only that matrix instance", () => {
expect(webview).toContain('type: "moveMatrixCard"');
expect(webview).toContain('view: "matrix"');
expect(webview).toContain('type: "getMatrix"');
expect(webview).toContain("viewInstanceId: context.viewInstanceId");
expect(webview).toContain("sequence !== context.requestSequence");
expect(webview).toContain("scheduleMatrixRefresh(interaction.root, context)");
expect(webview).toContain("context.postMoveRefreshTimer");
});
test("styles pointer targets, pending state, and live status", () => {
expect(css).toContain(".gtd-matrix-drop-target");
expect(css).toContain(".gtd-matrix-drop-active");
expect(css).toContain(".gtd-matrix-pending");
expect(css).toContain(".gtd-matrix-move-status");
expect(webview).toContain('status.setAttribute("role", "status")');
});
});