Make Skeleton matrix tag-driven and editable for v2.2.0
This commit is contained in:
parent
1f0679568b
commit
2b8b17f4fe
13
CHANGELOG.md
13
CHANGELOG.md
@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
|
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
|
||||||
|
|
||||||
|
## 2.2.0 — Unreleased
|
||||||
|
|
||||||
|
### Tag-driven editable Skeleton matrix
|
||||||
|
|
||||||
|
- Skeleton routing no longer uses native to-do due dates or `gtd` dates.
|
||||||
|
- Cards route by tag precedence: `urgent`, `in-progress`, `on-deck`, then
|
||||||
|
Backlog. The tags remain configurable with `urgent-tag`, `in-progress-tag`,
|
||||||
|
and the new `on-deck-tag` option.
|
||||||
|
- Skeleton quadrants are now Urgent, In Progress, On Deck, and Backlog.
|
||||||
|
- `editable: yes` enables the existing pointer drag and keyboard move controls
|
||||||
|
for Skeleton matrices. A move normalizes the three workflow tags while
|
||||||
|
preserving dates, completion, content, notebooks, and unrelated tags.
|
||||||
|
|
||||||
## 2.1.0 — Unreleased
|
## 2.1.0 — Unreleased
|
||||||
|
|
||||||
### Notebook-tree exclusions
|
### Notebook-tree exclusions
|
||||||
|
|||||||
24
README.md
24
README.md
@ -282,14 +282,14 @@ Two modes:
|
|||||||
|
|
||||||
### `mode: skeleton` (default) — the Skeleton Matrix
|
### `mode: skeleton` (default) — the Skeleton Matrix
|
||||||
|
|
||||||
Built to complement the kanban and calendar, using the tags and dates you already maintain. Rows ask *am I on this?* (the in-progress tag); columns ask *is the clock running?* (due within `urgent-window` days — overdue counts — or carrying the urgent tag as a manual override):
|
Built to complement the kanban and calendar as a four-state, tag-driven workflow. Due dates and `gtd` dates do not affect Skeleton placement. When old or externally edited cards carry overlapping workflow tags, the first matching state below wins:
|
||||||
|
|
||||||
| | **Due soon** | **Not due soon** |
|
| | **Priority** | **Current / backlog** |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Active** | Do Next | Scheduled |
|
| **Now** | Urgent (`urgent`) | In Progress (`in-progress`) |
|
||||||
| **Not active** | On Deck | Backlog |
|
| **Next / later** | On Deck (`on-deck`) | Backlog (none) |
|
||||||
|
|
||||||
**On Deck** is the quadrant to watch: due soon, not yet started. Nothing is ever labelled "Eliminate" — a Backlog item is simply low priority, not condemned. Dateless in-progress work sits in Scheduled (active, no clock). Eligible ordinary notes use their `gtd` date and the same tag rules; completed to-dos and notes carrying `done-tag` do not appear at all.
|
Precedence is Urgent, In Progress, On Deck, then Backlog. Completed to-dos and notes carrying `done-tag` do not appear at all.
|
||||||
|
|
||||||
### `mode: eisenhower` — urgent/important axes
|
### `mode: eisenhower` — urgent/important axes
|
||||||
|
|
||||||
@ -302,7 +302,7 @@ On Deck (urgent), and Backlog (neither).
|
|||||||
| Option | Values | Default | Description |
|
| Option | Values | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
||||||
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves for Eisenhower matrices on desktop. Skeleton remains read-only; invalid or ambiguous configurations warn and disable editing. |
|
| `editable` | `yes`, `no` | `no` | Enables persisted pointer and keyboard quadrant moves on desktop. Ambiguous tag configurations warn and disable editing. |
|
||||||
| `title` | text | — | Heading above the matrix. |
|
| `title` | text | — | Heading above the matrix. |
|
||||||
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
|
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
|
||||||
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
||||||
@ -312,22 +312,22 @@ On Deck (urgent), and Backlog (neither).
|
|||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. This option does not affect ordinary notes, which appear only when they contain a `gtd` block. |
|
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. This option does not affect ordinary notes, which appear only when they contain a `gtd` block. |
|
||||||
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
|
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
|
||||||
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||||
| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; the Skeleton mode's manual due-soon override. |
|
| `urgent-tag` | any tag name | `urgent` | Eisenhower's urgent axis; Skeleton's Urgent destination. |
|
||||||
| `important-tag` | any tag name | `important` | Eisenhower's important axis (unused in Skeleton mode). |
|
| `important-tag` | any tag name | `important` | Eisenhower's important axis (unused in Skeleton mode). |
|
||||||
| `in-progress-tag` | any tag name | `in-progress` | Skeleton mode's active row — same tag as the kanban's In Progress column. |
|
| `in-progress-tag` | any tag name | `in-progress` | Skeleton's In Progress destination; shared with kanban by default. |
|
||||||
| `urgent-window` | integer (days) | `3` | Skeleton mode: how close a due date must be to count as "due soon". |
|
| `on-deck-tag` | any tag name | `on-deck` | Skeleton's On Deck destination. |
|
||||||
| `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. |
|
| `card-detail` | `hover`, `always`, `none` | `hover` | Card detail line behaviour, as on the kanban. |
|
||||||
| `page-size` | positive integer | `10` | Cards initially shown in each quadrant and revealed by each **List more** click. Invalid values warn and fall back to 10. |
|
| `page-size` | positive integer | `10` | Cards initially shown in each quadrant and revealed by each **List more** click. Invalid values warn and fall back to 10. |
|
||||||
|
|
||||||
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
|
Each quadrant expands independently in `page-size` batches. The **List more** hover/focus popup reports the number still hidden, and reloading the note resets all expanded quadrants. Quadrant headings and matrix statistics continue to show full totals.
|
||||||
|
|
||||||
On an editable Eisenhower matrix, drag a card by its move handle or use the
|
On an editable matrix, drag a card by its move handle or use the
|
||||||
disclosure arrow beneath it to open the keyboard **Move to…** menu. Do Next adds
|
disclosure arrow beneath it to open the keyboard **Move to…** menu. Do Next adds
|
||||||
both axis tags; Scheduled keeps only important; On Deck keeps only urgent; and
|
both axis tags; Scheduled keeps only important; On Deck keeps only urgent; and
|
||||||
Backlog removes both. Moves never change completion, dates, content, notebook,
|
Backlog removes both. Moves never change completion, dates, content, notebook,
|
||||||
or unrelated tags, and sorting plus pagination remain authoritative after the
|
or unrelated tags, and sorting plus pagination remain authoritative after the
|
||||||
matrix refreshes. Skeleton matrices are intentionally read-only; `editable: yes`
|
matrix refreshes. In Skeleton mode, destinations keep exactly one of the three
|
||||||
with Skeleton warns and exposes no inert controls.
|
workflow tags (or none for Backlog); due dates are never changed.
|
||||||
|
|
||||||
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
|
Ordinary notes use the same `gtd` title/date/colour/icon/text overrides, use 📄
|
||||||
when no icon is set, and never show completion or recurrence styling. Empty and
|
when no icon is set, and never show completion or recurrence styling. Empty and
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
|
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
|
||||||
|
|||||||
@ -27,11 +27,9 @@ export interface MatrixResult {
|
|||||||
* - notes and todos filters apply independently.
|
* - notes and todos filters apply independently.
|
||||||
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
|
* - Completed to-dos and done-tag notes are excluded entirely: the matrix is a
|
||||||
* prioritisation view; completions live in the kanban's Done column.
|
* prioritisation view; completions live in the kanban's Done column.
|
||||||
* - Quadrants from the two axis tags:
|
* - Eisenhower uses its two axis tags.
|
||||||
* urgent + important -> Do Next
|
* - Skeleton uses exclusive workflow destinations with urgent > in-progress
|
||||||
* important only -> Scheduled
|
* > on-deck precedence and Backlog as the untagged default.
|
||||||
* urgent only -> On Deck
|
|
||||||
* neither -> Backlog (default bucket for untagged)
|
|
||||||
*
|
*
|
||||||
* The matrix note itself is always excluded.
|
* The matrix note itself is always excluded.
|
||||||
*/
|
*/
|
||||||
@ -40,7 +38,7 @@ export default async function collectMatrix(
|
|||||||
matrixNoteId: string,
|
matrixNoteId: string,
|
||||||
matrixFolderId: string,
|
matrixFolderId: string,
|
||||||
config: MatrixConfig,
|
config: MatrixConfig,
|
||||||
now: Date = new Date()
|
_now: Date = new Date()
|
||||||
): Promise<MatrixResult> {
|
): Promise<MatrixResult> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
@ -76,9 +74,6 @@ export default async function collectMatrix(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skeleton mode: a date on or before this ISO threshold is "due soon".
|
|
||||||
const soonThreshold = isoDaysFromNow(now, config.urgentWindow);
|
|
||||||
|
|
||||||
// Bodies preserve explicit note opt-in and optional to-do card overrides.
|
// Bodies preserve explicit note opt-in and optional to-do card overrides.
|
||||||
// When both types are disabled, metadata alone is sufficient for scan totals.
|
// When both types are disabled, metadata alone is sufficient for scan totals.
|
||||||
const includeBody = config.notes !== "none" || config.todos !== "none";
|
const includeBody = config.notes !== "none" || config.todos !== "none";
|
||||||
@ -123,13 +118,17 @@ export default async function collectMatrix(
|
|||||||
topRow = tags.includes(config.importantTag);
|
topRow = tags.includes(config.importantTag);
|
||||||
leftColumn = tags.includes(config.urgentTag);
|
leftColumn = tags.includes(config.urgentTag);
|
||||||
} else {
|
} else {
|
||||||
// Skeleton. Rows: active (in-progress) / not.
|
// Skeleton is a four-state tag workflow. Precedence makes
|
||||||
// Columns: due soon (urgent tag overrides; else date within
|
// overlapping legacy tags deterministic until the next move.
|
||||||
// the window, overdue included) / not.
|
if (tags.includes(config.urgentTag)) {
|
||||||
topRow = tags.includes(config.inProgressTag);
|
topRow = true; leftColumn = true;
|
||||||
leftColumn =
|
} else if (tags.includes(config.inProgressTag)) {
|
||||||
tags.includes(config.urgentTag) ||
|
topRow = true; leftColumn = false;
|
||||||
(card.date !== null && card.date <= soonThreshold);
|
} else if (tags.includes(config.onDeckTag)) {
|
||||||
|
topRow = false; leftColumn = true;
|
||||||
|
} else {
|
||||||
|
topRow = false; leftColumn = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topRow && leftColumn) targetBoard.topLeft.push(card);
|
if (topRow && leftColumn) targetBoard.topLeft.push(card);
|
||||||
@ -194,18 +193,6 @@ function countBoard(board: MatrixBoard): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ISO yyyy-mm-dd for local `now` plus `days`. */
|
|
||||||
function isoDaysFromNow(now: Date, days: number): string {
|
|
||||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
|
|
||||||
return (
|
|
||||||
String(d.getFullYear()).padStart(4, "0") +
|
|
||||||
"-" +
|
|
||||||
String(d.getMonth() + 1).padStart(2, "0") +
|
|
||||||
"-" +
|
|
||||||
String(d.getDate()).padStart(2, "0")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortColumn(cards: KanbanCard[], config: MatrixConfig): void {
|
function sortColumn(cards: KanbanCard[], config: MatrixConfig): void {
|
||||||
const direction = config.sort === "desc" ? -1 : 1;
|
const direction = config.sort === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,11 @@ export default function matrixLabels(mode: MatrixMode): MatrixLabels {
|
|||||||
}
|
}
|
||||||
// skeleton
|
// skeleton
|
||||||
return {
|
return {
|
||||||
columns: ["Due soon", "Not due soon"],
|
columns: ["Priority", "Current / backlog"],
|
||||||
rows: ["Active", "Not active"],
|
rows: ["Now", "Next / later"],
|
||||||
quadrants: {
|
quadrants: {
|
||||||
topLeft: "Do Next",
|
topLeft: "Urgent",
|
||||||
topRight: "Scheduled",
|
topRight: "In Progress",
|
||||||
bottomLeft: "On Deck",
|
bottomLeft: "On Deck",
|
||||||
bottomRight: "Backlog",
|
bottomRight: "Backlog",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export async function validateAndMoveMatrixCard(
|
|||||||
return result("stale", viewInstanceId, "The matrix configuration is no longer valid.");
|
return result("stale", viewInstanceId, "The matrix configuration is no longer valid.");
|
||||||
}
|
}
|
||||||
const config = parseMatrixConfig(parsed);
|
const config = parseMatrixConfig(parsed);
|
||||||
if (!config.editable || config.mode !== "eisenhower") {
|
if (!config.editable) {
|
||||||
return result("error", viewInstanceId, "This matrix is read-only.");
|
return result("error", viewInstanceId, "This matrix is read-only.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import { ensureNoteTag, removeNoteTag } from "./kanbanMutation";
|
import { ensureNoteTag, removeNoteTag } from "./kanbanMutation";
|
||||||
import {
|
import {
|
||||||
MatrixDestination,
|
MatrixDestination,
|
||||||
|
MatrixMode,
|
||||||
MutationAdapter,
|
MutationAdapter,
|
||||||
RawNote,
|
RawNote,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface MatrixMutationConfig {
|
export interface MatrixMutationConfig {
|
||||||
|
mode: MatrixMode;
|
||||||
urgentTag: string;
|
urgentTag: string;
|
||||||
importantTag: string;
|
importantTag: string;
|
||||||
|
inProgressTag: string;
|
||||||
|
onDeckTag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatrixMutationOutcome {
|
export interface MatrixMutationOutcome {
|
||||||
@ -36,6 +40,20 @@ export async function moveMatrixItem(
|
|||||||
if (validateFreshState) validateFreshState(note, noteTags);
|
if (validateFreshState) validateFreshState(note, noteTags);
|
||||||
const target = TARGETS[destination];
|
const target = TARGETS[destination];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
if (config.mode === "skeleton") {
|
||||||
|
const targetTag: Record<MatrixDestination, string | null> = {
|
||||||
|
topLeft: config.urgentTag,
|
||||||
|
topRight: config.inProgressTag,
|
||||||
|
bottomLeft: config.onDeckTag,
|
||||||
|
bottomRight: null,
|
||||||
|
};
|
||||||
|
for (const tag of [config.urgentTag, config.inProgressTag, config.onDeckTag]) {
|
||||||
|
changed = tag === targetTag[destination]
|
||||||
|
? (await ensureNoteTag(adapter, noteId, tag, noteTags)) || changed
|
||||||
|
: (await removeNoteTag(adapter, noteId, tag, noteTags)) || changed;
|
||||||
|
}
|
||||||
|
return { note, changed };
|
||||||
|
}
|
||||||
|
|
||||||
changed = target.urgent
|
changed = target.urgent
|
||||||
? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed
|
? (await ensureNoteTag(adapter, noteId, config.urgentTag, noteTags)) || changed
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
MatrixMode,
|
MatrixMode,
|
||||||
URGENT_TAG,
|
URGENT_TAG,
|
||||||
IMPORTANT_TAG,
|
IMPORTANT_TAG,
|
||||||
|
ON_DECK_TAG,
|
||||||
IN_PROGRESS_TAG,
|
IN_PROGRESS_TAG,
|
||||||
DONE_TAG,
|
DONE_TAG,
|
||||||
NoteInclusionMode,
|
NoteInclusionMode,
|
||||||
@ -40,9 +41,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
"sort",
|
"sort",
|
||||||
"urgent-tag",
|
"urgent-tag",
|
||||||
"important-tag",
|
"important-tag",
|
||||||
|
"on-deck-tag",
|
||||||
"in-progress-tag",
|
"in-progress-tag",
|
||||||
"done-tag",
|
"done-tag",
|
||||||
"urgent-window",
|
|
||||||
"card-detail",
|
"card-detail",
|
||||||
"page-size",
|
"page-size",
|
||||||
"editable",
|
"editable",
|
||||||
@ -163,13 +164,14 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
// axis tags (configurable; lowercased for tag comparison)
|
// axis tags (configurable; lowercased for tag comparison)
|
||||||
const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG);
|
const urgentTag = parseTag(input["urgent-tag"], URGENT_TAG);
|
||||||
const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG);
|
const importantTag = parseTag(input["important-tag"], IMPORTANT_TAG);
|
||||||
|
const onDeckTag = parseTag(input["on-deck-tag"], ON_DECK_TAG);
|
||||||
if (urgentTag === importantTag) {
|
if (urgentTag === importantTag) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate`
|
`urgent-tag and important-tag are both "${urgentTag}" — quadrants will not separate`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// skeleton mode: in-progress tag + urgent window
|
// skeleton workflow tags
|
||||||
const inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG);
|
const inProgressTag = parseTag(input["in-progress-tag"], IN_PROGRESS_TAG);
|
||||||
const doneTag = parseTag(input["done-tag"], DONE_TAG);
|
const doneTag = parseTag(input["done-tag"], DONE_TAG);
|
||||||
if (doneTag === inProgressTag) {
|
if (doneTag === inProgressTag) {
|
||||||
@ -178,16 +180,6 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let urgentWindow = 3;
|
|
||||||
if (input["urgent-window"] !== undefined) {
|
|
||||||
const value = input["urgent-window"];
|
|
||||||
if (Number.isInteger(Number(value)) && Number(value) >= 0) {
|
|
||||||
urgentWindow = Number(value);
|
|
||||||
} else {
|
|
||||||
warnings.push(`Invalid urgent-window "${value}" (using 3)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// card-detail
|
// card-detail
|
||||||
let cardDetail: MatrixConfig["cardDetail"] = "hover";
|
let cardDetail: MatrixConfig["cardDetail"] = "hover";
|
||||||
if (input["card-detail"] !== undefined) {
|
if (input["card-detail"] !== undefined) {
|
||||||
@ -214,12 +206,11 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`);
|
warnings.push(`Invalid editable "${String(input.editable)}" (using "no")`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (editable && mode === "skeleton") {
|
const editableTags = mode === "skeleton"
|
||||||
warnings.push("editable: yes is unavailable for Skeleton matrices (editing disabled)");
|
? [urgentTag, inProgressTag, onDeckTag]
|
||||||
editable = false;
|
: [urgentTag, importantTag];
|
||||||
}
|
if (editable && new Set(editableTags).size !== editableTags.length) {
|
||||||
if (editable && urgentTag === importantTag) {
|
warnings.push(`editable ${mode === "skeleton" ? "Skeleton workflow" : "Eisenhower axis"} tags must be distinct (editing disabled)`);
|
||||||
warnings.push("editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)");
|
|
||||||
editable = false;
|
editable = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,9 +228,9 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
sort,
|
sort,
|
||||||
urgentTag,
|
urgentTag,
|
||||||
importantTag,
|
importantTag,
|
||||||
|
onDeckTag,
|
||||||
inProgressTag,
|
inProgressTag,
|
||||||
doneTag,
|
doneTag,
|
||||||
urgentWindow,
|
|
||||||
cardDetail,
|
cardDetail,
|
||||||
pageSize,
|
pageSize,
|
||||||
editable,
|
editable,
|
||||||
|
|||||||
@ -235,6 +235,7 @@ export type KanbanLayout =
|
|||||||
*/
|
*/
|
||||||
export const URGENT_TAG = "urgent";
|
export const URGENT_TAG = "urgent";
|
||||||
export const IMPORTANT_TAG = "important";
|
export const IMPORTANT_TAG = "important";
|
||||||
|
export const ON_DECK_TAG = "on-deck";
|
||||||
|
|
||||||
export type MatrixMode = "skeleton" | "eisenhower";
|
export type MatrixMode = "skeleton" | "eisenhower";
|
||||||
|
|
||||||
@ -257,16 +258,16 @@ export interface MatrixConfig {
|
|||||||
sort: "asc" | "desc";
|
sort: "asc" | "desc";
|
||||||
urgentTag: string;
|
urgentTag: string;
|
||||||
importantTag: string;
|
importantTag: string;
|
||||||
|
/** Skeleton mode: tag marking work queued to start next. */
|
||||||
|
onDeckTag: string;
|
||||||
/** Skeleton mode: tag marking active work (shared with the kanban). */
|
/** Skeleton mode: tag marking active work (shared with the kanban). */
|
||||||
inProgressTag: string;
|
inProgressTag: string;
|
||||||
/** Tag that marks an opted-in ordinary note complete. */
|
/** Tag that marks an opted-in ordinary note complete. */
|
||||||
doneTag: string;
|
doneTag: string;
|
||||||
/** Skeleton mode: days ahead within which a due date counts as "due soon". */
|
|
||||||
urgentWindow: number;
|
|
||||||
cardDetail: "hover" | "always" | "none";
|
cardDetail: "hover" | "always" | "none";
|
||||||
/** Cards initially shown, and revealed per "List more" action. */
|
/** Cards initially shown, and revealed per "List more" action. */
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
/** Effective editable state; only valid for distinct-axis Eisenhower mode. */
|
/** Effective editable state; requires distinct workflow/axis tags. */
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
@ -292,8 +293,8 @@ export interface MoveMatrixCardIntent {
|
|||||||
* topLeft topRight
|
* topLeft topRight
|
||||||
* bottomLeft bottomRight
|
* bottomLeft bottomRight
|
||||||
*
|
*
|
||||||
* Both modes display Do Next / Scheduled / On Deck / Backlog; their axes and
|
* The modes share positional destinations while their labels and tag semantics
|
||||||
* bucketing semantics remain mode-specific.
|
* remain mode-specific.
|
||||||
*/
|
*/
|
||||||
export interface MatrixBoard {
|
export interface MatrixBoard {
|
||||||
topLeft: KanbanCard[];
|
topLeft: KanbanCard[];
|
||||||
|
|||||||
@ -1340,11 +1340,11 @@
|
|||||||
board: payload.board || emptyBoard,
|
board: payload.board || emptyBoard,
|
||||||
};
|
};
|
||||||
const labels = payload.labels || {
|
const labels = payload.labels || {
|
||||||
columns: ["Urgent", "Not urgent"],
|
columns: ["Priority", "Current / backlog"],
|
||||||
rows: ["Important", "Not important"],
|
rows: ["Now", "Next / later"],
|
||||||
quadrants: {
|
quadrants: {
|
||||||
topLeft: "Do Next",
|
topLeft: "Urgent",
|
||||||
topRight: "Scheduled",
|
topRight: "In Progress",
|
||||||
bottomLeft: "On Deck",
|
bottomLeft: "On Deck",
|
||||||
bottomRight: "Backlog",
|
bottomRight: "Backlog",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
||||||
"app_min_version": "2.7",
|
"app_min_version": "2.7",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"name": "GTD Calendar",
|
"name": "GTD Calendar",
|
||||||
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
||||||
"author": "Victor Wiebe",
|
"author": "Victor Wiebe",
|
||||||
|
|||||||
@ -20,11 +20,11 @@ describe("parseMatrixConfig", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(matrixLabels("skeleton")).toEqual({
|
expect(matrixLabels("skeleton")).toEqual({
|
||||||
columns: ["Due soon", "Not due soon"],
|
columns: ["Priority", "Current / backlog"],
|
||||||
rows: ["Active", "Not active"],
|
rows: ["Now", "Next / later"],
|
||||||
quadrants: {
|
quadrants: {
|
||||||
topLeft: "Do Next",
|
topLeft: "Urgent",
|
||||||
topRight: "Scheduled",
|
topRight: "In Progress",
|
||||||
bottomLeft: "On Deck",
|
bottomLeft: "On Deck",
|
||||||
bottomRight: "Backlog",
|
bottomRight: "Backlog",
|
||||||
},
|
},
|
||||||
@ -42,9 +42,9 @@ describe("parseMatrixConfig", () => {
|
|||||||
sort: "asc",
|
sort: "asc",
|
||||||
urgentTag: "urgent",
|
urgentTag: "urgent",
|
||||||
importantTag: "important",
|
importantTag: "important",
|
||||||
|
onDeckTag: "on-deck",
|
||||||
inProgressTag: "in-progress",
|
inProgressTag: "in-progress",
|
||||||
doneTag: "done",
|
doneTag: "done",
|
||||||
urgentWindow: 3,
|
|
||||||
cardDetail: "hover",
|
cardDetail: "hover",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
editable: false,
|
editable: false,
|
||||||
@ -52,7 +52,7 @@ describe("parseMatrixConfig", () => {
|
|||||||
expect(c.warnings).toHaveLength(0);
|
expect(c.warnings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("editable is strict, Eisenhower-only, and requires distinct axes", () => {
|
test("editable is strict and requires distinct destination tags", () => {
|
||||||
expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true);
|
expect(parseMatrixConfig({ mode: "eisenhower", editable: " YES " }).editable).toBe(true);
|
||||||
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false);
|
expect(parseMatrixConfig({ mode: "eisenhower", editable: "No" }).editable).toBe(false);
|
||||||
|
|
||||||
@ -62,11 +62,7 @@ describe("parseMatrixConfig", () => {
|
|||||||
expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`);
|
expect(config.warnings).toContain(`Invalid editable "${String(value)}" (using "no")`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const skeleton = parseMatrixConfig({ editable: "yes" });
|
expect(parseMatrixConfig({ editable: "yes" }).editable).toBe(true);
|
||||||
expect(skeleton.editable).toBe(false);
|
|
||||||
expect(skeleton.warnings).toContain(
|
|
||||||
"editable: yes is unavailable for Skeleton matrices (editing disabled)"
|
|
||||||
);
|
|
||||||
|
|
||||||
const equalAxes = parseMatrixConfig({
|
const equalAxes = parseMatrixConfig({
|
||||||
mode: "eisenhower",
|
mode: "eisenhower",
|
||||||
@ -76,8 +72,10 @@ describe("parseMatrixConfig", () => {
|
|||||||
});
|
});
|
||||||
expect(equalAxes.editable).toBe(false);
|
expect(equalAxes.editable).toBe(false);
|
||||||
expect(equalAxes.warnings).toContain(
|
expect(equalAxes.warnings).toContain(
|
||||||
"editable Eisenhower requires distinct urgent-tag and important-tag values (editing disabled)"
|
"editable Eisenhower axis tags must be distinct (editing disabled)"
|
||||||
);
|
);
|
||||||
|
const equalWorkflow = parseMatrixConfig({ editable: "yes", "on-deck-tag": "URGENT" });
|
||||||
|
expect(equalWorkflow.editable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses notes independently and rejects invalid or singular forms", () => {
|
test("parses notes independently and rejects invalid or singular forms", () => {
|
||||||
@ -566,7 +564,7 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
return new Date(y, m - 1, d, 12).getTime();
|
return new Date(y, m - 1, d, 12).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("the four quadrants: Do Next / Scheduled / On Deck / Backlog", async () => {
|
test("the four tag destinations: Urgent / In Progress / On Deck / Backlog", async () => {
|
||||||
const notes = [
|
const notes = [
|
||||||
// active + due within window -> Do Next
|
// active + due within window -> Do Next
|
||||||
makeNote({
|
makeNote({
|
||||||
@ -591,8 +589,9 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
];
|
];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(folders, notes, {
|
makeAdapter(folders, notes, {
|
||||||
donext: ["in-progress"],
|
donext: ["urgent"],
|
||||||
sched: ["in-progress"],
|
sched: ["in-progress"],
|
||||||
|
ondeck: ["on-deck"],
|
||||||
}),
|
}),
|
||||||
"matrix-note",
|
"matrix-note",
|
||||||
"board",
|
"board",
|
||||||
@ -605,10 +604,10 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]);
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["backlog"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("urgent tag overrides: in-progress + urgent + no date -> Do Next", async () => {
|
test("urgent takes precedence over overlapping workflow tags", async () => {
|
||||||
const notes = [makeNote({ id: "hot", is_todo: 0 })];
|
const notes = [makeNote({ id: "hot", is_todo: 0 })];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(folders, notes, { hot: ["in-progress", "urgent"] }),
|
makeAdapter(folders, notes, { hot: ["on-deck", "in-progress", "urgent"] }),
|
||||||
"matrix-note",
|
"matrix-note",
|
||||||
"board",
|
"board",
|
||||||
parseMatrixConfig({ todos: "all" }),
|
parseMatrixConfig({ todos: "all" }),
|
||||||
@ -617,7 +616,7 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]);
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["hot"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("urgent tag alone (no date, not active) -> On Deck", async () => {
|
test("urgent tag alone routes to Urgent", async () => {
|
||||||
const notes = [makeNote({ id: "flag", is_todo: 0 })];
|
const notes = [makeNote({ id: "flag", is_todo: 0 })];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(folders, notes, { flag: ["urgent"] }),
|
makeAdapter(folders, notes, { flag: ["urgent"] }),
|
||||||
@ -626,10 +625,10 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
parseMatrixConfig({ todos: "all" }),
|
parseMatrixConfig({ todos: "all" }),
|
||||||
NOW
|
NOW
|
||||||
);
|
);
|
||||||
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["flag"]);
|
expect(result.board.topLeft.map((c) => c.id)).toEqual(["flag"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("overdue counts as due soon", async () => {
|
test("native due dates do not affect Skeleton routing", async () => {
|
||||||
const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })];
|
const notes = [makeNote({ id: "late", todo_due: iso(2026, 6, 1) })];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(folders, notes),
|
makeAdapter(folders, notes),
|
||||||
@ -638,10 +637,10 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
parseMatrixConfig({ todos: "all" }),
|
parseMatrixConfig({ todos: "all" }),
|
||||||
NOW
|
NOW
|
||||||
);
|
);
|
||||||
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["late"]);
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["late"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dateless in-progress work -> Scheduled (active, no clock)", async () => {
|
test("in-progress work routes to In Progress", async () => {
|
||||||
const notes = [makeNote({ id: "wip" })];
|
const notes = [makeNote({ id: "wip" })];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(folders, notes, { wip: ["in-progress"] }),
|
makeAdapter(folders, notes, { wip: ["in-progress"] }),
|
||||||
@ -653,7 +652,7 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]);
|
expect(result.board.topRight.map((c) => c.id)).toEqual(["wip"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("urgent-window boundary: exactly N days out is due soon; N+1 is not", async () => {
|
test("future dates do not affect Skeleton routing", async () => {
|
||||||
const notes = [
|
const notes = [
|
||||||
makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days
|
makeNote({ id: "edge", todo_due: iso(2026, 6, 18) }), // +3 days
|
||||||
makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4
|
makeNote({ id: "past-edge", todo_due: iso(2026, 6, 19) }), // +4
|
||||||
@ -662,14 +661,13 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
makeAdapter(folders, notes),
|
makeAdapter(folders, notes),
|
||||||
"matrix-note",
|
"matrix-note",
|
||||||
"board",
|
"board",
|
||||||
parseMatrixConfig({ todos: "all", "urgent-window": 3 }),
|
parseMatrixConfig({ todos: "all" }),
|
||||||
NOW
|
NOW
|
||||||
);
|
);
|
||||||
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["edge"]);
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["edge", "past-edge"]);
|
||||||
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["past-edge"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("gtd date override affects due-soon just like a due date", async () => {
|
test("gtd date override does not affect Skeleton routing", async () => {
|
||||||
const body = "```gtd\ndate: 2026-06-16\n```";
|
const body = "```gtd\ndate: 2026-06-16\n```";
|
||||||
const notes = [makeNote({ id: "g", body: body })];
|
const notes = [makeNote({ id: "g", body: body })];
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
@ -679,7 +677,7 @@ describe("collectMatrix — Skeleton mode", () => {
|
|||||||
parseMatrixConfig({}),
|
parseMatrixConfig({}),
|
||||||
NOW
|
NOW
|
||||||
);
|
);
|
||||||
expect(result.board.bottomLeft.map((c) => c.id)).toEqual(["g"]);
|
expect(result.board.bottomRight.map((c) => c.id)).toEqual(["g"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -823,11 +821,12 @@ describe("collectMatrix — notebook groups", () => {
|
|||||||
makeNote({ id: "bl", parent_id: "child", is_todo: 0 }),
|
makeNote({ id: "bl", parent_id: "child", is_todo: 0 }),
|
||||||
makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
|
makeNote({ id: "br", parent_id: "child", is_todo: 0 }),
|
||||||
];
|
];
|
||||||
const tags = {
|
const tags = mode === "skeleton" ? {
|
||||||
"root-left": ["urgent"],
|
"root-left": ["on-deck"], tl: ["urgent"],
|
||||||
tl: ["urgent", "important", "in-progress"],
|
tr: ["in-progress"], bl: ["on-deck"],
|
||||||
tr: ["important", "in-progress"],
|
} : {
|
||||||
bl: ["urgent"],
|
"root-left": ["urgent"], tl: ["urgent", "important"],
|
||||||
|
tr: ["important"], bl: ["urgent"],
|
||||||
};
|
};
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
makeAdapter(tree, notes, tags),
|
makeAdapter(tree, notes, tags),
|
||||||
|
|||||||
@ -70,7 +70,7 @@ describe("validateAndMoveMatrixCard", () => {
|
|||||||
test.each([
|
test.each([
|
||||||
["", "error"],
|
["", "error"],
|
||||||
["mode: eisenhower\neditable: no", "error"],
|
["mode: eisenhower\neditable: no", "error"],
|
||||||
["mode: skeleton\neditable: yes", "error"],
|
["mode: skeleton\neditable: no", "error"],
|
||||||
["mode: eisenhower\neditable: yes\nurgent-tag: same\nimportant-tag: SAME", "error"],
|
["mode: eisenhower\neditable: yes\nurgent-tag: same\nimportant-tag: SAME", "error"],
|
||||||
["editable: [yes", "stale"],
|
["editable: [yes", "stale"],
|
||||||
] as const)("rejects invalid or non-editable config", async (rawConfig, status) => {
|
] as const)("rejects invalid or non-editable config", async (rawConfig, status) => {
|
||||||
@ -79,6 +79,16 @@ describe("validateAndMoveMatrixCard", () => {
|
|||||||
expect(writes).toEqual([]);
|
expect(writes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts an editable Skeleton destination", async () => {
|
||||||
|
const { read, mutation, writes } = setup();
|
||||||
|
const moveResult = await validateAndMoveMatrixCard(intent({
|
||||||
|
rawConfig: "mode: skeleton\neditable: yes\nnotes: all\ntodos: all",
|
||||||
|
destination: "bottomLeft",
|
||||||
|
}), host, read, mutation);
|
||||||
|
expect(moveResult.status).toBe("success");
|
||||||
|
expect(writes).toEqual(["attach:on-deck"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("requires the selected host identity", async () => {
|
test("requires the selected host identity", async () => {
|
||||||
for (const selected of [null, { id: "other", parentId: "board" }]) {
|
for (const selected of [null, { id: "other", parentId: "board" }]) {
|
||||||
const { read, mutation, writes } = setup();
|
const { read, mutation, writes } = setup();
|
||||||
|
|||||||
@ -35,7 +35,11 @@ const states: Array<{
|
|||||||
{ destination: "bottomRight", urgent: false, important: false },
|
{ destination: "bottomRight", urgent: false, important: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const config = { urgentTag: "urgent", importantTag: "important" };
|
const config = {
|
||||||
|
mode: "eisenhower" as const,
|
||||||
|
urgentTag: "urgent", importantTag: "important",
|
||||||
|
inProgressTag: "in-progress", onDeckTag: "on-deck",
|
||||||
|
};
|
||||||
|
|
||||||
describe("moveMatrixItem", () => {
|
describe("moveMatrixItem", () => {
|
||||||
for (const isTodo of [false, true]) {
|
for (const isTodo of [false, true]) {
|
||||||
@ -66,7 +70,7 @@ describe("moveMatrixItem", () => {
|
|||||||
{ id: "keep", title: "keep-me" },
|
{ id: "keep", title: "keep-me" },
|
||||||
], [{ id: "m", title: "MATTERS" }]);
|
], [{ id: "m", title: "MATTERS" }]);
|
||||||
await moveMatrixItem(adapter, "card", "topRight", {
|
await moveMatrixItem(adapter, "card", "topRight", {
|
||||||
urgentTag: "fire", importantTag: "matters",
|
...config, urgentTag: "fire", importantTag: "matters",
|
||||||
});
|
});
|
||||||
expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]);
|
expect(calls).toEqual(["detach:a", "detach:z", "attach:m"]);
|
||||||
});
|
});
|
||||||
@ -77,6 +81,23 @@ describe("moveMatrixItem", () => {
|
|||||||
expect(calls).toEqual(["create:urgent", "attach:urgent"]);
|
expect(calls).toEqual(["create:urgent", "attach:urgent"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["topLeft", "urgent"], ["topRight", "in-progress"],
|
||||||
|
["bottomLeft", "on-deck"], ["bottomRight", null],
|
||||||
|
] as Array<[MatrixDestination, string | null]>)
|
||||||
|
("Skeleton %s normalizes the three workflow tags", async (destination, expected) => {
|
||||||
|
const { adapter, calls } = fakeAdapter(note(false), [
|
||||||
|
{ id: "u", title: "urgent" }, { id: "i", title: "in-progress" },
|
||||||
|
{ id: "o", title: "on-deck" },
|
||||||
|
], [
|
||||||
|
{ id: "u", title: "urgent" }, { id: "i", title: "in-progress" },
|
||||||
|
{ id: "o", title: "on-deck" },
|
||||||
|
]);
|
||||||
|
await moveMatrixItem(adapter, "card", destination, { ...config, mode: "skeleton" });
|
||||||
|
const keptId = expected === "urgent" ? "u" : expected === "in-progress" ? "i" : expected === "on-deck" ? "o" : null;
|
||||||
|
expect(calls).toEqual(["u", "i", "o"].filter((id) => id !== keptId).map((id) => `detach:${id}`));
|
||||||
|
});
|
||||||
|
|
||||||
test("fresh validation can reject completed or newly filtered cards before writes", async () => {
|
test("fresh validation can reject completed or newly filtered cards before writes", async () => {
|
||||||
for (const current of [note(true), note(false)]) {
|
for (const current of [note(true), note(false)]) {
|
||||||
current.todo_completed = current.is_todo ? 100 : 0;
|
current.todo_completed = current.is_todo ? 100 : 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user