Include opted-in notes in kanban and matrix views

This commit is contained in:
Victor Wiebe 2026-07-27 18:23:05 -04:00
parent cb656b2df8
commit d0ed2c4769
14 changed files with 923 additions and 239 deletions

View File

@ -2,6 +2,23 @@
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases. All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
## Unreleased — Opted-in note cards for kanban and matrix
- Ordinary notes containing a `gtd` block now appear in kanban and matrix
views. Empty blocks opt in; plain notes without the block remain excluded.
- Kanban places note cards in Backlog or In Progress from the configured tag.
Notes never enter Done and never display completion or recurrence state.
- Skeleton and Eisenhower matrices apply their existing date/tag axes to note
cards. Completed to-dos remain excluded.
- Note cards reuse `gtd` title, date, colour, icon, and text overrides. They
use 📄 when no custom icon is provided and click through to the source note.
- Malformed `gtd` blocks remain visible with fallback values and the existing
warning. The `todos:` option continues to govern only to-dos.
- Kanban and matrix always inspect in-scope note bodies to discover explicit
note opt-in; tag requests remain limited to eligible items.
- Validation: 185 automated tests passed, the production JPL was built, and
manual Joplin acceptance passed on 2026-07-27.
## Unreleased — Incremental kanban and matrix card limits ## Unreleased — Incremental kanban and matrix card limits
- Added `page-size` to `gtd-kanban` and `gtd-matrix` (positive integer, default 10); invalid values warn and fall back to 10. - Added `page-size` to `gtd-kanban` and `gtd-matrix` (positive integer, default 10); invalid values warn and fall back to 10.

View File

