SLICE10-child-notebook-kanban-and-matrix-views
This commit is contained in:
parent
d0ed2c4769
commit
f4483ef496
338
SLICE10.md
338
SLICE10.md
@ -6,19 +6,22 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**PLANNED.** Implementation has not started. Begin only after SLICE8 and SLICE9
|
**IN PROGRESS.** Phases 1–6 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 4–8.
|
||||||
|
|
||||||
### 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 5–8.
|
||||||
|
|
||||||
### 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.
|
||||||
|
|||||||
76
TASKS.md
76
TASKS.md
@ -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 SLICE8–10 reflect the
|
- [ ] Ensure README.md, SPEC.md, CHANGELOG.md, TASKS.md, and SLICE8–10 reflect the
|
||||||
final implementation state.
|
final implementation state.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
93
src/Gtd/folderTree.ts
Normal 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
19
src/Gtd/layoutPayload.ts
Normal 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() };
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
45
src/index.ts
45
src/index.ts
@ -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,
|
||||||
|
|||||||
124
src/tests/Gtd/folderTree.test.ts
Normal file
124
src/tests/Gtd/folderTree.test.ts
Normal 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
19
src/tests/Gtd/layoutPayload.test.ts
Normal file
19
src/tests/Gtd/layoutPayload.test.ts
Normal 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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
83
src/tests/Gtd/scopeGrouping.test.ts
Normal file
83
src/tests/Gtd/scopeGrouping.test.ts
Normal 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)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user