SLICE10-child-notebook-kanban-and-matrix-views

This commit is contained in:
Victor Wiebe 2026-07-28 09:38:43 -04:00
parent d0ed2c4769
commit f4483ef496
17 changed files with 1308 additions and 273 deletions

View File

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

View File

@ -122,57 +122,59 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
### Scope and folder grouping ### Scope and folder grouping
- [ ] Preserve whether normalized kanban and matrix configuration came from the - [x] Preserve explicit `scope: children` plus `group: notebook` intent in
explicit `scope: children` keyword. normalized kanban and matrix configuration.
- [ ] Keep numeric scope, `this-folder`, and `scope: all` semantics unchanged. - [x] Keep numeric scope, `this-folder`, and `scope: all` semantics unchanged.
- [ ] Add a shared folder-tree helper that returns notebook IDs, titles, paths, - [x] Add a shared folder-tree helper that returns notebook IDs, titles, paths,
and parent relationships in deterministic tree/path order. and parent relationships in deterministic tree/path order.
- [ ] Handle nested notebooks and duplicate notebook names correctly. - [x] Handle nested notebooks and duplicate notebook names correctly.
- [ ] Create one result group for cards owned directly by the root notebook. - [x] Create one result group for cards owned directly by the root notebook.
- [ ] Create one result group for cards owned directly by each descendant - [x] Create one result group for cards owned directly by each descendant
notebook. notebook.
- [ ] Ensure every in-scope card appears in exactly one notebook group. - [x] Ensure every in-scope card appears in exactly one notebook group.
- [ ] Omit empty notebook groups. - [x] Omit empty notebook groups.
- [ ] Preserve one overall empty-state presentation when all groups are empty. - [x] Preserve one overall empty-state presentation when all groups are empty.
### Payloads and rendering ### Payloads and rendering
- [ ] Add named per-notebook kanban and matrix groups to collection result types. - [x] Add named per-notebook kanban and matrix groups to collection result types.
- [ ] Retain compatible single-view results for every scope except `children`. - [x] Retain compatible single-view results unless both `scope: children` and
- [ ] Provide aggregate and per-view statistics without double-counting notes or `group: notebook` are selected.
- [x] Provide aggregate and per-view statistics without double-counting notes or
cards. cards.
- [ ] Preserve warnings at the appropriate overall or notebook-view level. - [x] Preserve warnings at the appropriate overall or notebook-view level.
- [ ] Update plugin-to-webview messages and handlers for grouped results. - [x] Update plugin-to-webview messages and handlers for grouped results.
- [ ] Render a complete kanban or matrix component for every non-empty group. - [x] Render a complete kanban or matrix component for every non-empty group.
- [ ] Use the full notebook path as each view heading. - [x] Use the full notebook path as each view heading.
- [ ] Give every generated view independent SLICE8 expansion state. - [x] Give every generated view independent SLICE8 expansion state.
- [ ] Apply the configured `page-size` independently within every view bucket. - [x] Apply the configured `page-size` independently within every view bucket.
- [ ] Group SLICE9 note cards by owning notebook in the same way as to-do cards. - [x] Group SLICE9 note cards by owning notebook in the same way as to-do cards.
### Tests ### Tests
- [ ] Test preservation of the explicit `children` scope token. - [x] Test preservation of explicit `children` plus `group: notebook` intent.
- [ ] Test deterministic folder ordering, nested paths, duplicate names, and - [x] Test deterministic folder ordering, nested paths, duplicate names, and
empty groups. empty groups.
- [ ] Test that kanban cards appear exactly once in their owning notebook group. - [x] Test that kanban cards appear exactly once in their owning notebook group.
- [ ] Test the same invariant in Skeleton and Eisenhower matrices. - [x] Test the same invariant in Skeleton and Eisenhower matrices.
- [ ] Include both note and to-do cards in grouping tests. - [x] Include both note and to-do cards in grouping tests.
- [ ] Test aggregate statistics and warnings for double-counting regressions. - [x] Test aggregate statistics and warnings for double-counting regressions.
- [ ] Test independent batching and "List more" state across generated views. - [ ] Test independent batching and "List more" state across generated views.
- [ ] Test that numeric scope, `this-folder`, and `scope: all` still produce one - [x] Test that ungrouped `children`, numeric scope, `this-folder`, and
view with unchanged behavior. `scope: all` still produce one view with unchanged behavior.
### Documentation and acceptance ### Documentation and acceptance
- [ ] Document `scope: children` multi-view behavior in README.md. - [ ] Document `scope: children` plus `group: notebook` 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.
- [x] Add the feature to CHANGELOG.md. - [ ] Add the feature to CHANGELOG.md.
- [x] Run the full automated test suite and record command/results. - [x] Run the full automated test suite and record command/results.
- [x] 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 - [x] Manually verify a parent notebook containing multiple children and
grandchildren in Joplin. grandchildren in Joplin.
- [ ] Confirm headings, ordering, empty-group handling, no duplication, sorting, - [x] Confirm headings, ordering, empty-group handling, no duplication, sorting,
styling, navigation, warnings, and independent expansion. styling, navigation, warnings, and independent expansion.
- [ ] Mark SLICE10 complete in `SLICE10.md` and this file. - [ ] Mark SLICE10 complete in `SLICE10.md` and this file.
@ -180,10 +182,10 @@ Implementation order: SLICE8, then SLICE9, then SLICE10.
- [ ] Run the complete automated suite after all three slices are integrated. - [ ] Run the complete automated suite after all three slices are integrated.
- [ ] Build the final production `.jpl` and record its path and version. - [ ] Build the final production `.jpl` and record its path and version.
- [ ] Verify a dashboard containing kanban and both matrix modes together. - [x] Verify a dashboard containing kanban and both matrix modes together.
- [ ] Verify more than 20 mixed note/to-do cards across multiple child notebooks. - [x] Verify more than 20 mixed note/to-do cards across multiple child notebooks.
- [ ] Confirm reload resets every expanded list to its configured initial size. - [x] Confirm reload resets every expanded list to its configured initial size.
- [ ] Record manual Joplin acceptance without conflating it with automated checks. - [x] Record manual Joplin acceptance without conflating it with automated checks.
- [ ] Re-run the repository-wide prohibited-reference audit. - [ ] Re-run the repository-wide prohibited-reference audit.
- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE810 reflect the - [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE810 reflect the
final implementation state. final implementation state.

View File

@ -3,12 +3,17 @@ import {
KanbanBoard, KanbanBoard,
KanbanCard, KanbanCard,
KanbanConfig, KanbanConfig,
KanbanLayout,
NotebookKanbanGroup,
} from "./types"; } from "./types";
import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderIds from "./folderScope";
import resolveScopedFolderMetadata from "./folderTree";
import extractGtdBlock from "./gtdBlock"; import extractGtdBlock from "./gtdBlock";
import buildKanbanCard from "./buildKanbanCard"; import buildKanbanCard from "./buildKanbanCard";
export interface KanbanResult { export interface KanbanResult {
layout: KanbanLayout;
/** @deprecated Phase 4 moves payload consumers to layout. Single-view alias. */
board: KanbanBoard; board: KanbanBoard;
warnings: string[]; warnings: string[];
scannedFolders: number; scannedFolders: number;
@ -37,17 +42,29 @@ export default async function collectKanban(
const warnings: string[] = []; const warnings: string[] = [];
const folders = await adapter.getFolders(); const folders = await adapter.getFolders();
const folderIds = resolveScopedFolderIds( const folderMetadata = config.groupByNotebook
folders, ? resolveScopedFolderMetadata(folders, kanbanFolderId)
kanbanFolderId, : [];
config.scopeDepth, const folderIds = config.groupByNotebook
config.scopeAll ? folderMetadata.map((folder) => folder.id)
); : resolveScopedFolderIds(
folders,
kanbanFolderId,
config.scopeDepth,
config.scopeAll
);
let scannedNotes = 0; let scannedNotes = 0;
const backlog: KanbanCard[] = []; const backlog: KanbanCard[] = [];
const inProgress: KanbanCard[] = []; const inProgress: KanbanCard[] = [];
const done: KanbanCard[] = []; const done: KanbanCard[] = [];
const grouped = new Map<string, { board: KanbanBoard; warnings: string[] }>();
for (const folder of folderMetadata) {
grouped.set(folder.id, {
board: { backlog: [], inProgress: [], done: [] },
warnings: [],
});
}
const doneCutoff = const doneCutoff =
config.doneWindow === Infinity config.doneWindow === Infinity
@ -58,6 +75,8 @@ export default async function collectKanban(
// to-do inclusion mode, so bodies are required for every scanned note. // to-do inclusion mode, so bodies are required for every scanned note.
for (const folderId of folderIds) { for (const folderId of folderIds) {
const target = grouped.get(folderId);
const targetBoard = target?.board || { backlog, inProgress, done };
const notes = await adapter.getNotesInFolder(folderId, true); const notes = await adapter.getNotesInFolder(folderId, true);
for (const note of notes) { for (const note of notes) {
scannedNotes += 1; scannedNotes += 1;
@ -72,9 +91,8 @@ export default async function collectKanban(
} }
if (result.error) { if (result.error) {
warnings.push( const warning = `"${note.title}": gtd block problem — ${result.error}`;
`"${note.title}": gtd block problem — ${result.error}` (target?.warnings || warnings).push(warning);
);
} }
const tags = await adapter.getNoteTagTitles(note.id); const tags = await adapter.getNoteTagTitles(note.id);
@ -82,29 +100,62 @@ export default async function collectKanban(
// Bucketing — Done wins. // Bucketing — Done wins.
if (card.completed) { if (card.completed) {
if (card.completedTime >= doneCutoff) done.push(card); if (card.completedTime >= doneCutoff) targetBoard.done.push(card);
// completed but outside the window: dropped from the board // completed but outside the window: dropped from the board
} else if (tags.includes(config.inProgressTag)) { } else if (tags.includes(config.inProgressTag)) {
inProgress.push(card); targetBoard.inProgress.push(card);
} else { } else {
backlog.push(card); targetBoard.backlog.push(card);
} }
} }
} }
sortColumn(backlog, config); const groups: NotebookKanbanGroup[] = [];
sortColumn(inProgress, config); for (const folder of folderMetadata) {
sortColumn(done, config); const target = grouped.get(folder.id) as {
board: KanbanBoard;
warnings: string[];
};
sortBoard(target.board, config);
const cardCount = countBoard(target.board);
if (cardCount > 0) {
groups.push({
folderId: folder.id,
notebookPath: folder.notebookPath,
board: target.board,
cardCount,
warnings: target.warnings,
});
}
}
if (!config.groupByNotebook) sortBoard({ backlog, inProgress, done }, config);
const board = { backlog, inProgress, done };
const layout: KanbanLayout = config.groupByNotebook
? { kind: "notebooks", groups }
: { kind: "single", board };
const cardCount = config.groupByNotebook
? groups.reduce((sum, group) => sum + group.cardCount, 0)
: countBoard(board);
return { return {
board: { backlog, inProgress, done }, layout,
board,
warnings, warnings,
scannedFolders: folderIds.length, scannedFolders: folderIds.length,
scannedNotes, scannedNotes,
cardCount: backlog.length + inProgress.length + done.length, cardCount,
}; };
} }
function sortBoard(board: KanbanBoard, config: KanbanConfig): void {
sortColumn(board.backlog, config);
sortColumn(board.inProgress, config);
sortColumn(board.done, config);
}
function countBoard(board: KanbanBoard): number {
return board.backlog.length + board.inProgress.length + board.done.length;
}
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,17 @@ import {
KanbanCard, KanbanCard,
MatrixBoard, MatrixBoard,
MatrixConfig, MatrixConfig,
MatrixLayout,
NotebookMatrixGroup,
} from "./types"; } from "./types";
import resolveScopedFolderIds from "./folderScope"; import resolveScopedFolderIds from "./folderScope";
import resolveScopedFolderMetadata from "./folderTree";
import extractGtdBlock from "./gtdBlock"; import extractGtdBlock from "./gtdBlock";
import buildKanbanCard from "./buildKanbanCard"; import buildKanbanCard from "./buildKanbanCard";
export interface MatrixResult { export interface MatrixResult {
layout: MatrixLayout;
/** @deprecated Phase 4 moves payload consumers to layout. Single-view alias. */
board: MatrixBoard; board: MatrixBoard;
warnings: string[]; warnings: string[];
scannedFolders: number; scannedFolders: number;
@ -40,18 +45,30 @@ export default async function collectMatrix(
const warnings: string[] = []; const warnings: string[] = [];
const folders = await adapter.getFolders(); const folders = await adapter.getFolders();
const folderIds = resolveScopedFolderIds( const folderMetadata = config.groupByNotebook
folders, ? resolveScopedFolderMetadata(folders, matrixFolderId)
matrixFolderId, : [];
config.scopeDepth, const folderIds = config.groupByNotebook
config.scopeAll ? folderMetadata.map((folder) => folder.id)
); : resolveScopedFolderIds(
folders,
matrixFolderId,
config.scopeDepth,
config.scopeAll
);
let scannedNotes = 0; let scannedNotes = 0;
const topLeft: KanbanCard[] = []; const topLeft: KanbanCard[] = [];
const topRight: KanbanCard[] = []; const topRight: KanbanCard[] = [];
const bottomLeft: KanbanCard[] = []; const bottomLeft: KanbanCard[] = [];
const bottomRight: KanbanCard[] = []; const bottomRight: KanbanCard[] = [];
const grouped = new Map<string, { board: MatrixBoard; warnings: string[] }>();
for (const folder of folderMetadata) {
grouped.set(folder.id, {
board: { topLeft: [], topRight: [], bottomLeft: [], bottomRight: [] },
warnings: [],
});
}
// 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);
@ -60,6 +77,13 @@ export default async function collectMatrix(
// to-do inclusion mode, so bodies are required for every scanned note. // to-do inclusion mode, so bodies are required for every scanned note.
for (const folderId of folderIds) { for (const folderId of folderIds) {
const target = grouped.get(folderId);
const targetBoard = target?.board || {
topLeft,
topRight,
bottomLeft,
bottomRight,
};
const notes = await adapter.getNotesInFolder(folderId, true); const notes = await adapter.getNotesInFolder(folderId, true);
for (const note of notes) { for (const note of notes) {
scannedNotes += 1; scannedNotes += 1;
@ -75,9 +99,8 @@ export default async function collectMatrix(
} }
if (result.error) { if (result.error) {
warnings.push( const warning = `"${note.title}": gtd block problem — ${result.error}`;
`"${note.title}": gtd block problem — ${result.error}` (target?.warnings || warnings).push(warning);
);
} }
const tags = await adapter.getNoteTagTitles(note.id); const tags = await adapter.getNoteTagTitles(note.id);
@ -100,31 +123,68 @@ export default async function collectMatrix(
(card.date !== null && card.date <= soonThreshold); (card.date !== null && card.date <= soonThreshold);
} }
if (topRow && leftColumn) topLeft.push(card); if (topRow && leftColumn) targetBoard.topLeft.push(card);
else if (topRow) topRight.push(card); else if (topRow) targetBoard.topRight.push(card);
else if (leftColumn) bottomLeft.push(card); else if (leftColumn) targetBoard.bottomLeft.push(card);
else bottomRight.push(card); else targetBoard.bottomRight.push(card);
} }
} }
sortColumn(topLeft, config); const groups: NotebookMatrixGroup[] = [];
sortColumn(topRight, config); for (const folder of folderMetadata) {
sortColumn(bottomLeft, config); const target = grouped.get(folder.id) as {
sortColumn(bottomRight, config); board: MatrixBoard;
warnings: string[];
};
sortBoard(target.board, config);
const cardCount = countBoard(target.board);
if (cardCount > 0) {
groups.push({
folderId: folder.id,
notebookPath: folder.notebookPath,
board: target.board,
cardCount,
warnings: target.warnings,
});
}
}
if (!config.groupByNotebook) {
sortBoard({ topLeft, topRight, bottomLeft, bottomRight }, config);
}
const board = { topLeft, topRight, bottomLeft, bottomRight };
const layout: MatrixLayout = config.groupByNotebook
? { kind: "notebooks", groups }
: { kind: "single", board };
const cardCount = config.groupByNotebook
? groups.reduce((sum, group) => sum + group.cardCount, 0)
: countBoard(board);
return { return {
board: { topLeft, topRight, bottomLeft, bottomRight }, layout,
board,
warnings, warnings,
scannedFolders: folderIds.length, scannedFolders: folderIds.length,
scannedNotes, scannedNotes,
cardCount: cardCount,
topLeft.length +
topRight.length +
bottomLeft.length +
bottomRight.length,
}; };
} }
function sortBoard(board: MatrixBoard, config: MatrixConfig): void {
sortColumn(board.topLeft, config);
sortColumn(board.topRight, config);
sortColumn(board.bottomLeft, config);
sortColumn(board.bottomRight, config);
}
function countBoard(board: MatrixBoard): number {
return (
board.topLeft.length +
board.topRight.length +
board.bottomLeft.length +
board.bottomRight.length
);
}
/** ISO yyyy-mm-dd for local `now` plus `days`. */ /** ISO yyyy-mm-dd for local `now` plus `days`. */
function isoDaysFromNow(now: Date, days: number): string { function isoDaysFromNow(now: Date, days: number): string {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days); const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
@ -137,7 +197,6 @@ function isoDaysFromNow(now: Date, days: number): string {
); );
} }
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;