@ -57,7 +57,7 @@ Invalid values fall back to defaults and show a ⚠ warning above the calendar.
## The `gtd` block ## The `gtd` block
Goes in any note or to-do that should appear on the calendar. **An empty block is valid** — it's the minimal opt-in. All properties are optional: 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:
| Property | Example | What it does | | Property | Example | What it does |
|---|---|---| |---|---|---|
@ -84,7 +84,7 @@ Because that plugin keeps a recurring to-do as a single item at its *next* due d
## Kanban board ## Kanban board
Alongside calendars, GTD Calendar can render a **kanban board** from your to-dos. Add a `gtd-kanban` block — on its own, or stacked beneath a calendar in the same dashboard note: Alongside calendars, GTD Calendar can render a **kanban board** from your to-dos and opted-in notes. Add a `gtd-kanban` block — on its own, or stacked beneath a calendar in the same dashboard note:
```` ````
```gtd-kanban ```gtd-kanban
@ -99,34 +99,50 @@ done-window: 7
``` ```
```` ````
Three columns, driven by your to-dos' state: Ordinary notes opt in by containing a `gtd` block—the same block used by the
calendar. An empty block is valid. For example:
- **Backlog** — every uncompleted to-do without the in-progress tag. ````
- **In Progress** — to-dos carrying the in-progress tag (default tag name `in-progress`; configurable). ```gtd
- **Done** — completed to-dos, limited to those finished within `done-window` days (default 7; set `all` for the full history). title: Research brief
date: 2026-08-15
text: Supporting material for the next review
```
````
A completed to-do always lands in **Done**, even if it still carries the in-progress tag. The board has three columns:
- **Backlog** — every eligible ordinary note or incomplete to-do without the
in-progress tag.
- **In Progress** — eligible notes and incomplete to-dos carrying the
in-progress tag (default `in-progress`; configurable).
- **Done** — completed to-dos within `done-window` days (default 7; set `all`
for full history). Ordinary notes never enter Done.
A completed to-do always lands in **Done**, even if it still carries the
in-progress tag. The `todos:` option controls only to-dos; opted-in ordinary
notes remain visible under `todos: none`.
| Option | Values | Default | Description | | Option | Values | Default | Description |
|---|---|---|---| |---|---|---|---|
| `title` | text | — | Heading above the board. | | `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). | | `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. | | `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. |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. `gtd-only` requires a `gtd` block; `all` includes every to-do in scope. (Plain notes never appear on the board.) | | `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. |
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order of cards within each column. Under `due-date`, cards with no due date sort last. | | `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order of cards within each column. Under `due-date`, cards with no due date sort last. |
| `sort` | `asc`, `desc` | `asc` | Sort direction. | | `sort` | `asc`, `desc` | `asc` | Sort direction. |
| `in-progress-tag` | text | `in-progress` | The tag that places a to-do in the In Progress column. | | `in-progress-tag` | text | `in-progress` | The tag that places an eligible note or incomplete to-do in the In Progress column. |
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. | | `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
| `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. | | `page-size` | positive integer | `10` | Cards initially shown in each column and revealed by each **List more** click. Invalid values warn and fall back to 10. |
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. | | `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
Each column applies `page-size` independently after filtering and sorting. When a column has more cards, **List more** reveals the next batch; hovering over it or focusing it with the keyboard shows exactly how many entries remain. Expansion lasts only in the current rendered view and resets when the note is reloaded. Column headings and board statistics always show full totals. Each column applies `page-size` independently after filtering and sorting. When a column has more cards, **List more** reveals the next batch; hovering over it or focusing it with the keyboard shows exactly how many entries remain. Expansion lasts only in the current rendered view and resets when the note is reloaded. Column headings and board statistics always show full totals.
Cards are compact (title, recurrence ↻ if applicable) and click through to the to-do. The board is **read-only** in this version — it reflects your to-dos' state but doesn't change it. (Drag-and-drop to move cards between columns is planned.) Cards are compact and click through to their source note. To-dos use checkbox glyphs and may show recurrence ↻; ordinary notes use 📄 and are never completed or recurring. 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** in this version—it reflects note and to-do state but does not change it. (Drag-and-drop to move cards between columns is planned.)
## The Matrix (Skeleton & Eisenhower) ## The Matrix (Skeleton & Eisenhower)
A third view: a 2×2 prioritisation matrix. Add a `gtd-matrix` block anywhere: A third view: a 2×2 prioritisation matrix for incomplete to-dos and ordinary notes containing a `gtd` block. Add a `gtd-matrix` block anywhere:
```` ````
```gtd-matrix ```gtd-matrix
@ -137,6 +153,16 @@ page-size: 8
``` ```
```` ````
Ordinary notes use the same explicit opt-in as kanban. For example, this note
has a matrix date without becoming a to-do:
````
```gtd
date: 2026-08-15
text: Review before the planning meeting
```
````
Two modes: Two modes:
### `mode: skeleton` (default) — the Skeleton Matrix ### `mode: skeleton` (default) — the Skeleton Matrix
@ -148,7 +174,7 @@ Built to complement the kanban and calendar, using the tags and dates you alread
| **Active** | Do Next | Scheduled | | **Active** | Do Next | Scheduled |
| **Not active** | On Deck | Backlog | | **Not active** | On Deck | Backlog |
**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). Completed to-dos don't 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 do not appear at all.
### `mode: eisenhower` — the classic ### `mode: eisenhower` — the classic
@ -162,7 +188,7 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
| `title` | text | — | Heading above the matrix. | | `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). | | `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. | | `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. |
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. Plain notes never do. | | `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-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. | | `sort` | `asc`, `desc` | `asc` | Sort direction. |
| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. | | `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. |
@ -174,7 +200,7 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals. Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only. 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 ## Gantt chart

231
SLICE9.md
View File

@ -6,7 +6,7 @@
## Status ## Status
**PLANNED.** Implementation has not started. Begin only after SLICE8 is complete. **COMPLETE.** Phases 17 are complete. Automated validation, production packaging, and manual Joplin acceptance all passed.
## Goal ## Goal
@ -78,51 +78,97 @@ rendering logic for note cards.
### Phase 1 — Card model and shared builder ### Phase 1 — Card model and shared builder
- [ ] Add an explicit card discriminator to `KanbanCard` in `src/Gtd/types.ts`, - [x] Add an explicit card discriminator to `KanbanCard` in `src/Gtd/types.ts`,
preferably `isTodo: boolean`, so completion/recurrence rendering does not preferably `isTodo: boolean`, so completion/recurrence rendering does not
infer item kind indirectly. infer item kind indirectly.
- [ ] Extract duplicated kanban/matrix card construction into a small shared - [x] Extract duplicated kanban/matrix card construction into a small shared
helper only if doing so reduces real duplication without changing public helper only if doing so reduces real duplication without changing public
collector contracts. collector contracts.
- [ ] For normal notes set: - [x] For normal notes set:
- `isTodo: false`; - `isTodo: false`;
- `completed: false` and `completedTime: 0`; - `completed: false` and `completedTime: 0`;
- `isRecurring: false`; - `isRecurring: false`;
- date via `resolveEventDate(note, block)`, which correctly ignores `todo_due` - date via `resolveEventDate(note, block)`, which correctly ignores `todo_due`
for normal notes. for normal notes.
- [ ] For to-dos preserve current completion and recurrence behavior and set - [x] For to-dos preserve current completion and recurrence behavior and set
`isTodo: true`. `isTodo: true`.
- [ ] Update `renderCard` only as needed to choose a neutral note glyph when a - [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. normal note has no custom icon; retain current checkbox glyphs for to-dos.
- [ ] Decide and document the neutral fallback glyph during implementation using - [x] Decide and document the neutral fallback glyph during implementation using
an existing project-compatible symbol; do not alter custom icons. 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 ### Phase 2 — Kanban eligibility and bucketing
- [ ] Refactor the `collectKanban.ts` loop so host exclusion occurs first, then - [x] Refactor the `collectKanban.ts` loop so host exclusion occurs first, then
item-kind-specific eligibility. item-kind-specific eligibility.
- [ ] For to-dos, preserve `todos: none`, `gtd-only`, and `all` behavior exactly. - [x] For to-dos, preserve `todos: none`, `gtd-only`, and `all` behavior exactly.
- [ ] For normal notes, call `extractGtdBlock` and exclude only when no block is - [x] For normal notes, call `extractGtdBlock` and exclude only when no block is
found. found.
- [ ] Surface malformed-block warnings for included notes with the existing - [x] Surface malformed-block warnings for included notes with the existing
message format. message format.
- [ ] Fetch tags only after an item is eligible. - [x] Fetch tags only after an item is eligible.
- [ ] Bucket completed to-dos into Done using the existing done-window cutoff. - [x] Bucket completed to-dos into Done using the existing done-window cutoff.
- [ ] Bucket incomplete to-dos and normal notes by the configured in-progress - [x] Bucket incomplete to-dos and normal notes by the configured in-progress
tag; untagged eligible notes go to Backlog. tag; untagged eligible notes go to Backlog.
- [ ] Sort the combined note/to-do arrays with the existing configured sorter. - [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 ### Phase 3 — Matrix eligibility and bucketing
- [ ] Apply the same host, item-kind, and shortcode eligibility split in - [x] Apply the same host, item-kind, and shortcode eligibility split in
`collectMatrix.ts`. `collectMatrix.ts`.
- [ ] Preserve completed-to-do exclusion. - [x] Preserve completed-to-do exclusion.
- [ ] Allow eligible normal notes through because they have no completion state. - [x] Allow eligible normal notes through because they have no completion state.
- [ ] Fetch tags only after eligibility is established. - [x] Fetch tags only after eligibility is established.
- [ ] Reuse the current Eisenhower tag axes without note-specific exceptions. - [x] Reuse the current Eisenhower tag axes without note-specific exceptions.
- [ ] Reuse the current Skeleton active/due-soon rules; dateless normal notes - [x] Reuse the current Skeleton active/due-soon rules; dateless normal notes
naturally fall into the not-due-soon column unless marked urgent. naturally fall into the not-due-soon column unless marked urgent.
- [ ] Sort combined note/to-do quadrant arrays with the existing sorter. - [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 ### Phase 4 — Body-fetch contract
@ -131,80 +177,129 @@ kanban and matrix collectors must request bodies for every scanned folder even
when `todos: none`. The previous `needsBody = config.todos !== "none"` when `todos: none`. The previous `needsBody = config.todos !== "none"`
optimization is no longer valid for these two views. optimization is no longer valid for these two views.
- [ ] Set `includeBody: true` for kanban and matrix folder-note fetches. - [x] Set `includeBody: true` for kanban and matrix folder-note fetches.
- [ ] Update collector comments so they no longer claim `todos: none` guarantees - [x] Update collector comments so they no longer claim `todos: none` guarantees
an empty board. an empty board.
- [ ] Update `src/tests/Gtd/fieldsHint.test.ts` to expect body fetches in all - [x] Update `src/tests/Gtd/fieldsHint.test.ts` to expect body fetches in all
kanban/matrix modes. kanban/matrix modes.
- [ ] Leave calendar and Gantt body-fetch behavior unchanged. - [x] Leave calendar and Gantt body-fetch behavior unchanged.
- [ ] Document this intentional performance tradeoff in SPEC.md: explicit note - [x] Document this intentional performance tradeoff in SPEC.md: explicit note
opt-in requires body inspection, but tag requests remain limited to opt-in requires body inspection, but tag requests remain limited to
eligible items. 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 ### Phase 5 — Automated tests
#### Kanban #### Kanban
- [ ] Include a normal note with a valid block. - [x] Include a normal note with a valid block.
- [ ] Include a normal note with an empty block. - [x] Include a normal note with an empty block.
- [ ] Include a malformed-block note and retain its warning. - [x] Include a malformed-block note and retain its warning.
- [ ] Exclude a normal note without a block. - [x] Exclude a normal note without a block.
- [ ] Exclude the host note even when it has a block. - [x] Exclude the host note even when it has a block.
- [ ] Place tagged notes in In Progress and untagged notes in Backlog. - [x] Place tagged notes in In Progress and untagged notes in Backlog.
- [ ] Prove normal notes never enter Done and never become recurring. - [x] Prove normal notes never enter Done and never become recurring.
- [ ] Preserve all `todos:` modes and done-window behavior. - [x] Preserve all `todos:` modes and done-window behavior.
- [ ] Verify mixed sorting for due date, title, and modified date. - [x] Verify mixed sorting for due date, title, and modified date.
#### Matrix #### Matrix
- [ ] Cover all four Eisenhower quadrants with normal notes. - [x] Cover all four Eisenhower quadrants with normal notes.
- [ ] Cover Skeleton active/inactive and due-soon/not-due-soon combinations. - [x] Cover Skeleton active/inactive and due-soon/not-due-soon combinations.
- [ ] Cover dateless and explicitly urgent normal notes. - [x] Cover dateless and explicitly urgent normal notes.
- [ ] Include empty and malformed blocks; exclude absent blocks and the host. - [x] Include empty and malformed blocks; exclude absent blocks and the host.
- [ ] Preserve completed-to-do exclusion and all `todos:` modes. - [x] Preserve completed-to-do exclusion and all `todos:` modes.
- [ ] Verify mixed sorting for due date, title, and modified date. - [x] Verify mixed sorting for due date, title, and modified date.
#### Shared/integration contracts #### Shared/integration contracts
- [ ] Update existing `KanbanCard` fixtures for the item discriminator. - [x] Update existing `KanbanCard` fixtures for the item discriminator.
- [ ] Verify tags are not requested for ineligible plain notes or excluded to-dos. - [x] Verify tags are not requested for ineligible plain notes or excluded to-dos.
- [ ] Verify `cardCount` includes all eligible note and to-do cards once. - [x] Verify `cardCount` includes all eligible note and to-do cards once.
- [ ] Verify SLICE8 works on the combined ordered arrays without renderer changes. - [x] Verify SLICE8 works on the combined ordered arrays without renderer changes.
- [ ] Run focused kanban, matrix, and fields-hint suites. - [x] Run focused kanban, matrix, and fields-hint suites.
- [ ] Run the complete Jest suite and record suite/test totals. - [x] Run the complete Jest suite and record suite/test totals.
- [ ] Run `npm run dist` and record the produced `.jpl` path. - [x] Run `npm run dist` and record the produced `.jpl` path.
- [ ] Run `git diff --check` and the prohibited-reference audit. - [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 ### Phase 6 — Documentation
- [ ] Update README.md kanban and matrix sections with a normal-note example. - [x] Update README.md kanban and matrix sections with a normal-note example.
- [ ] State clearly that normal notes require a `gtd` block and that `todos:` - [x] State clearly that normal notes require a `gtd` block and that `todos:`
controls only to-dos. controls only to-dos.
- [ ] Document kanban placement, both matrix placement modes, note date rules, - [x] Document kanban placement, both matrix placement modes, note date rules,
malformed/empty block behavior, and fallback glyph behavior. malformed/empty block behavior, and fallback glyph behavior.
- [ ] Update SPEC.md inclusion tables, collection flow, card model, and body-fetch - [x] Update SPEC.md inclusion tables, collection flow, card model, and body-fetch
tradeoff. tradeoff.
- [ ] Add an unreleased SLICE9 entry to CHANGELOG.md without changing the package - [x] Add an unreleased SLICE9 entry to CHANGELOG.md without changing the package
version until release scope is decided. 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 ### Phase 7 — Manual Joplin acceptance
Use mixed views containing ordinary notes, incomplete to-dos, completed to-dos, Use mixed views containing ordinary notes, incomplete to-dos, completed to-dos,
empty blocks, malformed blocks, and plain notes. empty blocks, malformed blocks, and plain notes.
- [ ] Confirm only opted-in normal notes appear. **Acceptance package:** `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl`
- [ ] Confirm empty blocks opt in and malformed blocks warn without disappearing. (SHA-256
- [ ] Confirm normal-note Backlog/In Progress placement on kanban. `7257bb2bdbb8340539cdad3f88a2ac5f8b55bfa960816f16e472e047311db1cc`).
- [ ] Confirm normal notes never appear in Done.
- [ ] Confirm all four quadrants in both matrix modes behave as specified. **Accepted:** the user reported all manual checks passed on 2026-07-27.
- [ ] Confirm block title/date/colour/icon/text overrides render on normal notes.
- [ ] Confirm neutral note glyph and custom-icon precedence. - [x] Confirm only opted-in normal notes appear.
- [ ] Confirm normal notes do not show completion or recurrence styling. - [x] Confirm empty blocks opt in and malformed blocks warn without disappearing.
- [ ] Confirm clicking note cards opens the correct source note. - [x] Confirm normal-note Backlog/In Progress placement on kanban.
- [ ] Confirm `todos: none`, `gtd-only`, and `all` still affect only to-dos. - [x] Confirm normal notes never appear in Done.
- [ ] Confirm SLICE8 limits and remaining counts include the mixed card total. - [x] Confirm all four quadrants in both matrix modes behave as specified.
- [ ] Confirm sorting, warnings, headings, statistics, and reload behavior. - [x] Confirm block title/date/colour/icon/text overrides render on normal notes.
- [ ] Record user sign-off here; do not mark manual acceptance complete before - [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. confirmation.
## Acceptance criteria ## Acceptance criteria

109
SPEC.md
View File

@ -1,8 +1,8 @@
# GTD Calendar — Project Specification # GTD Calendar — Project Specification
*Status: living document, current through v0.5.0. Sections 15 describe the shipped design; §9 tracks what's still ahead.* *Status: living document, current through v0.7.0 plus completed unreleased kanban/matrix card limits and opted-in note cards. 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 three composable views — calendar, kanban, and a priorities matrix — sharing one data layer and one tag/date vocabulary. 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) **Package name:** `joplin-plugin-gtd-calendar` (npm)
**Manifest id:** `com.victorwiebe.joplin.plugin.gtd-calendar` **Manifest id:** `com.victorwiebe.joplin.plugin.gtd-calendar`
@ -90,7 +90,23 @@ page-size: 10 # positive integer (default: 10)
done-window: 7 # days, or "all" (default: 7) done-window: 7 # days, or "all" (default: 7)
``` ```
Three columns, always shown: **Backlog**, **In Progress**, **Done**. To-dos only — plain notes never appear. Bucketing: `todo_completed > 0` → Done (wins regardless of tags); else the `in-progress-tag` → In Progress; else → Backlog. Backlog shows everything uncompleted and untagged, dated or not. Done is filtered to to-dos completed within `done-window` days (`all` for full history), preventing unbounded growth. Sorting is per-column; under `due-date`, cards without a due date sort after all dated ones 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 one additional 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 — reflects to-do state, does not change it. 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. The `todos:` option applies only to to-dos.
Bucketing: a completed to-do enters Done (and wins regardless of tags); otherwise
an eligible note or incomplete to-do carrying `in-progress-tag` enters In
Progress; everything else enters Backlog. Ordinary notes never enter Done and
never acquire completion or recurrence state. Done is filtered to to-dos
completed within `done-window` days (`all` for full history), preventing
unbounded growth. 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) ### 2.4 The `gtd-matrix` block (lives in the matrix note)
@ -111,9 +127,9 @@ card-detail: hover
page-size: 10 # positive integer; per quadrant (default: 10) page-size: 10 # positive integer; per quadrant (default: 10)
``` ```
A 2×2 grid with labelled axes. To-dos only; **completed to-dos are excluded entirely** in both modes (a prioritisation view, not a tracking view the kanban's Done column is where completions live). The board is internally positional (top-left / top-right / bottom-left / bottom-right); each mode supplies its own axis and quadrant labels. A 2×2 grid with labelled axes. It includes incomplete to-dos admitted by `todos:` plus ordinary notes containing a `gtd` block; `todos:` does not affect notes. **Completed to-dos are excluded entirely** in both modes (a prioritisation view, not a tracking view—the kanban Done column is where completions live). Ordinary notes have no completion or recurrence state. The board is internally positional (top-left / top-right / bottom-left / bottom-right); 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 (due 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: 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). **`mode: eisenhower`** — the classic: rows = `important-tag`; columns = `urgent-tag`. Quadrants: Do First (both), Schedule (important only), Delegate (urgent only), Eliminate (neither).
@ -174,7 +190,7 @@ There is deliberately **no `notes`/`todos` inclusion option**: items opt in sole
1. **Todos:** `todo_due` from the Joplin API. A `date:` in the `gtd` block overrides it. 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. 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**item is rendered in the **Unscheduled** section below the grid. 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 ### 3.2 Inclusion matrix
@ -188,9 +204,22 @@ An item appears on the calendar iff its type's mode admits it:
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). 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 | Included, regardless of `todos:` | 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 blocks warn but do
not disappear. Each kanban/matrix host note is excluded before eligibility and
tag lookup.
### 3.3 Completed todos ### 3.3 Completed todos
**v1 decision:** completed todos (`todo_completed > 0`) render with strikethrough + checked indicator, and a future `completed: show | hide` calendar option is reserved. (Logged as decision D3 below; cheap to change.) **Calendar:** completed to-dos render with strikethrough and a checked indicator. **Kanban:** completed to-dos enter Done when inside the configured done window. **Matrix:** completed to-dos are excluded. Ordinary notes are always incomplete and never recurring in kanban/matrix cards.
--- ---
@ -198,23 +227,23 @@ The calendar note itself is always excluded from results, as is any note whose o
- **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). - **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. - **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:** todos render with a checkbox glyph (☐ / ☑ when completed); notes render with a document glyph. Glyphs are suppressed if the item supplies its own `icon`. Recurring to-dos (per the `recurring` tag) get a ↻ suffix to the right of any icon, across every view. - **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`.
- **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`). - **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`).
- **Kanban** renders three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows due date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches. - **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches.
- **Matrix** renders a 2×2 grid with axis and quadrant labels supplied per-mode by the plugin process; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter. - **Matrix** renders eligible notes and incomplete to-dos in a 2×2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter.
- **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it. - **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it.
- **Drilldown:** every tile, card, hover-card row, and chip across all three block types is clickable and opens the source note. - **Drilldown:** every tile, card, hover-card row, and chip across all four view types is clickable and opens the source note.
--- ---
## 5. Architecture ## 5. Architecture
Upstream is a single synchronous markdown-it content script with no data API access. The fork is a three-part plugin, now serving three fence types (`gtd-calendar`, `gtd-kanban`, `gtd-matrix`) through the same pipeline: 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 ```gtd-calendar / ```gtd-kanban / │ 1. Markdown-it content │ Intercepts the four view fence types
│ script (renderer) │ ```gtd-matrix fences. Parses YAML config, │ script (renderer) │ and parses their YAML configuration,
│ │ emits a placeholder <div> tagged with │ │ emits a placeholder <div> tagged with
│ │ data-block-type and the config as a │ │ data-block-type and the config as a
│ │ data attribute. │ │ data attribute.
@ -223,9 +252,9 @@ Upstream is a single synchronous markdown-it content script with no data API acc
┌──────────────▼──────────────┐ ┌──────────────▼──────────────┐
│ 2. Webview asset script │ Finds placeholders, calls │ 2. Webview asset script │ Finds placeholders, calls
│ (runs in rendered note) │ webviewApi.postMessage(config) with │ (runs in rendered note) │ webviewApi.postMessage(config) with
│ │ getEvents / getKanban / getMatrix │ │ getEvents / getKanban / getMatrix / getGantt
│ │ depending on block type. Builds the │ │ depending on block type. Builds the
│ │ calendar / board / matrix DOM from the │ │ calendar / board / matrix / Gantt DOM from
│ │ returned payload. Click → postMessage │ │ returned payload. Click → postMessage
│ │ ({type: "openNote", noteId}). │ │ ({type: "openNote", noteId}).
└──────────────┬──────────────┘ └──────────────┬──────────────┘
@ -241,25 +270,46 @@ Upstream is a single synchronous markdown-it content script with no data API acc
│ │ • fetches notes/todos, paginated │ │ • fetches notes/todos, paginated
│ │ • parses ```gtd blocks from bodies │ │ • parses ```gtd blocks from bodies
│ │ (src/Gtd/gtdBlock.ts) │ │ (src/Gtd/gtdBlock.ts)
│ │ • fetches tag titles per to-do │ │ • fetches tag titles per eligible item
│ │ (getNoteTagTitles) for recurrence / │ │ (getNoteTagTitles) for recurrence /
│ │ in-progress / urgent / important │ │ in-progress / urgent / important
│ │ detection │ │ detection
│ │ • applies the inclusion matrix, date │ │ • applies the inclusion matrix, date
│ │ resolution, and view-specific │ │ resolution, and view-specific
│ │ bucketing (collectEvents.ts / │ │ bucketing (collectEvents.ts /
│ │ collectKanban.ts / collectMatrix.ts) │ │ collectKanban.ts / collectMatrix.ts /
│ │ collectGantt.ts)
│ │ • handles openNote via │ │ • handles openNote via
│ │ joplin.commands.execute │ │ joplin.commands.execute
└─────────────────────────────┘ └─────────────────────────────┘
``` ```
All three collectors (`collectEvents`, `collectKanban`, `collectMatrix`) share `folderScope.ts`, `gtdBlock.ts`, and `resolveDate.ts` — the inclusion-matrix and date-resolution logic is written once and reused by every view. 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 ### Performance notes
- Folder tree and note list fetched per render request; paginated API calls (`page`/`has_more`). - Folder tree and note list fetched per render request; paginated API calls (`page`/`has_more`).
- Body scanning is limited to items in scope. Kanban and matrix additionally fetch tag titles per to-do (one call each) for recurrence/state detection. 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. - 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. - 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 ### Platform caveats
@ -344,7 +394,7 @@ done-window: 7 # days; integer or "all"; default 7
**Done column window:** shows only to-dos completed within `done-window` days (default 7); `all` shows the full history. Prevents unbounded growth. **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, due date, ↻ if recurring, click-to-open. `card-detail` controls the hover panel: `hover` (default), `always` (details inline), `none` (no panel). Notes are excluded entirely (to-dos only). **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. **Read-only in 0.3.0:** reflects state, never mutates it. Proves columns/bucketing cheaply before write-back.
@ -365,7 +415,7 @@ A fourth display block: a 2×2 prioritisation matrix, tag-driven like the kanban
- urgent only → **Delegate** - urgent only → **Delegate**
- neither → **Eliminate** (the default bucket for untagged to-dos — a deliberately pointed default) - 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. - **Completed to-dos are excluded entirely** — the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live.
- To-dos only; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as the kanban; same compact clickable cards with ↻. - 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. - Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
### v0.6.0 — Notebook targeting & profile-wide scope — SHIPPED ### v0.6.0 — Notebook targeting & profile-wide scope — SHIPPED
@ -374,7 +424,7 @@ Two scope-axis additions shared across all four view blocks (see §2.1 / §2.5 f
- **`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. - **`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. - **`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 optimisation** (groundwork the profile-wide scan leans on): the data adapter fetches note bodies only when a view's inclusion mode actually scans the `gtd` block, via an `includeBody` hint; behaviour-preserving. - **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 ### v0.7.0 — Gantt chart (`gtd-gantt` block) — SHIPPED
@ -392,6 +442,19 @@ an accessible hover/focus tooltip, preserves focus between intermediate clicks,
and disappears at completion. Reload resets all expansion. Automated validation and disappears at completion. Reload resets all expansion. Automated validation
passed at 164 tests and manual Joplin acceptance passed 2026-07-27. 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 ### 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: Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:

