diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fb2d9..a155053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 - Added `page-size` to `gtd-kanban` and `gtd-matrix` (positive integer, default 10); invalid values warn and fall back to 10. diff --git a/README.md b/README.md index 9010e4e..9fbf333 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Invalid values fall back to defaults and show a ⚠ warning above the calendar. ## 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 | |---|---|---| @@ -84,7 +84,7 @@ Because that plugin keeps a recurring to-do as a single item at its *next* due d ## 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 @@ -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). -- **Done** β€” completed to-dos, limited to those finished within `done-window` days (default 7; set `all` for the full history). +```` +```gtd +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 | |---|---|---|---| | `title` | text | β€” | Heading above the board. | | `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar, including `all` (every notebook β€” see the calendar table's caution). | | `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. | -| `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` | `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. | | `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. | 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) -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 @@ -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: ### `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 | | **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 @@ -162,7 +188,7 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important), | `title` | text | β€” | Heading above the matrix. | | `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook β€” see the calendar table's caution). | | `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. | -| `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` | `asc`, `desc` | `asc` | Sort direction. | | `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. -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 diff --git a/SLICE9.md b/SLICE9.md index 575f393..4e1f19c 100644 --- a/SLICE9.md +++ b/SLICE9.md @@ -6,7 +6,7 @@ ## Status -**PLANNED.** Implementation has not started. Begin only after SLICE8 is complete. +**COMPLETE.** Phases 1–7 are complete. Automated validation, production packaging, and manual Joplin acceptance all passed. ## Goal @@ -78,51 +78,97 @@ rendering logic for note cards. ### 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 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 collector contracts. -- [ ] For normal notes set: +- [x] For normal notes set: - `isTodo: false`; - `completed: false` and `completedTime: 0`; - `isRecurring: false`; - date via `resolveEventDate(note, block)`, which correctly ignores `todo_due` for normal notes. -- [ ] 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`. -- [ ] 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. -- [ ] 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. +### 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 -- [ ] 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. -- [ ] 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 to-dos, preserve `todos: none`, `gtd-only`, and `all` behavior exactly. +- [x] For normal notes, call `extractGtdBlock` and exclude only when no block is found. -- [ ] Surface malformed-block warnings for included notes with the existing +- [x] Surface malformed-block warnings for included notes with the existing message format. -- [ ] Fetch tags only after an item is eligible. -- [ ] 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] Fetch tags only after an item is eligible. +- [x] Bucket completed to-dos into Done using the existing done-window cutoff. +- [x] Bucket incomplete to-dos and normal notes by the configured in-progress tag; untagged eligible notes go to Backlog. -- [ ] 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 -- [ ] 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`. -- [ ] Preserve completed-to-do exclusion. -- [ ] Allow eligible normal notes through because they have no completion state. -- [ ] Fetch tags only after eligibility is established. -- [ ] Reuse the current Eisenhower tag axes without note-specific exceptions. -- [ ] Reuse the current Skeleton active/due-soon rules; dateless normal notes +- [x] Preserve completed-to-do exclusion. +- [x] Allow eligible normal notes through because they have no completion state. +- [x] Fetch tags only after eligibility is established. +- [x] Reuse the current Eisenhower tag axes without note-specific exceptions. +- [x] Reuse the current Skeleton active/due-soon rules; dateless normal notes naturally fall into the not-due-soon column unless marked urgent. -- [ ] 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 @@ -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"` optimization is no longer valid for these two views. -- [ ] Set `includeBody: true` for kanban and matrix folder-note fetches. -- [ ] Update collector comments so they no longer claim `todos: none` guarantees +- [x] Set `includeBody: true` for kanban and matrix folder-note fetches. +- [x] Update collector comments so they no longer claim `todos: none` guarantees an empty board. -- [ ] 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. -- [ ] Leave calendar and Gantt body-fetch behavior unchanged. -- [ ] Document this intentional performance tradeoff in SPEC.md: explicit note +- [x] Leave calendar and Gantt body-fetch behavior unchanged. +- [x] Document this intentional performance tradeoff in SPEC.md: explicit note opt-in requires body inspection, but tag requests remain limited to eligible items. +### Phase 4 completion record + +- Kanban and matrix now request bodies for every in-scope note, including under + `todos: none`, so ordinary-note `gtd` opt-in can always be evaluated. +- Calendar body-fetch behavior remains unchanged; Gantt code was not modified. +- Tag requests remain deferred until an item passes eligibility. +- `fieldsHint.test.ts` now records the new board contract in all to-do modes. +- SPEC.md documents the intentional performance tradeoff. +- Focused validation: 3 suites and 76 tests passed. +- Full validation: 13 suites and 176 tests passed. +- TypeScript, webview syntax, whitespace, and prohibited-reference checks passed. +- No manual Joplin test is required for this data-fetch contract; mixed-view + acceptance remains Phase 7. + ### Phase 5 β€” Automated tests #### Kanban -- [ ] Include a normal note with a valid block. -- [ ] Include a normal note with an empty block. -- [ ] Include a malformed-block note and retain its warning. -- [ ] Exclude a normal note without a block. -- [ ] Exclude the host note even when it has a block. -- [ ] Place tagged notes in In Progress and untagged notes in Backlog. -- [ ] Prove normal notes never enter Done and never become recurring. -- [ ] Preserve all `todos:` modes and done-window behavior. -- [ ] Verify mixed sorting for due date, title, and modified date. +- [x] Include a normal note with a valid block. +- [x] Include a normal note with an empty block. +- [x] Include a malformed-block note and retain its warning. +- [x] Exclude a normal note without a block. +- [x] Exclude the host note even when it has a block. +- [x] Place tagged notes in In Progress and untagged notes in Backlog. +- [x] Prove normal notes never enter Done and never become recurring. +- [x] Preserve all `todos:` modes and done-window behavior. +- [x] Verify mixed sorting for due date, title, and modified date. #### Matrix -- [ ] Cover all four Eisenhower quadrants with normal notes. -- [ ] Cover Skeleton active/inactive and due-soon/not-due-soon combinations. -- [ ] Cover dateless and explicitly urgent normal notes. -- [ ] Include empty and malformed blocks; exclude absent blocks and the host. -- [ ] Preserve completed-to-do exclusion and all `todos:` modes. -- [ ] Verify mixed sorting for due date, title, and modified date. +- [x] Cover all four Eisenhower quadrants with normal notes. +- [x] Cover Skeleton active/inactive and due-soon/not-due-soon combinations. +- [x] Cover dateless and explicitly urgent normal notes. +- [x] Include empty and malformed blocks; exclude absent blocks and the host. +- [x] Preserve completed-to-do exclusion and all `todos:` modes. +- [x] Verify mixed sorting for due date, title, and modified date. #### Shared/integration contracts -- [ ] Update existing `KanbanCard` fixtures for the item discriminator. -- [ ] Verify tags are not requested for ineligible plain notes or excluded to-dos. -- [ ] Verify `cardCount` includes all eligible note and to-do cards once. -- [ ] Verify SLICE8 works on the combined ordered arrays without renderer changes. -- [ ] Run focused kanban, matrix, and fields-hint suites. -- [ ] Run the complete Jest suite and record suite/test totals. -- [ ] Run `npm run dist` and record the produced `.jpl` path. -- [ ] Run `git diff --check` and the prohibited-reference audit. +- [x] Update existing `KanbanCard` fixtures for the item discriminator. +- [x] Verify tags are not requested for ineligible plain notes or excluded to-dos. +- [x] Verify `cardCount` includes all eligible note and to-do cards once. +- [x] Verify SLICE8 works on the combined ordered arrays without renderer changes. +- [x] Run focused kanban, matrix, and fields-hint suites. +- [x] Run the complete Jest suite and record suite/test totals. +- [x] Run `npm run dist` and record the produced `.jpl` path. +- [x] Run `git diff --check` and the prohibited-reference audit. + +### Phase 5 completion record + +- Added comprehensive mixed note/to-do coverage for valid, empty, malformed, + absent, and host-note blocks. +- Covered kanban placement, both matrix modes, all to-do modes, completion and + recurrence invariants, source IDs, warnings, and exact card totals. +- Verified mixed due-date, title, and modified-date sorting. +- Verified `page-size: 1` does not truncate complete collector arrays or totals; + existing SLICE8 rendering consumes those combined arrays unchanged. +- Focused validation: 4 suites and 89 tests passed. +- Full validation: 13 suites and 185 tests passed. +- TypeScript validation passed. +- Production build passed: `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl` + (155 KB; SHA-256 + `7257bb2bdbb8340539cdad3f88a2ac5f8b55bfa960816f16e472e047311db1cc`). +- `git diff --check` and the prohibited-reference audit passed. +- No manual Joplin acceptance was performed; that remains Phase 7. ### Phase 6 β€” Documentation -- [ ] 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] Update README.md kanban and matrix sections with a normal-note example. +- [x] State clearly that normal notes require a `gtd` block and that `todos:` controls only to-dos. -- [ ] 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. -- [ ] 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. -- [ ] 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. +### Phase 6 completion record + +- README.md now documents ordinary-note opt-in, kanban placement, both matrix + modes, note dates, `todos:` isolation, empty/malformed blocks, fallback + glyphs, custom icons, and clickable source-note behavior. +- SPEC.md now records the inclusion table, shared card model, collection flow, + rendering rules, body-fetch tradeoff, and completed unreleased feature. +- CHANGELOG.md includes the unreleased SLICE9 feature and validation results + without changing the package version. +- The strict prohibited-reference and contradictory-wording audits passed. + ### Phase 7 β€” Manual Joplin acceptance Use mixed views containing ordinary notes, incomplete to-dos, completed to-dos, empty blocks, malformed blocks, and plain notes. -- [ ] Confirm only opted-in normal notes appear. -- [ ] Confirm empty blocks opt in and malformed blocks warn without disappearing. -- [ ] Confirm normal-note Backlog/In Progress placement on kanban. -- [ ] Confirm normal notes never appear in Done. -- [ ] Confirm all four quadrants in both matrix modes behave as specified. -- [ ] Confirm block title/date/colour/icon/text overrides render on normal notes. -- [ ] Confirm neutral note glyph and custom-icon precedence. -- [ ] Confirm normal notes do not show completion or recurrence styling. -- [ ] Confirm clicking note cards opens the correct source note. -- [ ] Confirm `todos: none`, `gtd-only`, and `all` still affect only to-dos. -- [ ] Confirm SLICE8 limits and remaining counts include the mixed card total. -- [ ] Confirm sorting, warnings, headings, statistics, and reload behavior. -- [ ] Record user sign-off here; do not mark manual acceptance complete before +**Acceptance package:** `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl` +(SHA-256 +`7257bb2bdbb8340539cdad3f88a2ac5f8b55bfa960816f16e472e047311db1cc`). + +**Accepted:** the user reported all manual checks passed on 2026-07-27. + +- [x] Confirm only opted-in normal notes appear. +- [x] Confirm empty blocks opt in and malformed blocks warn without disappearing. +- [x] Confirm normal-note Backlog/In Progress placement on kanban. +- [x] Confirm normal notes never appear in Done. +- [x] Confirm all four quadrants in both matrix modes behave as specified. +- [x] Confirm block title/date/colour/icon/text overrides render on normal notes. +- [x] Confirm neutral note glyph and custom-icon precedence. +- [x] Confirm normal notes do not show completion or recurrence styling. +- [x] Confirm clicking note cards opens the correct source note. +- [x] Confirm `todos: none`, `gtd-only`, and `all` still affect only to-dos. +- [x] Confirm SLICE8 limits and remaining counts include the mixed card total. +- [x] Confirm sorting, warnings, headings, statistics, and reload behavior. +- [x] Record user sign-off here; do not mark manual acceptance complete before confirmation. ## Acceptance criteria diff --git a/SPEC.md b/SPEC.md index be07e3c..1f33de4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,8 +1,8 @@ # GTD Calendar β€” Project Specification -*Status: living document, current through v0.5.0. Sections 1–5 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 1–5 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) **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) ``` -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) @@ -111,9 +127,9 @@ card-detail: hover 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). @@ -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. 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 @@ -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). +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 -**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). - **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`). -- **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. -- **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. +- **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches. +- **Matrix** renders eligible notes and incomplete to-dos in a 2Γ—2 grid with axis and quadrant labels supplied per mode; cards are visually and behaviourally identical to kanban cards. Each quadrant owns an independent batch counter. - **Incremental card lists:** collectors always return complete sorted arrays and full statistics. The main process sends normalized `pageSize`; the webview owns transient `visibleCount` state per column/quadrant and appends one batch per **List more** click. A linked tooltip (`aria-describedby`) reports the exact remaining count on pointer hover and keyboard focus. No expansion state is persisted; rerender/reload resets it. -- **Drilldown:** every tile, card, hover-card row, and chip across all 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 -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 / -β”‚ script (renderer) β”‚ ```gtd-matrix fences. Parses YAML config, +β”‚ 1. Markdown-it content β”‚ Intercepts the four view fence types +β”‚ script (renderer) β”‚ and parses their YAML configuration, β”‚ β”‚ emits a placeholder
tagged with β”‚ β”‚ data-block-type and the config as a β”‚ β”‚ 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 β”‚ (runs in rendered note) β”‚ webviewApi.postMessage(config) with -β”‚ β”‚ getEvents / getKanban / getMatrix +β”‚ β”‚ getEvents / getKanban / getMatrix / getGantt β”‚ β”‚ depending on block type. Builds the -β”‚ β”‚ calendar / board / matrix DOM from the +β”‚ β”‚ calendar / board / matrix / Gantt DOM from β”‚ β”‚ returned payload. Click β†’ postMessage β”‚ β”‚ ({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 β”‚ β”‚ β€’ parses ```gtd blocks from bodies β”‚ β”‚ (src/Gtd/gtdBlock.ts) -β”‚ β”‚ β€’ fetches tag titles per to-do +β”‚ β”‚ β€’ fetches tag titles per eligible item β”‚ β”‚ (getNoteTagTitles) for recurrence / β”‚ β”‚ in-progress / urgent / important β”‚ β”‚ detection β”‚ β”‚ β€’ applies the inclusion matrix, date β”‚ β”‚ resolution, and view-specific β”‚ β”‚ bucketing (collectEvents.ts / -β”‚ β”‚ collectKanban.ts / collectMatrix.ts) +β”‚ β”‚ collectKanban.ts / collectMatrix.ts / +β”‚ β”‚ collectGantt.ts) β”‚ β”‚ β€’ handles openNote via β”‚ β”‚ 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 - 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. ### 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. -**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. @@ -365,7 +415,7 @@ A fourth display block: a 2Γ—2 prioritisation matrix, tag-driven like the kanban - urgent only β†’ **Delegate** - neither β†’ **Eliminate** (the default bucket for untagged to-dos β€” a deliberately pointed default) - **Completed to-dos are excluded entirely** β€” the matrix is a prioritisation view, not a tracking view; kanban's Done column is where completions live. -- 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. ### 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. - **`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 @@ -392,6 +442,19 @@ an accessible hover/focus tooltip, preserves focus between intermediate clicks, and disappears at completion. Reload resets all expansion. Automated validation passed at 164 tests and manual Joplin acceptance passed 2026-07-27. +### Unreleased β€” Opted-in note cards for kanban/matrix β€” COMPLETE + +Ordinary notes containing a `gtd` block now participate in kanban and matrix +views. Empty and malformed blocks opt in; malformed YAML warns and falls back to +source-note values. Notes use `gtd` dates only, share title/colour/icon/text +overrides, render with πŸ“„ when no icon is supplied, and never become completed or +recurring. `todos:` continues to govern only to-dos. Kanban places notes in +Backlog or In Progress; both matrix modes reuse their existing tag/date axes. +Body inspection is always enabled for these two collectors, while tag lookup +remains eligibility-gated. Automated validation passed at 185 tests, the +production JPL built successfully, and manual Joplin acceptance passed +2026-07-27. + ### v0.5.0+ β€” The authoring axis (drag-and-drop + create-from-view) β€” one deliberate epic Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault: diff --git a/TASKS.md b/TASKS.md index 58b3b30..bf4c6e6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -7,7 +7,7 @@ manual Joplin acceptance recorded separately. ## Overall status - [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 Implementation order: SLICE8, then SLICE9, then SLICE10. @@ -74,49 +74,49 @@ Implementation order: SLICE8, then SLICE9, then SLICE10. ### Collection and types -- [ ] Update kanban collection to inspect ordinary notes (`is_todo: 0`). -- [ ] Include an ordinary note only when its body contains a `gtd` block. -- [ ] Keep ordinary notes without a `gtd` block excluded. -- [ ] Keep the host view note excluded even when it contains a `gtd` block. -- [ ] Preserve current `todos:` behavior for to-do notes. -- [ ] Place eligible normal notes with the configured in-progress tag in the +- [x] Update kanban collection to inspect ordinary notes (`is_todo: 0`). +- [x] Include an ordinary note only when its body contains a `gtd` block. +- [x] Keep ordinary notes without a `gtd` block excluded. +- [x] Keep the host view note excluded even when it contains a `gtd` block. +- [x] Preserve current `todos:` behavior for to-do notes. +- [x] Place eligible normal notes with the configured in-progress tag in the kanban In Progress column. -- [ ] Place other eligible normal notes in the kanban Backlog column. -- [ ] Never place normal notes in the kanban Done column. -- [ ] Apply existing urgent/important tag bucketing to normal notes in an +- [x] Place other eligible normal notes in the kanban Backlog column. +- [x] Never place normal notes in the kanban Done column. +- [x] Apply existing urgent/important tag bucketing to normal notes in an Eisenhower matrix. -- [ ] Apply existing date bucketing to normal notes in a Skeleton matrix. -- [ ] Reuse `gtd` title, date, colour, icon, and text overrides on note cards. -- [ ] Ensure normal notes are never marked completed or recurring. -- [ ] Update card types to represent notes and to-dos accurately. -- [ ] Fetch note bodies wherever opted-in notes may be collected while retaining +- [x] Apply existing date bucketing to normal notes in a Skeleton matrix. +- [x] Reuse `gtd` title, date, colour, icon, and text overrides on note cards. +- [x] Ensure normal notes are never marked completed or recurring. +- [x] Update card types to represent notes and to-dos accurately. +- [x] Fetch note bodies wherever opted-in notes may be collected while retaining only safe body-fetch optimizations. ### Tests -- [ ] Test kanban inclusion for a note containing a valid `gtd` block. -- [ ] Test kanban exclusion for a plain note. -- [ ] Test kanban Backlog and In Progress placement for note cards. -- [ ] Test host-note exclusion and malformed `gtd` block warnings. -- [ ] Test that current to-do and Done behavior is unchanged. -- [ ] Test note-card bucketing in Skeleton matrix mode. -- [ ] Test note-card bucketing in Eisenhower matrix mode. -- [ ] Test completed-to-do exclusion remains unchanged in matrices. -- [ ] 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 kanban inclusion for a note containing a valid `gtd` block. +- [x] Test kanban exclusion for a plain note. +- [x] Test kanban Backlog and In Progress placement for note cards. +- [x] Test host-note exclusion and malformed `gtd` block warnings. +- [x] Test that current to-do and Done behavior is unchanged. +- [x] Test note-card bucketing in Skeleton matrix mode. +- [x] Test note-card bucketing in Eisenhower matrix mode. +- [x] Test completed-to-do exclusion remains unchanged in matrices. +- [x] Test mixed note/to-do sorting and click-to-open payloads. +- [x] Test that SLICE8 limits use the combined note and to-do card count. ### Documentation and acceptance -- [ ] Document note-card opt-in rules and examples in README.md. -- [ ] Update SPEC.md with mixed note/to-do behavior. -- [ ] Add the feature to CHANGELOG.md. -- [ ] Run the full automated test suite and record command/results. -- [ ] Run the production package build and record command/artifact. -- [ ] Manually verify mixed note/to-do kanban views in Joplin. -- [ ] Manually verify mixed note/to-do views in both matrix modes in Joplin. -- [ ] Confirm existing to-do filtering, completion, sorting, styling, warnings, +- [x] Document note-card opt-in rules and examples in README.md. +- [x] Update SPEC.md with mixed note/to-do behavior. +- [x] Add the feature to CHANGELOG.md. +- [x] Run the full automated test suite and record command/results. +- [x] Run the production package build and record command/artifact. +- [x] Manually verify mixed note/to-do kanban views in Joplin. +- [x] Manually verify mixed note/to-do views in both matrix modes in Joplin. +- [x] Confirm existing to-do filtering, completion, sorting, styling, warnings, and navigation do not regress. -- [ ] 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 @@ -167,9 +167,9 @@ Implementation order: SLICE8, then SLICE9, then SLICE10. - [ ] Document `scope: children` multi-view behavior in README.md. - [ ] Add a parent/child/grandchild notebook example to README.md or SPEC.md. - [ ] Update architecture and payload behavior in SPEC.md. -- [ ] Add the feature to CHANGELOG.md. -- [ ] Run the full automated test suite and record command/results. -- [ ] Run the production package build and record command/artifact. +- [x] Add the feature to CHANGELOG.md. +- [x] Run the full automated test suite and record command/results. +- [x] Run the production package build and record command/artifact. - [ ] Manually verify a parent notebook containing multiple children and grandchildren in Joplin. - [ ] Confirm headings, ordering, empty-group handling, no duplication, sorting, diff --git a/src/Gtd/buildKanbanCard.ts b/src/Gtd/buildKanbanCard.ts new file mode 100644 index 0000000..df1385b --- /dev/null +++ b/src/Gtd/buildKanbanCard.ts @@ -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, + }; +} diff --git a/src/Gtd/collectKanban.ts b/src/Gtd/collectKanban.ts index ab81eba..666849c 100644 --- a/src/Gtd/collectKanban.ts +++ b/src/Gtd/collectKanban.ts @@ -3,12 +3,10 @@ import { KanbanBoard, KanbanCard, KanbanConfig, - RawNote, - RECURRING_TAG, } from "./types"; import resolveScopedFolderIds from "./folderScope"; import extractGtdBlock from "./gtdBlock"; -import { resolveEventDate } from "./resolveDate"; +import buildKanbanCard from "./buildKanbanCard"; export interface KanbanResult { board: KanbanBoard; @@ -56,21 +54,22 @@ export default async function collectKanban( ? -Infinity : now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000; - // Bodies are only scanned (gtd block β†’ card styling/title/date) when to-dos - // are actually included; "none" yields an empty board, so skip fetching them. - const needsBody = config.todos !== "none"; + // Ordinary notes opt in through their gtd block, independently of the + // to-do inclusion mode, so bodies are required for every scanned note. for (const folderId of folderIds) { - const notes = await adapter.getNotesInFolder(folderId, needsBody); + const notes = await adapter.getNotesInFolder(folderId, true); for (const note of notes) { scannedNotes += 1; if (note.id === kanbanNoteId) continue; - if (!note.is_todo) continue; // to-dos only - - if (config.todos === "none") continue; 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) { warnings.push( @@ -79,7 +78,7 @@ export default async function collectKanban( } const tags = await adapter.getNoteTagTitles(note.id); - const card = buildCard(note, result.block, tags); + const card = buildKanbanCard(note, result.block, tags); // Bucketing β€” Done wins. if (card.completed) { @@ -106,25 +105,6 @@ export default async function collectKanban( }; } -function buildCard( - note: RawNote, - block: ReturnType["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 { const direction = config.sort === "desc" ? -1 : 1; diff --git a/src/Gtd/collectMatrix.ts b/src/Gtd/collectMatrix.ts index c1e347d..3dba457 100644 --- a/src/Gtd/collectMatrix.ts +++ b/src/Gtd/collectMatrix.ts @@ -3,12 +3,10 @@ import { KanbanCard, MatrixBoard, MatrixConfig, - RawNote, - RECURRING_TAG, } from "./types"; import resolveScopedFolderIds from "./folderScope"; import extractGtdBlock from "./gtdBlock"; -import { resolveEventDate } from "./resolveDate"; +import buildKanbanCard from "./buildKanbanCard"; export interface MatrixResult { board: MatrixBoard; @@ -58,21 +56,23 @@ export default async function collectMatrix( // Skeleton mode: a date on or before this ISO threshold is "due soon". const soonThreshold = isoDaysFromNow(now, config.urgentWindow); - // Bodies are only scanned (gtd block β†’ card styling/title/date) when to-dos - // are actually included; "none" yields an empty matrix, so skip fetching them. - const needsBody = config.todos !== "none"; + // Ordinary notes opt in through their gtd block, independently of the + // to-do inclusion mode, so bodies are required for every scanned note. for (const folderId of folderIds) { - const notes = await adapter.getNotesInFolder(folderId, needsBody); + const notes = await adapter.getNotesInFolder(folderId, true); for (const note of notes) { scannedNotes += 1; 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); - 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) { warnings.push( @@ -81,7 +81,7 @@ export default async function collectMatrix( } 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 leftColumn: boolean; @@ -137,25 +137,6 @@ function isoDaysFromNow(now: Date, days: number): string { ); } -function buildCard( - note: RawNote, - block: ReturnType["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 { const direction = config.sort === "desc" ? -1 : 1; diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 7fe4b27..91eabe8 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -143,11 +143,12 @@ export interface KanbanConfig { warnings: string[]; } -/** A to-do as a kanban card. */ +/** A note or to-do rendered as a kanban/matrix card. */ export interface KanbanCard { id: string; title: string; date: string | null; + isTodo: boolean; completed: boolean; completedTime: number; isRecurring: boolean; diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index dce0207..b6aa12b 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -636,7 +636,13 @@ const titleRow = document.createElement("div"); 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" : ""; titleRow.textContent = glyph + recur + " " + card.title; if (card.completed) titleRow.style.textDecoration = "line-through"; diff --git a/src/tests/Gtd/buildKanbanCard.test.ts b/src/tests/Gtd/buildKanbanCard.test.ts new file mode 100644 index 0000000..592b4ee --- /dev/null +++ b/src/tests/Gtd/buildKanbanCard.test.ts @@ -0,0 +1,91 @@ +import buildKanbanCard from "../../Gtd/buildKanbanCard"; +import { GtdBlock, RawNote } from "../../Gtd/types"; + +function makeNote(overrides: Partial = {}): 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 { + 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, + }); + }); +}); diff --git a/src/tests/Gtd/fieldsHint.test.ts b/src/tests/Gtd/fieldsHint.test.ts index 453c8f8..f2d1ed7 100644 --- a/src/tests/Gtd/fieldsHint.test.ts +++ b/src/tests/Gtd/fieldsHint.test.ts @@ -7,11 +7,10 @@ import parseMatrixConfig from "../../Gtd/parseMatrixConfig"; import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types"; // --------------------------------------------------------------------------- -// Body-fetch optimization (SLICE1 Task 4): each collector must ask the adapter -// for note bodies ONLY when its inclusion mode means the gtd block gets -// scanned. When the view can produce nothing (all relevant modes "none"), the -// body field is skipped. This test records the `includeBody` arg the collectors -// pass to getNotesInFolder. +// Body-fetch contract: the calendar may skip bodies when both item modes are +// "none". Kanban and matrix always require bodies because ordinary notes opt in +// through a gtd block independently of the to-do inclusion mode. These tests +// record the `includeBody` argument passed to getNotesInFolder. // --------------------------------------------------------------------------- const folders: RawFolder[] = [{ id: "folder", parent_id: "root" }]; @@ -66,7 +65,7 @@ describe("collectKanban β€” body fetch hint", () => { test.each([ ["gtd-only", { todos: "gtd-only" }, true], ["all", { todos: "all" }, true], - ["none", { todos: "none" }, false], + ["none", { todos: "none" }, true], ])("%s β†’ includeBody %s", async (_label, raw, expected) => { const { adapter, bodyFlags } = recordingAdapter([makeNote()]); const config = parseKanbanConfig(raw as any); @@ -79,7 +78,7 @@ describe("collectMatrix β€” body fetch hint", () => { test.each([ ["gtd-only", { todos: "gtd-only" }, true], ["all", { todos: "all" }, true], - ["none", { todos: "none" }, false], + ["none", { todos: "none" }, true], ])("%s β†’ includeBody %s", async (_label, raw, expected) => { const { adapter, bodyFlags } = recordingAdapter([makeNote()]); const config = parseMatrixConfig(raw as any); diff --git a/src/tests/Gtd/kanban.test.ts b/src/tests/Gtd/kanban.test.ts index 2c67486..3b21e60 100644 --- a/src/tests/Gtd/kanban.test.ts +++ b/src/tests/Gtd/kanban.test.ts @@ -166,26 +166,146 @@ describe("collectKanban β€” bucketing", () => { 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 = [ 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( makeAdapter(folders, notes), "kanban-note", "board", - parseKanbanConfig({ todos: "all", notes: "all" } as any), + parseKanbanConfig({ todos: "all" }), NOW ); - const allIds = [ - ...result.board.backlog, - ...result.board.inProgress, - ...result.board.done, - ].map((c) => c.id); - expect(allIds).toEqual(["t"]); + + expect(result.board.backlog.map((c) => c.id)).toEqual([ + "t", + "gtd-note", + ]); + 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 => [] + ); + 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 () => { const notes = [makeNote({ id: "x", title: "WIP item" })]; const result = await collectKanban( @@ -199,7 +319,9 @@ describe("collectKanban β€” bucketing", () => { }); 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( makeAdapter(folders, notes), "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", () => { test("only completions within the window appear in Done", async () => { const notes = [ diff --git a/src/tests/Gtd/matrix.test.ts b/src/tests/Gtd/matrix.test.ts index 3678b99..46b0bc2 100644 --- a/src/tests/Gtd/matrix.test.ts +++ b/src/tests/Gtd/matrix.test.ts @@ -126,10 +126,15 @@ const folders: RawFolder[] = [{ id: "board", parent_id: "root" }]; describe("collectMatrix β€” quadrant bucketing", () => { test("all four quadrants route correctly; untagged lands in Eliminate", async () => { const notes = [ - makeNote({ id: "both", title: "Crisis" }), - makeNote({ id: "imp", title: "Strategy" }), - makeNote({ id: "urg", title: "Interruption" }), - makeNote({ id: "none", title: "Timewaster" }), + makeNote({ + id: "both", + is_todo: 0, + 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( makeAdapter(folders, notes, { @@ -184,11 +189,21 @@ describe("collectMatrix β€” quadrant bucketing", () => { 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 = [ - makeNote({ id: "matrix-note", title: "The matrix note" }), - makeNote({ id: "n", is_todo: 0, title: "A note" }), - makeNote({ id: "t", title: "A todo" }), + makeNote({ + id: "matrix-note", + 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( makeAdapter(folders, notes), @@ -196,8 +211,16 @@ describe("collectMatrix β€” quadrant bucketing", () => { "board", parseMatrixConfig({ todos: "all", mode: "eisenhower" }) ); - expect(result.cardCount).toBe(1); - expect(result.board.bottomRight.map((c) => c.id)).toEqual(["t"]); + expect(result.cardCount).toBe(2); + 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 () => { @@ -215,6 +238,54 @@ describe("collectMatrix β€” quadrant bucketing", () => { 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 => [] + ); + 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 () => { const notes = [ 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", () => { 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 () => { const notes = [ // 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 - 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 - 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 - makeNote({ id: "backlog" }), + makeNote({ id: "backlog", is_todo: 0 }), ]; const result = await collectMatrix( makeAdapter(folders, notes, { @@ -281,7 +444,7 @@ describe("collectMatrix β€” Skeleton mode", () => { }); 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( makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }), "matrix-note", @@ -293,7 +456,7 @@ describe("collectMatrix β€” Skeleton mode", () => { }); 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( makeAdapter(folders, notes, { flag: ["urgent"] }), "matrix-note",