93
src/Gtd/folderTree.ts Normal file
View File

@ -0,0 +1,93 @@
import { RawFolder } from "./types";
export const UNTITLED_NOTEBOOK = "Untitled notebook";
export interface ScopedFolderMetadata {
id: string;
parentId: string;
title: string;
notebookPath: string;
}
/**
* Return the selected root and every reachable descendant in deterministic
* depth-first order. Paths retain ancestors above a notebook-selected root.
*/
export default function resolveScopedFolderMetadata(
folders: RawFolder[],
rootFolderId: string
): ScopedFolderMetadata[] {
const foldersById = new Map<string, RawFolder>();
for (const folder of folders) {
if (!foldersById.has(folder.id)) foldersById.set(folder.id, folder);
}
const root = foldersById.get(rootFolderId);
if (!root) return [];
const childrenByParent = new Map<string, RawFolder[]>();
for (const folder of foldersById.values()) {
const siblings = childrenByParent.get(folder.parent_id) || [];
siblings.push(folder);
childrenByParent.set(folder.parent_id, siblings);
}
for (const siblings of childrenByParent.values()) {
siblings.sort(compareFolders);
}
const result: ScopedFolderMetadata[] = [];
const visited = new Set<string>();
const stack: RawFolder[] = [root];
while (stack.length > 0) {
const folder = stack.pop() as RawFolder;
if (visited.has(folder.id)) continue;
visited.add(folder.id);
result.push({
id: folder.id,
parentId: folder.parent_id,
title: displayTitle(folder),
notebookPath: buildNotebookPath(folder, foldersById),
});
const children = childrenByParent.get(folder.id) || [];
for (let i = children.length - 1; i >= 0; i -= 1) {
if (!visited.has(children[i].id)) stack.push(children[i]);
}
}
return result;
}
function displayTitle(folder: RawFolder): string {
const title = (folder.title || "").trim();
return title || UNTITLED_NOTEBOOK;
}
function compareFolders(a: RawFolder, b: RawFolder): number {
const aTitle = displayTitle(a).toLowerCase();
const bTitle = displayTitle(b).toLowerCase();
if (aTitle < bTitle) return -1;
if (aTitle > bTitle) return 1;
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
}
function buildNotebookPath(
folder: RawFolder,
foldersById: Map<string, RawFolder>
): string {
const segments: string[] = [];
const visited = new Set<string>();
let current: RawFolder | undefined = folder;
while (current && !visited.has(current.id)) {
visited.add(current.id);
segments.unshift(displayTitle(current));
current = foldersById.get(current.parent_id);
}
return segments.join("/");
}