View File

@ -7,7 +7,7 @@ manual Joplin acceptance recorded separately.
## Overall status ## Overall status
- [x] SLICE8 complete — incremental card limits - [x] SLICE8 complete — incremental card limits
- [ ] SLICE9 complete — opted-in note cards - [x] SLICE9 complete — opted-in note cards
- [ ] SLICE10 complete — child-notebook boards and matrices - [ ] SLICE10 complete — child-notebook boards and matrices
Implementation order: SLICE8, then SLICE9, then SLICE10. Implementation order: SLICE8, then SLICE9, then SLICE10.
@ -74,49 +74,49 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
### Collection and types ### Collection and types
- [ ] Update kanban collection to inspect ordinary notes (`is_todo: 0`). - [x] Update kanban collection to inspect ordinary notes (`is_todo: 0`).
- [ ] Include an ordinary note only when its body contains a `gtd` block. - [x] Include an ordinary note only when its body contains a `gtd` block.
- [ ] Keep ordinary notes without a `gtd` block excluded. - [x] Keep ordinary notes without a `gtd` block excluded.
- [ ] Keep the host view note excluded even when it contains a `gtd` block. - [x] Keep the host view note excluded even when it contains a `gtd` block.
- [ ] Preserve current `todos:` behavior for to-do notes. - [x] Preserve current `todos:` behavior for to-do notes.
- [ ] Place eligible normal notes with the configured in-progress tag in the - [x] Place eligible normal notes with the configured in-progress tag in the
kanban In Progress column. kanban In Progress column.
- [ ] Place other eligible normal notes in the kanban Backlog column. - [x] Place other eligible normal notes in the kanban Backlog column.
- [ ] Never place normal notes in the kanban Done column. - [x] Never place normal notes in the kanban Done column.
- [ ] Apply existing urgent/important tag bucketing to normal notes in an - [x] Apply existing urgent/important tag bucketing to normal notes in an
Eisenhower matrix. Eisenhower matrix.
- [ ] Apply existing date bucketing to normal notes in a Skeleton matrix. - [x] Apply existing date bucketing to normal notes in a Skeleton matrix.
- [ ] Reuse `gtd` title, date, colour, icon, and text overrides on note cards. - [x] Reuse `gtd` title, date, colour, icon, and text overrides on note cards.
- [ ] Ensure normal notes are never marked completed or recurring. - [x] Ensure normal notes are never marked completed or recurring.
- [ ] Update card types to represent notes and to-dos accurately. - [x] Update card types to represent notes and to-dos accurately.
- [ ] Fetch note bodies wherever opted-in notes may be collected while retaining - [x] Fetch note bodies wherever opted-in notes may be collected while retaining
only safe body-fetch optimizations. only safe body-fetch optimizations.
### Tests ### Tests
- [ ] Test kanban inclusion for a note containing a valid `gtd` block. - [x] Test kanban inclusion for a note containing a valid `gtd` block.
- [ ] Test kanban exclusion for a plain note. - [x] Test kanban exclusion for a plain note.
- [ ] Test kanban Backlog and In Progress placement for note cards. - [x] Test kanban Backlog and In Progress placement for note cards.
- [ ] Test host-note exclusion and malformed `gtd` block warnings. - [x] Test host-note exclusion and malformed `gtd` block warnings.
- [ ] Test that current to-do and Done behavior is unchanged. - [x] Test that current to-do and Done behavior is unchanged.
- [ ] Test note-card bucketing in Skeleton matrix mode. - [x] Test note-card bucketing in Skeleton matrix mode.
- [ ] Test note-card bucketing in Eisenhower matrix mode. - [x] Test note-card bucketing in Eisenhower matrix mode.
- [ ] Test completed-to-do exclusion remains unchanged in matrices. - [x] Test completed-to-do exclusion remains unchanged in matrices.
- [ ] Test mixed note/to-do sorting and click-to-open payloads. - [x] Test mixed note/to-do sorting and click-to-open payloads.
- [ ] Test that SLICE8 limits use the combined note and to-do card count. - [x] Test that SLICE8 limits use the combined note and to-do card count.
### Documentation and acceptance ### Documentation and acceptance
- [ ] Document note-card opt-in rules and examples in README.md. - [x] Document note-card opt-in rules and examples in README.md.
- [ ] Update SPEC.md with mixed note/to-do behavior. - [x] Update SPEC.md with mixed note/to-do behavior.
- [ ] Add the feature to CHANGELOG.md. - [x] Add the feature to CHANGELOG.md.
- [ ] Run the full automated test suite and record command/results. - [x] Run the full automated test suite and record command/results.
- [ ] Run the production package build and record command/artifact. - [x] Run the production package build and record command/artifact.
- [ ] Manually verify mixed note/to-do kanban views in Joplin. - [x] Manually verify mixed note/to-do kanban views in Joplin.
- [ ] Manually verify mixed note/to-do views in both matrix modes in Joplin. - [x] Manually verify mixed note/to-do views in both matrix modes in Joplin.
- [ ] Confirm existing to-do filtering, completion, sorting, styling, warnings, - [x] Confirm existing to-do filtering, completion, sorting, styling, warnings,
and navigation do not regress. and navigation do not regress.
- [ ] Mark SLICE9 complete in `SLICE9.md` and this file. - [x] Mark SLICE9 complete in `SLICE9.md` and this file.
## SLICE10 — Separate child-notebook kanban and matrix views ## SLICE10 — Separate child-notebook kanban and matrix views
@ -167,9 +167,9 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
- [ ] Document `scope: children` multi-view behavior in README.md. - [ ] Document `scope: children` multi-view behavior in README.md.
- [ ] Add a parent/child/grandchild notebook example to README.md or SPEC.md. - [ ] Add a parent/child/grandchild notebook example to README.md or SPEC.md.
- [ ] Update architecture and payload behavior in SPEC.md. - [ ] Update architecture and payload behavior in SPEC.md.
- [ ] Add the feature to CHANGELOG.md. - [x] Add the feature to CHANGELOG.md.
- [ ] Run the full automated test suite and record command/results. - [x] Run the full automated test suite and record command/results.
- [ ] Run the production package build and record command/artifact. - [x] Run the production package build and record command/artifact.
- [ ] Manually verify a parent notebook containing multiple children and - [ ] Manually verify a parent notebook containing multiple children and
grandchildren in Joplin. grandchildren in Joplin.
- [ ] Confirm headings, ordering, empty-group handling, no duplication, sorting, - [ ] Confirm headings, ordering, empty-group handling, no duplication, sorting,