19
src/Gtd/layoutPayload.ts Normal file
View File

@ -0,0 +1,19 @@
import { KanbanBoard, KanbanLayout, MatrixBoard, MatrixLayout } from "./types";
export function emptyKanbanBoard(): KanbanBoard {
return { backlog: [], inProgress: [], done: [] };
}
export function emptyMatrixBoard(): MatrixBoard {
return { topLeft: [], topRight: [], bottomLeft: [], bottomRight: [] };
}
export function emptyKanbanLayout(groupByNotebook: boolean): KanbanLayout {
if (groupByNotebook) return { kind: "notebooks", groups: [] };
return { kind: "single", board: emptyKanbanBoard() };
}
export function emptyMatrixLayout(groupByNotebook: boolean): MatrixLayout {
if (groupByNotebook) return { kind: "notebooks", groups: [] };
return { kind: "single", board: emptyMatrixBoard() };
}

View File

@ -17,6 +17,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
const knownKeys = [ const knownKeys = [
"title", "title",
"scope", "scope",
"group",
"notebook", "notebook",
"todos", "todos",
"sort-type", "sort-type",
@ -38,6 +39,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
// scope (shared semantics with gtd-calendar) // scope (shared semantics with gtd-calendar)
let scopeDepth = 0; let scopeDepth = 0;
let scopeAll = false; let scopeAll = false;
let childrenScope = false;
if (input.scope !== undefined) { if (input.scope !== undefined) {
const value = input.scope; const value = input.scope;
if (value === "all") { if (value === "all") {
@ -46,6 +48,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
scopeDepth = 0; scopeDepth = 0;
} else if (value === "children") { } else if (value === "children") {
scopeDepth = Infinity; scopeDepth = Infinity;
childrenScope = true;
} else if (Number.isInteger(Number(value)) && Number(value) >= 0) { } else if (Number.isInteger(Number(value)) && Number(value) >= 0) {
scopeDepth = Number(value); scopeDepth = Number(value);
} else { } else {
@ -53,6 +56,22 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
} }
} }
let groupByNotebook = false;
if (input.group !== undefined) {
const candidate = String(input.group).trim().toLowerCase();
if (candidate !== "notebook") {
warnings.push(
`Invalid group "${input.group}" (grouping disabled)`
);
} else if (!childrenScope) {
warnings.push(
'"group: notebook" requires scope: children (grouping disabled)'
);
} else {
groupByNotebook = true;
}
}
const notebook = parseNotebookOption(input.notebook); const notebook = parseNotebookOption(input.notebook);
if (scopeAll && notebook) { if (scopeAll && notebook) {
warnings.push( warnings.push(
@ -138,6 +157,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
title, title,
scopeDepth, scopeDepth,
scopeAll, scopeAll,
groupByNotebook,
notebook, notebook,
todos, todos,
sortType, sortType,

View File

@ -26,6 +26,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
"mode", "mode",
"title", "title",
"scope", "scope",
"group",
"notebook", "notebook",
"todos", "todos",
"sort-type", "sort-type",
@ -60,6 +61,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
// scope (shared semantics with gtd-calendar / gtd-kanban) // scope (shared semantics with gtd-calendar / gtd-kanban)
let scopeDepth = 0; let scopeDepth = 0;
let scopeAll = false; let scopeAll = false;
let childrenScope = false;
if (input.scope !== undefined) { if (input.scope !== undefined) {
const value = input.scope; const value = input.scope;
if (value === "all") { if (value === "all") {
@ -68,6 +70,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
scopeDepth = 0; scopeDepth = 0;
} else if (value === "children") { } else if (value === "children") {
scopeDepth = Infinity; scopeDepth = Infinity;
childrenScope = true;
} else if (Number.isInteger(Number(value)) && Number(value) >= 0) { } else if (Number.isInteger(Number(value)) && Number(value) >= 0) {
scopeDepth = Number(value); scopeDepth = Number(value);
} else { } else {
@ -75,6 +78,22 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
} }
} }
let groupByNotebook = false;
if (input.group !== undefined) {
const candidate = String(input.group).trim().toLowerCase();
if (candidate !== "notebook") {
warnings.push(
`Invalid group "${input.group}" (grouping disabled)`
);
} else if (!childrenScope) {
warnings.push(
'"group: notebook" requires scope: children (grouping disabled)'
);
} else {
groupByNotebook = true;
}
}
const notebook = parseNotebookOption(input.notebook); const notebook = parseNotebookOption(input.notebook);
if (scopeAll && notebook) { if (scopeAll && notebook) {
warnings.push( warnings.push(
@ -160,6 +179,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
title, title,
scopeDepth, scopeDepth,
scopeAll, scopeAll,
groupByNotebook,
notebook, notebook,
todos, todos,
sortType, sortType,

View File

@ -98,10 +98,7 @@ export interface DataAdapter {
* may omit the (potentially large) `body` field. Collectors pass false only * may omit the (potentially large) `body` field. Collectors pass false only
* when their inclusion mode guarantees the gtd block is never scanned. * when their inclusion mode guarantees the gtd block is never scanned.
*/ */
getNotesInFolder( getNotesInFolder(folderId: string, includeBody: boolean): Promise<RawNote[]>;
folderId: string,
includeBody: boolean
): Promise<RawNote[]>;
/** Lowercased tag titles for a note. Used to detect recurrence. */ /** Lowercased tag titles for a note. Used to detect recurrence. */
getNoteTagTitles(noteId: string): Promise<string[]>; getNoteTagTitles(noteId: string): Promise<string[]>;
} }
@ -129,6 +126,8 @@ export interface KanbanConfig {
scopeDepth: number; scopeDepth: number;
/** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */ /** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */
scopeAll: boolean; scopeAll: boolean;
/** Separate exact-owner notebook views; requires children + notebook grouping. */
groupByNotebook: boolean;
/** Notebook to root the scan at (id/title/path); null = host folder. */ /** Notebook to root the scan at (id/title/path); null = host folder. */
notebook: string | null; notebook: string | null;
todos: InclusionMode; todos: InclusionMode;
@ -165,6 +164,22 @@ export interface KanbanBoard {
done: KanbanCard[]; done: KanbanCard[];
} }
/** Identity shared by every exact-owner notebook view. */
export interface NotebookViewMetadata {
folderId: string;
notebookPath: string;
cardCount: number;
warnings: string[];
}
export interface NotebookKanbanGroup extends NotebookViewMetadata {
board: KanbanBoard;
}
export type KanbanLayout =
| { kind: "single"; board: KanbanBoard }
| { kind: "notebooks"; groups: NotebookKanbanGroup[] };
/** /**
* Default tags for the Eisenhower matrix axes. Overridable via the * Default tags for the Eisenhower matrix axes. Overridable via the
* gtd-matrix `urgent-tag:` / `important-tag:` options. * gtd-matrix `urgent-tag:` / `important-tag:` options.
@ -181,6 +196,8 @@ export interface MatrixConfig {
scopeDepth: number; scopeDepth: number;
/** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */ /** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */
scopeAll: boolean; scopeAll: boolean;
/** Separate exact-owner notebook views; requires children + notebook grouping. */
groupByNotebook: boolean;
/** Notebook to root the scan at (id/title/path); null = host folder. */ /** Notebook to root the scan at (id/title/path); null = host folder. */
notebook: string | null; notebook: string | null;
todos: InclusionMode; todos: InclusionMode;
@ -213,6 +230,14 @@ export interface MatrixBoard {
bottomRight: KanbanCard[]; bottomRight: KanbanCard[];
} }
export interface NotebookMatrixGroup extends NotebookViewMetadata {
board: MatrixBoard;
}
export type MatrixLayout =
| { kind: "single"; board: MatrixBoard }
| { kind: "notebooks"; groups: NotebookMatrixGroup[] };
/** Display labels for a matrix mode, sent to the webview. */ /** Display labels for a matrix mode, sent to the webview. */
export interface MatrixLabels { export interface MatrixLabels {
columns: [string, string]; columns: [string, string];

View File

@ -291,6 +291,18 @@
margin: 0.5em 0; margin: 0.5em 0;
} }
.gtd-notebook-view + .gtd-notebook-view {
margin-top: 1.25em;
}
.gtd-notebook-view-heading {
margin: 0 0 0.45em;
font-size: 1em;
font-weight: 600;
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
padding-bottom: 0.25em;
}
.gtd-kanban-columns { .gtd-kanban-columns {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);

View File

@ -492,14 +492,63 @@
wrapper.appendChild(warn); wrapper.appendChild(warn);
}); });
const board = payload.board || { const emptyBoard = {
backlog: [], backlog: [],
inProgress: [], inProgress: [],
done: [], done: [],
}; };
const layout = payload.layout || {
kind: "single",
board: payload.board || emptyBoard,
};
const detail = payload.cardDetail || "hover"; const detail = payload.cardDetail || "hover";
const pageSize = payload.pageSize || 10; const pageSize = payload.pageSize || 10;
if (layout.kind === "notebooks" && layout.groups.length > 0) {
layout.groups.forEach(function (group) {
wrapper.appendChild(
renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId)
);
});
} else {
const board = layout.kind === "single" ? layout.board : emptyBoard;
wrapper.appendChild(
renderKanbanBoard(board, pageSize, detail, contentScriptId)
);
}
if (payload.stats) {
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
meta.textContent =
payload.stats.cardCount +
" card(s) from " +
payload.stats.scannedNotes +
" note(s) in " +
payload.stats.scannedFolders +
" folder(s)" +
(payload.scopeAll ? " · scope: all (every notebook)" : "");
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
}
function renderNotebookKanbanGroup(group, pageSize, detail, contentScriptId) {
const section = document.createElement("section");
section.className = "gtd-notebook-view";
const heading = document.createElement("h4");
heading.className = "gtd-notebook-view-heading";
heading.textContent = group.notebookPath;
section.appendChild(heading);
renderViewWarnings(section, group.warnings);
section.appendChild(
renderKanbanBoard(group.board, pageSize, detail, contentScriptId)
);
return section;
}
function renderKanbanBoard(board, pageSize, detail, contentScriptId) {
const columns = document.createElement("div"); const columns = document.createElement("div");
columns.className = "gtd-kanban-columns"; columns.className = "gtd-kanban-columns";
@ -519,23 +568,7 @@
renderColumn("Done", board.done, pageSize, detail, contentScriptId) renderColumn("Done", board.done, pageSize, detail, contentScriptId)
); );
wrapper.appendChild(columns); return columns;
if (payload.stats) {
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
meta.textContent =
payload.stats.cardCount +
" card(s) from " +
payload.stats.scannedNotes +
" note(s) in " +
payload.stats.scannedFolders +
" folder(s)" +
(payload.scopeAll ? " · scope: all (every notebook)" : "");
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
} }
function renderColumn(title, cards, pageSize, detail, contentScriptId) { function renderColumn(title, cards, pageSize, detail, contentScriptId) {
@ -710,12 +743,16 @@
wrapper.appendChild(warn); wrapper.appendChild(warn);
}); });
const board = payload.board || { const emptyBoard = {
topLeft: [], topLeft: [],
topRight: [], topRight: [],
bottomLeft: [], bottomLeft: [],
bottomRight: [], bottomRight: [],
}; };
const layout = payload.layout || {
kind: "single",
board: payload.board || emptyBoard,
};
const labels = payload.labels || { const labels = payload.labels || {
columns: ["Urgent", "Not urgent"], columns: ["Urgent", "Not urgent"],
rows: ["Important", "Not important"], rows: ["Important", "Not important"],
@ -729,6 +766,81 @@
const detail = payload.cardDetail || "hover"; const detail = payload.cardDetail || "hover";
const pageSize = payload.pageSize || 10; const pageSize = payload.pageSize || 10;
if (layout.kind === "notebooks" && layout.groups.length > 0) {
layout.groups.forEach(function (group) {
wrapper.appendChild(
renderNotebookMatrixGroup(
group,
labels,
pageSize,
detail,
contentScriptId
)
);
});
} else {
const board = layout.kind === "single" ? layout.board : emptyBoard;
wrapper.appendChild(
renderMatrixBoard(
board,
labels,
pageSize,
detail,
contentScriptId
)
);
}
if (payload.stats) {
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
meta.textContent =
payload.stats.cardCount +
" card(s) from " +
payload.stats.scannedNotes +
" note(s) in " +
payload.stats.scannedFolders +
" folder(s)" +
(payload.scopeAll ? " · scope: all (every notebook)" : "");
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
}
function renderNotebookMatrixGroup(
group,
labels,
pageSize,
detail,
contentScriptId
) {
const section = document.createElement("section");
section.className = "gtd-notebook-view";
const heading = document.createElement("h4");
heading.className = "gtd-notebook-view-heading";
heading.textContent = group.notebookPath;
section.appendChild(heading);
renderViewWarnings(section, group.warnings);
section.appendChild(
renderMatrixBoard(
group.board,
labels,
pageSize,
detail,
contentScriptId
)
);
return section;
}
function renderMatrixBoard(
board,
labels,
pageSize,
detail,
contentScriptId
) {
// Axis header row: blank corner + column labels // Axis header row: blank corner + column labels
const grid = document.createElement("div"); const grid = document.createElement("div");
grid.className = "gtd-matrix-grid"; grid.className = "gtd-matrix-grid";
@ -781,23 +893,16 @@
) )
); );
wrapper.appendChild(grid); return grid;
}
if (payload.stats) { function renderViewWarnings(parent, warnings) {
const meta = document.createElement("p"); (warnings || []).forEach(function (warning) {
meta.className = "gtd-calendar-debug-meta"; const warn = document.createElement("p");
meta.textContent = warn.className = "gtd-calendar-warning";
payload.stats.cardCount + warn.textContent = "⚠ " + warning;
" card(s) from " + parent.appendChild(warn);
payload.stats.scannedNotes + });
" note(s) in " +
payload.stats.scannedFolders +
" folder(s)" +
(payload.scopeAll ? " · scope: all (every notebook)" : "");
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
} }
function matrixAxisCell(label) { function matrixAxisCell(label) {

View File

@ -14,6 +14,10 @@ import parseGanttConfig from "./Gtd/parseGanttConfig";
import collectGantt from "./Gtd/collectGantt"; import collectGantt from "./Gtd/collectGantt";
import matrixLabels from "./Gtd/matrixLabels"; import matrixLabels from "./Gtd/matrixLabels";
import resolveNotebook from "./Gtd/resolveNotebook"; import resolveNotebook from "./Gtd/resolveNotebook";
import {
emptyKanbanLayout,
emptyMatrixLayout,
} from "./Gtd/layoutPayload";
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
@ -156,8 +160,7 @@ async function handleGetEvents(message: { rawConfig?: string }) {
const config = parseCalendarConfig(rawParsed); const config = parseCalendarConfig(rawParsed);
// 2. Resolve the calendar note (the note being rendered). // 2. Resolve the calendar note (the note being rendered).
let sourceNote: { id: string; title: string; parentId: string } | null = let sourceNote: { id: string; title: string; parentId: string } | null = null;
null;
try { try {
const note = await joplin.workspace.selectedNote(); const note = await joplin.workspace.selectedNote();
if (note) { if (note) {
@ -176,8 +179,7 @@ async function handleGetEvents(message: { rawConfig?: string }) {
title: config.title, title: config.title,
view: config.view, view: config.view,
configError: configError:
configError || configError || "Could not determine which note contains this calendar.",
"Could not determine which note contains this calendar.",
warnings: config.warnings, warnings: config.warnings,
sourceNote: null, sourceNote: null,
events: [], events: [],
@ -230,8 +232,7 @@ async function handleGetKanban(message: { rawConfig?: string }) {
} }
const config = parseKanbanConfig(rawParsed); const config = parseKanbanConfig(rawParsed);
let sourceNote: { id: string; title: string; parentId: string } | null = let sourceNote: { id: string; title: string; parentId: string } | null = null;
null;
try { try {
const note = await joplin.workspace.selectedNote(); const note = await joplin.workspace.selectedNote();
if (note) { if (note) {
@ -246,15 +247,16 @@ async function handleGetKanban(message: { rawConfig?: string }) {
} }
if (!sourceNote) { if (!sourceNote) {
const layout = emptyKanbanLayout(config.groupByNotebook);
return { return {
title: config.title, title: config.title,
cardDetail: config.cardDetail, cardDetail: config.cardDetail,
pageSize: config.pageSize, pageSize: config.pageSize,
scopeAll: config.scopeAll,
configError: configError:
configError || configError || "Could not determine which note contains this kanban.",
"Could not determine which note contains this kanban.",
warnings: config.warnings, warnings: config.warnings,
board: { backlog: [], inProgress: [], done: [] }, layout,
stats: null, stats: null,
}; };
} }
@ -280,7 +282,7 @@ async function handleGetKanban(message: { rawConfig?: string }) {
...result.warnings, ...result.warnings,
...(softCap ? [softCap] : []), ...(softCap ? [softCap] : []),
], ],
board: result.board, layout: result.layout,
stats: { stats: {
scannedFolders: result.scannedFolders, scannedFolders: result.scannedFolders,
scannedNotes: result.scannedNotes, scannedNotes: result.scannedNotes,
@ -299,8 +301,7 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
} }
const config = parseMatrixConfig(rawParsed); const config = parseMatrixConfig(rawParsed);
let sourceNote: { id: string; title: string; parentId: string } | null = let sourceNote: { id: string; title: string; parentId: string } | null = null;
null;
try { try {
const note = await joplin.workspace.selectedNote(); const note = await joplin.workspace.selectedNote();
if (note) { if (note) {
@ -315,21 +316,17 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
} }
if (!sourceNote) { if (!sourceNote) {
const layout = emptyMatrixLayout(config.groupByNotebook);
return { return {
title: config.title, title: config.title,
cardDetail: config.cardDetail, cardDetail: config.cardDetail,
pageSize: config.pageSize, pageSize: config.pageSize,
scopeAll: config.scopeAll,
configError: configError:
configError || configError || "Could not determine which note contains this matrix.",
"Could not determine which note contains this matrix.",
warnings: config.warnings, warnings: config.warnings,
labels: matrixLabels(config.mode), labels: matrixLabels(config.mode),
board: { layout,
topLeft: [],
topRight: [],
bottomLeft: [],
bottomRight: [],
},
stats: null, stats: null,
}; };
} }
@ -356,7 +353,7 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
...(softCap ? [softCap] : []), ...(softCap ? [softCap] : []),
], ],
labels: matrixLabels(config.mode), labels: matrixLabels(config.mode),
board: result.board, layout: result.layout,
stats: { stats: {
scannedFolders: result.scannedFolders, scannedFolders: result.scannedFolders,
scannedNotes: result.scannedNotes, scannedNotes: result.scannedNotes,
@ -375,8 +372,7 @@ async function handleGetGantt(message: { rawConfig?: string }) {
} }
const config = parseGanttConfig(rawParsed); const config = parseGanttConfig(rawParsed);
let sourceNote: { id: string; title: string; parentId: string } | null = let sourceNote: { id: string; title: string; parentId: string } | null = null;
null;
try { try {
const note = await joplin.workspace.selectedNote(); const note = await joplin.workspace.selectedNote();
if (note) { if (note) {
@ -396,8 +392,7 @@ async function handleGetGantt(message: { rawConfig?: string }) {
cardDetail: config.cardDetail, cardDetail: config.cardDetail,
scopeAll: config.scopeAll, scopeAll: config.scopeAll,
configError: configError:
configError || configError || "Could not determine which note contains this gantt.",
"Could not determine which note contains this gantt.",
warnings: config.warnings, warnings: config.warnings,
chart: { projects: [], rangeStart: null, rangeEnd: null }, chart: { projects: [], rangeStart: null, rangeEnd: null },
stats: null, stats: null,

View File

@ -0,0 +1,124 @@
import resolveScopedFolderMetadata, {
UNTITLED_NOTEBOOK,
} from "../../Gtd/folderTree";
import resolveScopedFolderIds from "../../Gtd/folderScope";
import { RawFolder } from "../../Gtd/types";
const folders: RawFolder[] = [
{ id: "archive-b", parent_id: "beta", title: "Archive" },
{ id: "alpha-2", parent_id: "work", title: "alpha" },
{ id: "work", parent_id: "", title: "Work" },
{ id: "untitled", parent_id: "work", title: " " },
{ id: "archive-a", parent_id: "alpha-1", title: "Archive" },
{ id: "beta", parent_id: "work", title: "Beta" },
{ id: "alpha-1", parent_id: "work", title: "Alpha" },
];
describe("resolveScopedFolderMetadata", () => {
test("returns root-first deterministic depth-first metadata", () => {
const result = resolveScopedFolderMetadata(folders, "work");
expect(result.map((folder) => folder.id)).toEqual([
"work",
"alpha-1",
"archive-a",
"alpha-2",
"beta",
"archive-b",
"untitled",
]);
expect(result[0]).toEqual({
id: "work",
parentId: "",
title: "Work",
notebookPath: "Work",
});
expect(result.find((folder) => folder.id === "untitled")).toMatchObject({
title: UNTITLED_NOTEBOOK,
notebookPath: `Work/${UNTITLED_NOTEBOOK}`,
});
});
test("is independent of input folder order", () => {
const forward = resolveScopedFolderMetadata(folders, "work");
const reversed = resolveScopedFolderMetadata([...folders].reverse(), "work");
expect(reversed).toEqual(forward);
});
test("uses folder id to break case-insensitive sibling title ties", () => {
const result = resolveScopedFolderMetadata(folders, "work");
expect(
result
.filter((folder) => folder.id.startsWith("alpha-"))
.map((folder) => folder.id)
).toEqual(["alpha-1", "alpha-2"]);
});
test("full paths disambiguate duplicate descendant titles", () => {
const result = resolveScopedFolderMetadata(folders, "work");
expect(
result
.filter((folder) => folder.title === "Archive")
.map((folder) => folder.notebookPath)
).toEqual(["Work/Alpha/Archive", "Work/Beta/Archive"]);
});
test("a selected child root retains its ancestors in the path", () => {
const result = resolveScopedFolderMetadata(folders, "alpha-1");
expect(result.map((folder) => folder.id)).toEqual([
"alpha-1",
"archive-a",
]);
expect(result.map((folder) => folder.notebookPath)).toEqual([
"Work/Alpha",
"Work/Alpha/Archive",
]);
});
test("an orphan root and its descendants remain usable", () => {
const malformed: RawFolder[] = [
{ id: "orphan", parent_id: "missing", title: "Orphan" },
{ id: "child", parent_id: "orphan", title: "Child" },
];
expect(resolveScopedFolderMetadata(malformed, "orphan")).toEqual([
{
id: "orphan",
parentId: "missing",
title: "Orphan",
notebookPath: "Orphan",
},
{
id: "child",
parentId: "orphan",
title: "Child",
notebookPath: "Orphan/Child",
},
]);
});
test("cycles terminate and include each reachable folder once", () => {
const cyclic: RawFolder[] = [
{ id: "a", parent_id: "b", title: "A" },
{ id: "b", parent_id: "a", title: "B" },
];
const result = resolveScopedFolderMetadata(cyclic, "a");
expect(result.map((folder) => folder.id)).toEqual(["a", "b"]);
expect(new Set(result.map((folder) => folder.id)).size).toBe(2);
});
test("a missing root returns no metadata", () => {
expect(resolveScopedFolderMetadata(folders, "missing")).toEqual([]);
});
test("the existing id-only scope helper remains unchanged", () => {
expect(resolveScopedFolderIds(folders, "work", Infinity)).toEqual([
"work",
"alpha-2",
"untitled",
"beta",
"alpha-1",
"archive-b",
"archive-a",
]);
});
});

View File

@ -62,9 +62,7 @@ describe("parseKanbanConfig", () => {
(input) => { (input) => {
const c = parseKanbanConfig({ "page-size": input }); const c = parseKanbanConfig({ "page-size": input });
expect(c.pageSize).toBe(10); expect(c.pageSize).toBe(10);
expect(c.warnings).toEqual([ expect(c.warnings).toEqual([`Invalid page-size "${input}" (using 10)`]);
`Invalid page-size "${input}" (using 10)`,
]);
} }
); );
@ -137,7 +135,11 @@ function daysAgo(n: number): number {
describe("collectKanban — bucketing", () => { describe("collectKanban — bucketing", () => {
test("Done wins over in-progress tag", async () => { test("Done wins over in-progress tag", async () => {
const notes = [ const notes = [
makeNote({ id: "c", title: "Completed but tagged", todo_completed: daysAgo(1) }), makeNote({
id: "c",
title: "Completed but tagged",
todo_completed: daysAgo(1),
}),
]; ];
const result = await collectKanban( const result = await collectKanban(
makeAdapter(folders, notes, { c: ["in-progress"] }), makeAdapter(folders, notes, { c: ["in-progress"] }),
@ -185,10 +187,7 @@ describe("collectKanban — bucketing", () => {
NOW NOW
); );
expect(result.board.backlog.map((c) => c.id)).toEqual([ expect(result.board.backlog.map((c) => c.id)).toEqual(["t", "gtd-note"]);
"t",
"gtd-note",
]);
expect(result.board.backlog[1]).toMatchObject({ expect(result.board.backlog[1]).toMatchObject({
isTodo: false, isTodo: false,
completed: false, completed: false,
@ -245,9 +244,7 @@ describe("collectKanban — bucketing", () => {
expect(result.board.backlog.map((c) => c.id)).toEqual(["broken-note"]); expect(result.board.backlog.map((c) => c.id)).toEqual(["broken-note"]);
expect(result.warnings).toHaveLength(1); expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain( expect(result.warnings[0]).toContain('"Broken note": gtd block problem');
'"Broken note": gtd block problem'
);
}); });
test.each([ test.each([
@ -436,15 +433,13 @@ describe("collectKanban — mixed note/to-do contracts", () => {
); );
expect(result.cardCount).toBe(3); expect(result.cardCount).toBe(3);
expect([ expect(
...result.board.backlog, [
...result.board.inProgress, ...result.board.backlog,
...result.board.done, ...result.board.inProgress,
].map((card) => card.id)).toEqual([ ...result.board.done,
"backlog-todo", ].map((card) => card.id)
"active-note", ).toEqual(["backlog-todo", "active-note", "done-todo"]);
"done-todo",
]);
}); });
}); });
describe("collectKanban — done window", () => { describe("collectKanban — done window", () => {
@ -556,3 +551,119 @@ describe("collectKanban — recurrence flag", () => {
expect(result.board.backlog[0].isRecurring).toBe(true); expect(result.board.backlog[0].isRecurring).toBe(true);
}); });
}); });
describe("collectKanban — notebook groups", () => {
test("builds sorted exact-owner groups and omits empty folders", async () => {
const tree: RawFolder[] = [
{ id: "root-board", parent_id: "", title: "Projects" },
{ id: "empty", parent_id: "root-board", title: "Empty" },
{ id: "work", parent_id: "root-board", title: "Work" },
{ id: "release", parent_id: "work", title: "Release" },
];
const notes = [
makeNote({ id: "host", parent_id: "root-board", is_todo: 0 }),
makeNote({ id: "root-card", parent_id: "root-board", title: "Root" }),
makeNote({ id: "work-z", parent_id: "work", is_todo: 0, title: "Zulu" }),
makeNote({ id: "work-b", parent_id: "work", title: "Bravo" }),
makeNote({ id: "work-a", parent_id: "work", title: "Alpha" }),
makeNote({
id: "old-done",
parent_id: "empty",
todo_completed: daysAgo(30),
}),
makeNote({
id: "broken",
parent_id: "release",
is_todo: 0,
title: "Broken",
body: "```gtd\ntitle: [invalid\n```",
}),
];
const result = await collectKanban(
makeAdapter(tree, notes, { "work-z": ["in-progress"] }),
"host",
"root-board",
parseKanbanConfig({
scope: "children",
group: "notebook",
todos: "all",
"sort-type": "title",
"done-window": 7,
}),
NOW
);
expect(result.layout.kind).toBe("notebooks");
if (result.layout.kind !== "notebooks")
throw new Error("grouped layout expected");
expect(result.layout.groups.map((group) => group.folderId)).toEqual([
"root-board",
"work",
"release",
]);
expect(result.layout.groups.map((group) => group.notebookPath)).toEqual([
"Projects",
"Projects/Work",
"Projects/Work/Release",
]);
expect(
result.layout.groups[1].board.backlog.map((card) => card.id)
).toEqual(["work-a", "work-b"]);
expect(
result.layout.groups[1].board.inProgress.map((card) => card.id)
).toEqual(["work-z"]);
expect(result.layout.groups[2].warnings).toHaveLength(1);
expect(result.board.backlog).toHaveLength(0);
expect(result.scannedFolders).toBe(4);
expect(result.scannedNotes).toBe(7);
expect(result.cardCount).toBe(5);
expect(
result.layout.groups.reduce((sum, group) => sum + group.cardCount, 0)
).toBe(5);
});
test("retains the single layout when notebook grouping is off", async () => {
const result = await collectKanban(
makeAdapter(folders, [makeNote({ id: "one" })]),
"host",
"board",
parseKanbanConfig({ scope: "children", todos: "all" }),
NOW
);
expect(result.layout).toMatchObject({
kind: "single",
board: result.board,
});
});
test.each([
["this-folder", "this-folder", 1],
["numeric depth", 1, 2],
["scope all", "all", 4],
] as const)(
"retains one aggregate view for %s",
async (_label, scope, count) => {
const tree: RawFolder[] = [
{ id: "board", parent_id: "", title: "Board" },
{ id: "child", parent_id: "board", title: "Child" },
{ id: "grand", parent_id: "child", title: "Grand" },
{ id: "other", parent_id: "", title: "Other" },
];
const notes = tree.map((folder) =>
makeNote({ id: "card-" + folder.id, parent_id: folder.id })
);
const result = await collectKanban(
makeAdapter(tree, notes),
"host",
"board",
parseKanbanConfig({ scope, todos: "all" }),
NOW
);
expect(result.layout.kind).toBe("single");
expect(result.cardCount).toBe(count);
if (result.layout.kind !== "single")
throw new Error("single layout expected");
expect(result.layout.board.backlog).toHaveLength(count);
}
);
});

View File

@ -0,0 +1,19 @@
import {
emptyKanbanLayout,
emptyMatrixLayout,
} from "../../Gtd/layoutPayload";
describe("layout payload helpers", () => {
test("missing-source layouts retain the requested discriminator", () => {
expect(emptyKanbanLayout(false)).toEqual({
kind: "single",
board: { backlog: [], inProgress: [], done: [] },
});
expect(emptyKanbanLayout(true)).toEqual({ kind: "notebooks", groups: [] });
expect(emptyMatrixLayout(false)).toEqual({
kind: "single",
board: { topLeft: [], topRight: [], bottomLeft: [], bottomRight: [] },
});
expect(emptyMatrixLayout(true)).toEqual({ kind: "notebooks", groups: [] });
});
});

View File

@ -42,16 +42,12 @@ describe("parseMatrixConfig", () => {
(input) => { (input) => {
const c = parseMatrixConfig({ "page-size": input }); const c = parseMatrixConfig({ "page-size": input });
expect(c.pageSize).toBe(10); expect(c.pageSize).toBe(10);
expect(c.warnings).toEqual([ expect(c.warnings).toEqual([`Invalid page-size "${input}" (using 10)`]);
`Invalid page-size "${input}" (using 10)`,
]);
} }
); );
test("mode parses and warns on invalid", () => { test("mode parses and warns on invalid", () => {
expect(parseMatrixConfig({ mode: "eisenhower" }).mode).toBe( expect(parseMatrixConfig({ mode: "eisenhower" }).mode).toBe("eisenhower");
"eisenhower"
);
const bad = parseMatrixConfig({ mode: "wiebe" }); const bad = parseMatrixConfig({ mode: "wiebe" });
expect(bad.mode).toBe("skeleton"); expect(bad.mode).toBe("skeleton");
expect(bad.warnings.some((w) => w.includes("mode"))).toBe(true); expect(bad.warnings.some((w) => w.includes("mode"))).toBe(true);
@ -71,9 +67,7 @@ describe("parseMatrixConfig", () => {
"urgent-tag": "now", "urgent-tag": "now",
"important-tag": "NOW", "important-tag": "NOW",
}); });
expect(c.warnings.some((w) => w.includes("will not separate"))).toBe( expect(c.warnings.some((w) => w.includes("will not separate"))).toBe(true);
true
);
}); });
test("invalid values warn and fall back", () => { test("invalid values warn and fall back", () => {
@ -347,9 +341,7 @@ describe("collectMatrix — mixed note/to-do contracts", () => {
]); ]);
expect(result.cardCount).toBe(2); expect(result.cardCount).toBe(2);
expect(result.warnings).toHaveLength(1); expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain( expect(result.warnings[0]).toContain('"Broken note": gtd block problem');
'"Broken note": gtd block problem'
);
}); });
test.each([ test.each([
@ -504,9 +496,7 @@ describe("collectMatrix — Skeleton mode", () => {
NOW NOW
); );
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]); expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]);
expect(result.board.bottomRight.map((c) => c.id)).toEqual([ expect(result.board.bottomRight.map((c) => c.id)).toEqual(["past-edge"]);
"past-edge",
]);
}); });
test("gtd date override affects due-soon just like a due date", async () => { test("gtd date override affects due-soon just like a due date", async () => {
@ -522,3 +512,164 @@ describe("collectMatrix — Skeleton mode", () => {
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]); expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]);
}); });
}); });
describe("collectMatrix — notebook groups", () => {
test.each(["skeleton", "eisenhower"] as const)(
"groups mixed cards exactly once in %s mode",
async (mode) => {
const tree: RawFolder[] = [
{ id: "root-board", parent_id: "", title: "Projects" },
{ id: "empty", parent_id: "root-board", title: "Empty" },
{ id: "child", parent_id: "root-board", title: "Child" },
];
const notes = [
makeNote({ id: "host", parent_id: "root-board", is_todo: 0 }),
makeNote({ id: "root-note", parent_id: "root-board", is_todo: 0 }),
makeNote({ id: "child-todo", parent_id: "child" }),
makeNote({
id: "child-plain",
parent_id: "child",
is_todo: 0,
title: "Alpha",
}),
makeNote({
id: "broken",
parent_id: "child",
is_todo: 0,
title: "Broken",
body: "```gtd\ntitle: [invalid\n```",
}),
makeNote({ id: "done", parent_id: "empty", todo_completed: 1 }),
];
const result = await collectMatrix(
makeAdapter(tree, notes, {
"root-note": ["important", "in-progress"],
"child-todo": ["urgent"],
}),
"host",
"root-board",
parseMatrixConfig({
scope: "children",
group: "notebook",
todos: "all",
mode,
"sort-type": "title",
})
);
expect(result.layout.kind).toBe("notebooks");
if (result.layout.kind !== "notebooks")
throw new Error("grouped layout expected");
expect(result.layout.groups.map((group) => group.folderId)).toEqual([
"root-board",
"child",
]);
expect(result.layout.groups[0].cardCount).toBe(1);
expect(result.layout.groups[1].cardCount).toBe(3);
expect(result.layout.groups[1].warnings).toHaveLength(1);
expect(
result.layout.groups[1].board.bottomRight.map((card) => card.id)
).toEqual(["child-plain", "broken"]);
const ids = result.layout.groups.flatMap((group) =>
[
...group.board.topLeft,
...group.board.topRight,
...group.board.bottomLeft,
...group.board.bottomRight,
].map((card) => card.id)
);
expect(ids.sort()).toEqual([
"broken",
"child-plain",
"child-todo",
"root-note",
]);
expect(result.board.bottomRight).toHaveLength(0);
expect(result.scannedFolders).toBe(3);
expect(result.scannedNotes).toBe(6);
expect(result.cardCount).toBe(4);
}
);
test("retains the single layout when notebook grouping is off", async () => {
const result = await collectMatrix(
makeAdapter(folders, [makeNote({ id: "one" })]),
"host",
"board",
parseMatrixConfig({ scope: "children", todos: "all" })
);
expect(result.layout).toMatchObject({
kind: "single",
board: result.board,
});
});
test.each(["skeleton", "eisenhower"] as const)(
"keeps all four %s quadrants local to the owning notebook",
async (mode) => {
const tree: RawFolder[] = [
{ id: "root-board", parent_id: "", title: "Projects" },
{ id: "child", parent_id: "root-board", title: "Child" },
];
const notes = [
makeNote({ id: "root-left", parent_id: "root-board", is_todo: 0 }),
makeNote({ id: "tl", parent_id: "child", is_todo: 0 }),
makeNote({ id: "tr", parent_id: "child", is_todo: 0 }),
makeNote({ id: "bl", parent_id: "child", is_todo: 0 }),
makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
];
const tags = {
"root-left": ["urgent"],
tl: ["urgent", "important", "in-progress"],
tr: ["important", "in-progress"],
bl: ["urgent"],
};
const result = await collectMatrix(
makeAdapter(tree, notes, tags),
"host",
"root-board",
parseMatrixConfig({ scope: "children", group: "notebook", mode })
);
expect(result.layout.kind).toBe("notebooks");
if (result.layout.kind !== "notebooks")
throw new Error("grouped layout expected");
const root = result.layout.groups[0].board;
const child = result.layout.groups[1].board;
expect(root.bottomLeft.map((card) => card.id)).toEqual(["root-left"]);
expect(child.topLeft.map((card) => card.id)).toEqual(["tl"]);
expect(child.topRight.map((card) => card.id)).toEqual(["tr"]);
expect(child.bottomLeft.map((card) => card.id)).toEqual(["bl"]);
expect(child.bottomRight.map((card) => card.id)).toEqual(["br"]);
}
);
test.each([
["this-folder", "this-folder", 1],
["numeric depth", 1, 2],
["scope all", "all", 4],
] as const)(
"retains one aggregate matrix for %s",
async (_label, scope, count) => {
const tree: RawFolder[] = [
{ id: "board", parent_id: "", title: "Board" },
{ id: "child", parent_id: "board", title: "Child" },
{ id: "grand", parent_id: "child", title: "Grand" },
{ id: "other", parent_id: "", title: "Other" },
];
const notes = tree.map((folder) =>
makeNote({ id: "card-" + folder.id, parent_id: folder.id })
);
const result = await collectMatrix(
makeAdapter(tree, notes),
"host",
"board",
parseMatrixConfig({ scope, todos: "all", mode: "eisenhower" })
);
expect(result.layout.kind).toBe("single");
expect(result.cardCount).toBe(count);
if (result.layout.kind !== "single")
throw new Error("single layout expected");
expect(result.layout.board.bottomRight).toHaveLength(count);
}
);
});

View File

@ -0,0 +1,83 @@
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
const parsers = [
["kanban", parseKanbanConfig],
["matrix", parseMatrixConfig],
] as const;
describe.each(parsers)("%s notebook grouping config", (_name, parse) => {
test("requires both literal scope: children and group: notebook", () => {
const grouped = parse({ scope: "children", group: "notebook" });
expect(grouped.scopeDepth).toBe(Infinity);
expect(grouped.scopeAll).toBe(false);
expect(grouped.groupByNotebook).toBe(true);
expect(grouped.warnings).toHaveLength(0);
});
test("scope: children remains aggregated when group is omitted", () => {
const config = parse({ scope: "children" });
expect(config.scopeDepth).toBe(Infinity);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toHaveLength(0);
});
test.each([
["omitted", {}, 0],
["this-folder", { scope: "this-folder" }, 0],
["numeric", { scope: 3 }, 3],
["numeric string", { scope: "3" }, 3],
])("%s scope remains non-grouped", (_label, raw, expectedDepth) => {
const config = parse(raw);
expect(config.scopeDepth).toBe(expectedDepth);
expect(config.scopeAll).toBe(false);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toHaveLength(0);
});
test("scope: all remains non-grouped", () => {
const config = parse({ scope: "all" });
expect(config.scopeAll).toBe(true);
expect(config.scopeDepth).toBe(0);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toHaveLength(0);
});
test("scope: all stays non-grouped and retains notebook conflict warnings", () => {
const config = parse({ scope: "all", notebook: "Work" });
expect(config.scopeAll).toBe(true);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toEqual([
'"notebook" is ignored when scope: all (scanning every notebook)',
]);
});
test("group: notebook with another scope warns and remains non-grouped", () => {
const config = parse({ scope: "all", group: "notebook" });
expect(config.scopeAll).toBe(true);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toEqual([
'"group: notebook" requires scope: children (grouping disabled)',
]);
});
test("an unsupported group value warns and remains non-grouped", () => {
const config = parse({ scope: "children", group: "folder" });
expect(config.scopeDepth).toBe(Infinity);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toEqual([
'Invalid group "folder" (grouping disabled)',
]);
});
test("invalid scope fallback remains non-grouped", () => {
const config = parse({ scope: "descendants", group: "notebook" });
expect(config.scopeDepth).toBe(0);
expect(config.scopeAll).toBe(false);
expect(config.groupByNotebook).toBe(false);
expect(config.warnings).toEqual([
'Invalid scope "descendants" (using "this-folder")',
'"group: notebook" requires scope: children (grouping disabled)',
]);
});
});