View File

@ -0,0 +1,26 @@
import { GtdBlock, KanbanCard, RawNote, RECURRING_TAG } from "./types";
import { resolveEventDate } from "./resolveDate";
/** Normalize a note or to-do into the shared kanban/matrix card shape. */
export default function buildKanbanCard(
note: RawNote,
block: GtdBlock | null,
tags: string[]
): KanbanCard {
const isTodo = Boolean(note.is_todo);
return {
id: note.id,
title: block && block.title ? block.title : note.title,
date: resolveEventDate(note, block),
isTodo,
completed: isTodo && note.todo_completed > 0,
completedTime: isTodo ? note.todo_completed || 0 : 0,
isRecurring: isTodo && tags.includes(RECURRING_TAG),
bgColour: block ? block.bgColour : null,
fgColour: block ? block.fgColour : null,
icon: block ? block.icon : null,
text: block ? block.text : null,
updatedTime: note.updated_time,
};
}

View File

@ -3,12 +3,10 @@ import {
KanbanBoard, KanbanBoard,
KanbanCard, KanbanCard,
KanbanConfig, KanbanConfig,
RawNote,
RECURRING_TAG,
} from "./types"; } from "./types";
import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderIds from "./folderScope";
import extractGtdBlock from "./gtdBlock"; import extractGtdBlock from "./gtdBlock";
import { resolveEventDate } from "./resolveDate"; import buildKanbanCard from "./buildKanbanCard";
export interface KanbanResult { export interface KanbanResult {
board: KanbanBoard; board: KanbanBoard;
@ -56,21 +54,22 @@ export default async function collectKanban(
? -Infinity ? -Infinity
: now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000; : now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000;
// Bodies are only scanned (gtd block → card styling/title/date) when to-dos // Ordinary notes opt in through their gtd block, independently of the
// are actually included; "none" yields an empty board, so skip fetching them. // to-do inclusion mode, so bodies are required for every scanned note.
const needsBody = config.todos !== "none";
for (const folderId of folderIds) { for (const folderId of folderIds) {
const notes = await adapter.getNotesInFolder(folderId, needsBody); const notes = await adapter.getNotesInFolder(folderId, true);
for (const note of notes) { for (const note of notes) {
scannedNotes += 1; scannedNotes += 1;
if (note.id === kanbanNoteId) continue; if (note.id === kanbanNoteId) continue;
if (!note.is_todo) continue; // to-dos only
if (config.todos === "none") continue;
const result = extractGtdBlock(note.body); const result = extractGtdBlock(note.body);
if (config.todos === "gtd-only" && !result.found) continue; if (note.is_todo) {
if (config.todos === "none") continue;
if (config.todos === "gtd-only" && !result.found) continue;
} else if (!result.found) {
continue;
}
if (result.error) { if (result.error) {
warnings.push( warnings.push(
@ -79,7 +78,7 @@ export default async function collectKanban(
} }
const tags = await adapter.getNoteTagTitles(note.id); const tags = await adapter.getNoteTagTitles(note.id);
const card = buildCard(note, result.block, tags); const card = buildKanbanCard(note, result.block, tags);
// Bucketing — Done wins. // Bucketing — Done wins.
if (card.completed) { if (card.completed) {
@ -106,25 +105,6 @@ export default async function collectKanban(
}; };
} }
function buildCard(
note: RawNote,
block: ReturnType<typeof extractGtdBlock>["block"],
tags: string[]
): KanbanCard {
return {
id: note.id,
title: block && block.title ? block.title : note.title,
date: resolveEventDate(note, block),
completed: note.todo_completed > 0,
completedTime: note.todo_completed || 0,
isRecurring: tags.includes(RECURRING_TAG),
bgColour: block ? block.bgColour : null,
fgColour: block ? block.fgColour : null,
icon: block ? block.icon : null,
text: block ? block.text : null,
updatedTime: note.updated_time,
};
}
function sortColumn(cards: KanbanCard[], config: KanbanConfig): void { function sortColumn(cards: KanbanCard[], config: KanbanConfig): void {
const direction = config.sort === "desc" ? -1 : 1; const direction = config.sort === "desc" ? -1 : 1;

View File

@ -3,12 +3,10 @@ import {
KanbanCard, KanbanCard,
MatrixBoard, MatrixBoard,
MatrixConfig, MatrixConfig,
RawNote,
RECURRING_TAG,
} from "./types"; } from "./types";
import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderIds from "./folderScope";
import extractGtdBlock from "./gtdBlock"; import extractGtdBlock from "./gtdBlock";
import { resolveEventDate } from "./resolveDate"; import buildKanbanCard from "./buildKanbanCard";
export interface MatrixResult { export interface MatrixResult {
board: MatrixBoard; board: MatrixBoard;
@ -58,21 +56,23 @@ export default async function collectMatrix(
// Skeleton mode: a date on or before this ISO threshold is "due soon". // Skeleton mode: a date on or before this ISO threshold is "due soon".
const soonThreshold = isoDaysFromNow(now, config.urgentWindow); const soonThreshold = isoDaysFromNow(now, config.urgentWindow);
// Bodies are only scanned (gtd block → card styling/title/date) when to-dos // Ordinary notes opt in through their gtd block, independently of the
// are actually included; "none" yields an empty matrix, so skip fetching them. // to-do inclusion mode, so bodies are required for every scanned note.
const needsBody = config.todos !== "none";
for (const folderId of folderIds) { for (const folderId of folderIds) {
const notes = await adapter.getNotesInFolder(folderId, needsBody); const notes = await adapter.getNotesInFolder(folderId, true);
for (const note of notes) { for (const note of notes) {
scannedNotes += 1; scannedNotes += 1;
if (note.id === matrixNoteId) continue; if (note.id === matrixNoteId) continue;
if (!note.is_todo) continue; // to-dos only
if (note.todo_completed > 0) continue; // prioritisation view
if (config.todos === "none") continue;
const result = extractGtdBlock(note.body); const result = extractGtdBlock(note.body);
if (config.todos === "gtd-only" && !result.found) continue; if (note.is_todo) {
if (note.todo_completed > 0) continue; // prioritisation view
if (config.todos === "none") continue;
if (config.todos === "gtd-only" && !result.found) continue;
} else if (!result.found) {
continue;
}
if (result.error) { if (result.error) {
warnings.push( warnings.push(
@ -81,7 +81,7 @@ export default async function collectMatrix(
} }
const tags = await adapter.getNoteTagTitles(note.id); const tags = await adapter.getNoteTagTitles(note.id);
const card = buildCard(note, result.block, tags); const card = buildKanbanCard(note, result.block, tags);
let topRow: boolean; let topRow: boolean;
let leftColumn: boolean; let leftColumn: boolean;
@ -137,25 +137,6 @@ function isoDaysFromNow(now: Date, days: number): string {
); );
} }
function buildCard(
note: RawNote,
block: ReturnType<typeof extractGtdBlock>["block"],
tags: string[]
): KanbanCard {
return {
id: note.id,
title: block && block.title ? block.title : note.title,
date: resolveEventDate(note, block),
completed: false,
completedTime: 0,
isRecurring: tags.includes(RECURRING_TAG),
bgColour: block ? block.bgColour : null,
fgColour: block ? block.fgColour : null,
icon: block ? block.icon : null,
text: block ? block.text : null,
updatedTime: note.updated_time,
};
}
function sortColumn(cards: KanbanCard[], config: MatrixConfig): void { function sortColumn(cards: KanbanCard[], config: MatrixConfig): void {
const direction = config.sort === "desc" ? -1 : 1; const direction = config.sort === "desc" ? -1 : 1;

View File

@ -143,11 +143,12 @@ export interface KanbanConfig {
warnings: string[]; warnings: string[];
} }
/** A to-do as a kanban card. */ /** A note or to-do rendered as a kanban/matrix card. */
export interface KanbanCard { export interface KanbanCard {
id: string; id: string;
title: string; title: string;
date: string | null; date: string | null;
isTodo: boolean;
completed: boolean; completed: boolean;
completedTime: number; completedTime: number;
isRecurring: boolean; isRecurring: boolean;

View File

@ -636,7 +636,13 @@
const titleRow = document.createElement("div"); const titleRow = document.createElement("div");
titleRow.className = "gtd-kanban-card-title"; titleRow.className = "gtd-kanban-card-title";
const glyph = card.icon ? card.icon : card.completed ? "\u2611" : "\u2610"; const glyph = card.icon
? card.icon
: card.isTodo === false
? "📄"
: card.completed
? "\u2611"
: "\u2610";
const recur = card.isRecurring ? " \u21BB" : ""; const recur = card.isRecurring ? " \u21BB" : "";
titleRow.textContent = glyph + recur + " " + card.title; titleRow.textContent = glyph + recur + " " + card.title;
if (card.completed) titleRow.style.textDecoration = "line-through"; if (card.completed) titleRow.style.textDecoration = "line-through";

View File

@ -0,0 +1,91 @@
import buildKanbanCard from "../../Gtd/buildKanbanCard";
import { GtdBlock, RawNote } from "../../Gtd/types";
function makeNote(overrides: Partial<RawNote> = {}): RawNote {
return {
id: "note-id",
title: "Source title",
parent_id: "folder-id",
is_todo: 1,
todo_due: new Date(2026, 6, 14).getTime(),
todo_completed: 0,
updated_time: 123,
...overrides,
};
}
function makeBlock(overrides: Partial<GtdBlock> = {}): GtdBlock {
return {
date: null,
bgColour: null,
fgColour: null,
icon: null,
title: null,
text: null,
...overrides,
};
}
describe("buildKanbanCard", () => {
test("preserves to-do completion, recurrence, and due-date behavior", () => {
const completedTime = new Date(2026, 6, 15).getTime();
const card = buildKanbanCard(
makeNote({ todo_completed: completedTime }),
null,
["recurring"]
);
expect(card).toMatchObject({
isTodo: true,
completed: true,
completedTime,
isRecurring: true,
date: "2026-07-14",
});
});
test("normalizes notes as incomplete and non-recurring", () => {
const card = buildKanbanCard(
makeNote({ is_todo: 0, todo_completed: 999 }),
makeBlock({ date: "2026-08-02" }),
["recurring"]
);
expect(card).toMatchObject({
isTodo: false,
completed: false,
completedTime: 0,
isRecurring: false,
date: "2026-08-02",
});
});
test("ignores todo_due for a normal note", () => {
const card = buildKanbanCard(makeNote({ is_todo: 0 }), null, []);
expect(card.date).toBeNull();
});
test("applies gtd card overrides without changing the source identity", () => {
const card = buildKanbanCard(
makeNote(),
makeBlock({
title: "Block title",
bgColour: "#112233",
fgColour: "#ffffff",
icon: "!",
text: "Details",
}),
[]
);
expect(card).toMatchObject({
id: "note-id",
title: "Block title",
bgColour: "#112233",
fgColour: "#ffffff",
icon: "!",
text: "Details",
updatedTime: 123,
});
});
});

View File

@ -7,11 +7,10 @@ import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types"; import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Body-fetch optimization (SLICE1 Task 4): each collector must ask the adapter // Body-fetch contract: the calendar may skip bodies when both item modes are
// for note bodies ONLY when its inclusion mode means the gtd block gets // "none". Kanban and matrix always require bodies because ordinary notes opt in
// scanned. When the view can produce nothing (all relevant modes "none"), the // through a gtd block independently of the to-do inclusion mode. These tests
// body field is skipped. This test records the `includeBody` arg the collectors // record the `includeBody` argument passed to getNotesInFolder.
// pass to getNotesInFolder.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const folders: RawFolder[] = [{ id: "folder", parent_id: "root" }]; const folders: RawFolder[] = [{ id: "folder", parent_id: "root" }];
@ -66,7 +65,7 @@ describe("collectKanban — body fetch hint", () => {
test.each([ test.each([
["gtd-only", { todos: "gtd-only" }, true], ["gtd-only", { todos: "gtd-only" }, true],
["all", { todos: "all" }, true], ["all", { todos: "all" }, true],
["none", { todos: "none" }, false], ["none", { todos: "none" }, true],
])("%s → includeBody %s", async (_label, raw, expected) => { ])("%s → includeBody %s", async (_label, raw, expected) => {
const { adapter, bodyFlags } = recordingAdapter([makeNote()]); const { adapter, bodyFlags } = recordingAdapter([makeNote()]);
const config = parseKanbanConfig(raw as any); const config = parseKanbanConfig(raw as any);
@ -79,7 +78,7 @@ describe("collectMatrix — body fetch hint", () => {
test.each([ test.each([
["gtd-only", { todos: "gtd-only" }, true], ["gtd-only", { todos: "gtd-only" }, true],
["all", { todos: "all" }, true], ["all", { todos: "all" }, true],
["none", { todos: "none" }, false], ["none", { todos: "none" }, true],
])("%s → includeBody %s", async (_label, raw, expected) => { ])("%s → includeBody %s", async (_label, raw, expected) => {
const { adapter, bodyFlags } = recordingAdapter([makeNote()]); const { adapter, bodyFlags } = recordingAdapter([makeNote()]);
const config = parseMatrixConfig(raw as any); const config = parseMatrixConfig(raw as any);

View File

@ -166,26 +166,146 @@ describe("collectKanban — bucketing", () => {
expect(result.board.backlog.map((c) => c.id)).toEqual(["todo"]); expect(result.board.backlog.map((c) => c.id)).toEqual(["todo"]);
}); });
test("notes (non-todos) are excluded entirely", async () => { test("includes opted-in notes and excludes plain notes", async () => {
const notes = [ const notes = [
makeNote({ id: "t", is_todo: 1 }), makeNote({ id: "t", is_todo: 1 }),
makeNote({ id: "n", is_todo: 0, title: "Just a note" }), makeNote({ id: "gtd-note", is_todo: 0, title: "Opted in" }),
makeNote({
id: "plain-note",
is_todo: 0,
title: "Plain note",
body: "No shortcode here",
}),
]; ];
const result = await collectKanban( const result = await collectKanban(
makeAdapter(folders, notes), makeAdapter(folders, notes),
"kanban-note", "kanban-note",
"board", "board",
parseKanbanConfig({ todos: "all", notes: "all" } as any), parseKanbanConfig({ todos: "all" }),
NOW NOW
); );
const allIds = [
...result.board.backlog, expect(result.board.backlog.map((c) => c.id)).toEqual([
...result.board.inProgress, "t",
...result.board.done, "gtd-note",
].map((c) => c.id); ]);
expect(allIds).toEqual(["t"]); expect(result.board.backlog[1]).toMatchObject({
isTodo: false,
completed: false,
isRecurring: false,
});
}); });
test("places opted-in notes by the configured in-progress tag", async () => {
const notes = [
makeNote({ id: "backlog-note", is_todo: 0 }),
makeNote({
id: "active-note",
is_todo: 0,
todo_completed: daysAgo(1),
}),
];
const result = await collectKanban(
makeAdapter(folders, notes, { "active-note": ["doing", "recurring"] }),
"kanban-note",
"board",
parseKanbanConfig({
todos: "none",
"in-progress-tag": "doing",
}),
NOW
);
expect(result.board.backlog.map((c) => c.id)).toEqual(["backlog-note"]);
expect(result.board.inProgress.map((c) => c.id)).toEqual(["active-note"]);
expect(result.board.inProgress[0]).toMatchObject({
completed: false,
completedTime: 0,
isRecurring: false,
});
expect(result.board.done).toHaveLength(0);
});
test("includes malformed opted-in notes and surfaces their warning", async () => {
const notes = [
makeNote({
id: "broken-note",
is_todo: 0,
title: "Broken note",
body: "```gtd\ntitle: [invalid\n```",
}),
];
const result = await collectKanban(
makeAdapter(folders, notes),
"kanban-note",
"board",
parseKanbanConfig({ todos: "all" }),
NOW
);
expect(result.board.backlog.map((c) => c.id)).toEqual(["broken-note"]);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain(
'"Broken note": gtd block problem'
);
});
test.each([
["none", []],
["gtd-only", ["with-gtd"]],
["all", ["plain-todo", "with-gtd"]],
] as const)("preserves todos: %s behavior", async (todos, expectedIds) => {
const notes = [
makeNote({
id: "plain-todo",
body: "No shortcode here",
}),
makeNote({ id: "with-gtd" }),
];
const result = await collectKanban(
makeAdapter(folders, notes),
"kanban-note",
"board",
parseKanbanConfig({ todos }),
NOW
);
expect(result.board.backlog.map((c) => c.id)).toEqual(expectedIds);
});
test("requests tags only after an item is eligible", async () => {
const notes = [
makeNote({
id: "plain-note",
is_todo: 0,
body: "No shortcode here",
}),
makeNote({
id: "plain-todo",
body: "No shortcode here",
}),
makeNote({ id: "gtd-note", is_todo: 0 }),
makeNote({ id: "gtd-todo" }),
];
const adapter = makeAdapter(folders, notes);
const getNoteTagTitles = jest.fn(
async (_noteId: string): Promise<string[]> => []
);
adapter.getNoteTagTitles = getNoteTagTitles;
await collectKanban(
adapter,
"kanban-note",
"board",
parseKanbanConfig({ todos: "gtd-only" }),
NOW
);
expect(getNoteTagTitles.mock.calls.map(([id]) => id)).toEqual([
"gtd-note",
"gtd-todo",
]);
});
test("configurable in-progress tag is honoured", async () => { test("configurable in-progress tag is honoured", async () => {
const notes = [makeNote({ id: "x", title: "WIP item" })]; const notes = [makeNote({ id: "x", title: "WIP item" })];
const result = await collectKanban( const result = await collectKanban(
@ -199,7 +319,9 @@ describe("collectKanban — bucketing", () => {
}); });
test("the kanban note itself is excluded", async () => { test("the kanban note itself is excluded", async () => {
const notes = [makeNote({ id: "kanban-note", title: "The board note" })]; const notes = [
makeNote({ id: "kanban-note", is_todo: 0, title: "The board note" }),
];
const result = await collectKanban( const result = await collectKanban(
makeAdapter(folders, notes), makeAdapter(folders, notes),
"kanban-note", "kanban-note",
@ -211,6 +333,120 @@ describe("collectKanban — bucketing", () => {
}); });
}); });
describe("collectKanban — mixed note/to-do contracts", () => {
test("includes empty and populated note blocks with source identities", async () => {
const notes = [
makeNote({ id: "todo", title: "Todo" }),
makeNote({ id: "empty-note", is_todo: 0, title: "Empty opt-in" }),
makeNote({
id: "styled-note",
is_todo: 0,
title: "Source title",
body:
"```gtd\n" +
"title: Display title\n" +
"date: 2026-07-02\n" +
"bg-colour: '#112233'\n" +
"fg-colour: '#ffffff'\n" +
"icon: N\n" +
"text: Details\n" +
"```",
}),
];
const result = await collectKanban(
makeAdapter(folders, notes),
"kanban-note",
"board",
parseKanbanConfig({ todos: "all" }),
NOW
);
expect(result.cardCount).toBe(3);
expect(result.board.backlog.map((card) => card.id)).toEqual([
"styled-note",
"todo",
"empty-note",
]);
expect(result.board.backlog[0]).toMatchObject({
id: "styled-note",
title: "Display title",
date: "2026-07-02",
bgColour: "#112233",
fgColour: "#ffffff",
icon: "N",
text: "Details",
});
});
test.each([
["due-date", ["note-alpha", "todo-bravo", "note-zulu"]],
["title", ["note-alpha", "todo-bravo", "note-zulu"]],
["modified-date", ["note-zulu", "todo-bravo", "note-alpha"]],
] as const)("sorts mixed cards by %s", async (sortType, expectedIds) => {
const notes = [
makeNote({
id: "todo-bravo",
title: "Bravo",
todo_due: new Date(2026, 6, 20).getTime(),
updated_time: 20,
}),
makeNote({
id: "note-alpha",
is_todo: 0,
title: "Alpha",
body: "```gtd\ndate: 2026-07-01\n```",
updated_time: 30,
}),
makeNote({
id: "note-zulu",
is_todo: 0,
title: "Zulu",
updated_time: 10,
}),
];
const result = await collectKanban(
makeAdapter(folders, notes),
"kanban-note",
"board",
parseKanbanConfig({
todos: "all",
"sort-type": sortType,
"page-size": 1,
}),
NOW
);
expect(result.board.backlog.map((card) => card.id)).toEqual(expectedIds);
expect(result.cardCount).toBe(3);
expect(result.board.backlog).toHaveLength(3);
});
test("counts mixed cards once across all kanban columns", async () => {
const notes = [
makeNote({ id: "backlog-todo" }),
makeNote({ id: "active-note", is_todo: 0 }),
makeNote({ id: "done-todo", todo_completed: daysAgo(1) }),
];
const result = await collectKanban(
makeAdapter(folders, notes, { "active-note": ["in-progress"] }),
"kanban-note",
"board",
parseKanbanConfig({ todos: "all", "page-size": 1 }),
NOW
);
expect(result.cardCount).toBe(3);
expect([
...result.board.backlog,
...result.board.inProgress,
...result.board.done,
].map((card) => card.id)).toEqual([
"backlog-todo",
"active-note",
"done-todo",
]);
});
});
describe("collectKanban — done window", () => { describe("collectKanban — done window", () => {
test("only completions within the window appear in Done", async () => { test("only completions within the window appear in Done", async () => {
const notes = [ const notes = [

View File

@ -126,10 +126,15 @@ const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
describe("collectMatrix — quadrant bucketing", () => { describe("collectMatrix — quadrant bucketing", () => {
test("all four quadrants route correctly; untagged lands in Eliminate", async () => { test("all four quadrants route correctly; untagged lands in Eliminate", async () => {
const notes = [ const notes = [
makeNote({ id: "both", title: "Crisis" }), makeNote({
makeNote({ id: "imp", title: "Strategy" }), id: "both",
makeNote({ id: "urg", title: "Interruption" }), is_todo: 0,
makeNote({ id: "none", title: "Timewaster" }), title: "Crisis",
todo_completed: 123456,
}),
makeNote({ id: "imp", is_todo: 0, title: "Strategy" }),
makeNote({ id: "urg", is_todo: 0, title: "Interruption" }),
makeNote({ id: "none", is_todo: 0, title: "Timewaster" }),
]; ];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { makeAdapter(folders, notes, {
@ -184,11 +189,21 @@ describe("collectMatrix — quadrant bucketing", () => {
expect(result.board.topLeft.map((c) => c.id)).toEqual(["x"]); expect(result.board.topLeft.map((c) => c.id)).toEqual(["x"]);
}); });
test("plain notes and the matrix note itself are excluded", async () => { test("includes opted-in notes while excluding plain and host notes", async () => {
const notes = [ const notes = [
makeNote({ id: "matrix-note", title: "The matrix note" }), makeNote({
makeNote({ id: "n", is_todo: 0, title: "A note" }), id: "matrix-note",
makeNote({ id: "t", title: "A todo" }), is_todo: 0,
title: "The matrix note",
}),
makeNote({
id: "plain-note",
is_todo: 0,
title: "A plain note",
body: "No shortcode here",
}),
makeNote({ id: "gtd-note", is_todo: 0, title: "An opted-in note" }),
makeNote({ id: "todo", title: "A todo" }),
]; ];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes), makeAdapter(folders, notes),
@ -196,8 +211,16 @@ describe("collectMatrix — quadrant bucketing", () => {
"board", "board",
parseMatrixConfig({ todos: "all", mode: "eisenhower" }) parseMatrixConfig({ todos: "all", mode: "eisenhower" })
); );
expect(result.cardCount).toBe(1); expect(result.cardCount).toBe(2);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["t"]); expect(result.board.bottomRight.map((c) => c.id)).toEqual([
"gtd-note",
"todo",
]);
expect(result.board.bottomRight[0]).toMatchObject({
isTodo: false,
completed: false,
isRecurring: false,
});
}); });
test("gtd-only requires a gtd block", async () => { test("gtd-only requires a gtd block", async () => {
@ -215,6 +238,54 @@ describe("collectMatrix — quadrant bucketing", () => {
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["in"]); expect(result.board.bottomRight.map((c) => c.id)).toEqual(["in"]);
}); });
test("todos: none excludes to-dos without disabling opted-in notes", async () => {
const notes = [
makeNote({ id: "gtd-note", is_todo: 0 }),
makeNote({ id: "gtd-todo" }),
];
const result = await collectMatrix(
makeAdapter(folders, notes),
"matrix-note",
"board",
parseMatrixConfig({ todos: "none", mode: "eisenhower" })
);
expect(result.cardCount).toBe(1);
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["gtd-note"]);
});
test("requests tags only after an item is eligible", async () => {
const notes = [
makeNote({
id: "plain-note",
is_todo: 0,
body: "No shortcode here",
}),
makeNote({
id: "plain-todo",
body: "No shortcode here",
}),
makeNote({ id: "gtd-note", is_todo: 0 }),
makeNote({ id: "gtd-todo" }),
makeNote({ id: "done-todo", todo_completed: 123456 }),
];
const adapter = makeAdapter(folders, notes);
const getNoteTagTitles = jest.fn(
async (_noteId: string): Promise<string[]> => []
);
adapter.getNoteTagTitles = getNoteTagTitles;
await collectMatrix(
adapter,
"matrix-note",
"board",
parseMatrixConfig({ todos: "gtd-only", mode: "eisenhower" })
);
expect(getNoteTagTitles.mock.calls.map(([id]) => id)).toEqual([
"gtd-note",
"gtd-todo",
]);
});
test("due-date sort within a quadrant, dateless last", async () => { test("due-date sort within a quadrant, dateless last", async () => {
const notes = [ const notes = [
makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }), makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }),
@ -246,6 +317,86 @@ describe("collectMatrix — quadrant bucketing", () => {
}); });
}); });
describe("collectMatrix — mixed note/to-do contracts", () => {
test("handles empty, malformed, absent, and host note blocks", async () => {
const notes = [
makeNote({ id: "empty-note", is_todo: 0 }),
makeNote({
id: "broken-note",
is_todo: 0,
title: "Broken note",
body: "```gtd\ntitle: [invalid\n```",
}),
makeNote({
id: "plain-note",
is_todo: 0,
body: "No shortcode here",
}),
makeNote({ id: "matrix-note", is_todo: 0 }),
];
const result = await collectMatrix(
makeAdapter(folders, notes),
"matrix-note",
"board",
parseMatrixConfig({ todos: "none", mode: "eisenhower" })
);
expect(result.board.bottomRight.map((card) => card.id)).toEqual([
"empty-note",
"broken-note",
]);
expect(result.cardCount).toBe(2);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain(
'"Broken note": gtd block problem'
);
});
test.each([
["due-date", ["note-alpha", "todo-bravo", "note-zulu"]],
["title", ["note-alpha", "todo-bravo", "note-zulu"]],
["modified-date", ["note-zulu", "todo-bravo", "note-alpha"]],
] as const)("sorts mixed cards by %s", async (sortType, expectedIds) => {
const notes = [
makeNote({
id: "todo-bravo",
title: "Bravo",
todo_due: new Date(2026, 6, 20).getTime(),
updated_time: 20,
}),
makeNote({
id: "note-alpha",
is_todo: 0,
title: "Alpha",
body: "```gtd\ndate: 2026-07-01\n```",
updated_time: 30,
}),
makeNote({
id: "note-zulu",
is_todo: 0,
title: "Zulu",
updated_time: 10,
}),
];
const result = await collectMatrix(
makeAdapter(folders, notes),
"matrix-note",
"board",
parseMatrixConfig({
todos: "all",
mode: "eisenhower",
"sort-type": sortType,
"page-size": 1,
})
);
expect(result.board.bottomRight.map((card) => card.id)).toEqual(
expectedIds
);
expect(result.cardCount).toBe(3);
expect(result.board.bottomRight).toHaveLength(3);
});
});
describe("collectMatrix — Skeleton mode", () => { describe("collectMatrix — Skeleton mode", () => {
const NOW = new Date(2026, 5, 15); // Jun 15 2026 const NOW = new Date(2026, 5, 15); // Jun 15 2026
@ -256,13 +407,25 @@ describe("collectMatrix — Skeleton mode", () => {
test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => { test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => {
const notes = [ const notes = [
// active + due within window -> Do Next // active + due within window -> Do Next
makeNote({ id: "donext", todo_due: iso(2026, 6, 17) }), makeNote({
id: "donext",
is_todo: 0,
body: "```gtd\ndate: 2026-06-17\n```",
}),
// active + due later -> Scheduled // active + due later -> Scheduled
makeNote({ id: "sched", todo_due: iso(2026, 6, 25) }), makeNote({
id: "sched",
is_todo: 0,
body: "```gtd\ndate: 2026-06-25\n```",
}),
// not active + due soon -> On Deck // not active + due soon -> On Deck
makeNote({ id: "ondeck", todo_due: iso(2026, 6, 16) }), makeNote({
id: "ondeck",
is_todo: 0,
body: "```gtd\ndate: 2026-06-16\n```",
}),
// not active, no date -> Backlog // not active, no date -> Backlog
makeNote({ id: "backlog" }), makeNote({ id: "backlog", is_todo: 0 }),
]; ];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { makeAdapter(folders, notes, {
@ -281,7 +444,7 @@ describe("collectMatrix — Skeleton mode", () => {
}); });
test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => { test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => {
const notes = [makeNote({ id: "hot" })]; const notes = [makeNote({ id: "hot", is_todo: 0 })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }), makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }),
"matrix-note", "matrix-note",
@ -293,7 +456,7 @@ describe("collectMatrix — Skeleton mode", () => {
}); });
test("urgent tag alone (no date, not active) -> On Deck", async () => { test("urgent tag alone (no date, not active) -> On Deck", async () => {
const notes = [makeNote({ id: "flag" })]; const notes = [makeNote({ id: "flag", is_todo: 0 })];
const result = await collectMatrix( const result = await collectMatrix(
makeAdapter(folders, notes, { flag: ["urgent"] }), makeAdapter(folders, notes, { flag: ["urgent"] }),
"matrix-note", "matrix-note",