Release v2.2.1 with matrix completion

This commit is contained in:
Victor Wiebe 2026-08-08 15:00:51 -04:00
parent 2b8b17f4fe
commit 1e2bb69bb6
13 changed files with 115 additions and 20 deletions

View File

@ -2,7 +2,17 @@
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 ## 2.2.1 — Unreleased
### Matrix completion action
- Editable Skeleton and Eisenhower matrix move menus now include **Done**.
- Done completes native to-dos and applies the configured `done-tag` to
ordinary notes, removing the completed card from the matrix.
- Successful matrix moves now perform a settled follow-up refresh after the
immediate redraw, avoiding stale cards while Joplin publishes tag/to-do writes.
## 2.2.0
### Tag-driven editable Skeleton matrix ### Tag-driven editable Skeleton matrix

View File

@ -324,10 +324,16 @@ Each quadrant expands independently in `page-size` batches. The **List more** ho
On an editable 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. **Done** completes a native to-do or applies the configured
or unrelated tags, and sorting plus pagination remain authoritative after the `done-tag` to an ordinary note, so the card leaves the matrix. Quadrant moves
matrix refreshes. In Skeleton mode, destinations keep exactly one of the three never change completion, dates, content, notebook, or unrelated tags, and
workflow tags (or none for Backlog); due dates are never changed. sorting plus pagination remain authoritative after the matrix refreshes. In
Skeleton mode, quadrant destinations keep exactly one of the three workflow
tags (or none for Backlog); due dates are never changed.
After any successful matrix move, the view refreshes in place and performs a
short follow-up refresh to account for Joplin's write propagation; reloading the
dashboard note is not required.
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
View File

@ -1,12 +1,12 @@
{ {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "2.2.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "2.2.0", "version": "2.2.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"date-fns": "^2.29.3", "date-fns": "^2.29.3",

View File

@ -1,6 +1,6 @@
{ {
"name": "joplin-plugin-gtd-calendar", "name": "joplin-plugin-gtd-calendar",
"version": "2.2.0", "version": "2.2.1",
"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",

View File

@ -12,6 +12,7 @@ export interface MatrixMutationConfig {
importantTag: string; importantTag: string;
inProgressTag: string; inProgressTag: string;
onDeckTag: string; onDeckTag: string;
doneTag: string;
} }
export interface MatrixMutationOutcome { export interface MatrixMutationOutcome {
@ -19,7 +20,7 @@ export interface MatrixMutationOutcome {
changed: boolean; changed: boolean;
} }
const TARGETS: Record<MatrixDestination, { urgent: boolean; important: boolean }> = { const TARGETS: Record<Exclude<MatrixDestination, "done">, { urgent: boolean; important: boolean }> = {
topLeft: { urgent: true, important: true }, topLeft: { urgent: true, important: true },
topRight: { urgent: false, important: true }, topRight: { urgent: false, important: true },
bottomLeft: { urgent: true, important: false }, bottomLeft: { urgent: true, important: false },
@ -38,10 +39,24 @@ export async function moveMatrixItem(
if (!note) throw new Error("The card no longer exists."); if (!note) throw new Error("The card no longer exists.");
const noteTags = await adapter.getNoteTags(noteId); const noteTags = await adapter.getNoteTags(noteId);
if (validateFreshState) validateFreshState(note, noteTags); if (validateFreshState) validateFreshState(note, noteTags);
const target = TARGETS[destination];
let changed = false; let changed = false;
if (destination === "done") {
if (note.is_todo) {
if (note.todo_completed <= 0) {
await adapter.setTodoCompleted(noteId, Date.now());
changed = true;
}
} else {
changed = (await ensureNoteTag(adapter, noteId, config.doneTag, noteTags)) || changed;
}
changed =
(await removeNoteTag(adapter, noteId, config.inProgressTag, noteTags)) || changed;
return { note, changed };
}
const target = TARGETS[destination];
if (config.mode === "skeleton") { if (config.mode === "skeleton") {
const targetTag: Record<MatrixDestination, string | null> = { const targetTag: Record<Exclude<MatrixDestination, "done">, string | null> = {
topLeft: config.urgentTag, topLeft: config.urgentTag,
topRight: config.inProgressTag, topRight: config.inProgressTag,
bottomLeft: config.onDeckTag, bottomLeft: config.onDeckTag,

View File

@ -16,6 +16,7 @@ const MATRIX_DESTINATIONS: MatrixDestination[] = [
"topRight", "topRight",
"bottomLeft", "bottomLeft",
"bottomRight", "bottomRight",
"done",
]; ];
export function isMatrixDestination(value: unknown): value is MatrixDestination { export function isMatrixDestination(value: unknown): value is MatrixDestination {

View File

@ -276,7 +276,8 @@ export type MatrixDestination =
| "topLeft" | "topLeft"
| "topRight" | "topRight"
| "bottomLeft" | "bottomLeft"
| "bottomRight"; | "bottomRight"
| "done";
export interface MoveMatrixCardIntent { export interface MoveMatrixCardIntent {
type: "moveMatrixCard"; type: "moveMatrixCard";

View File

@ -942,6 +942,7 @@
["topRight", interaction.labels.topRight], ["topRight", interaction.labels.topRight],
["bottomLeft", interaction.labels.bottomLeft], ["bottomLeft", interaction.labels.bottomLeft],
["bottomRight", interaction.labels.bottomRight], ["bottomRight", interaction.labels.bottomRight],
["done", "Done"],
].forEach(function (entry) { ].forEach(function (entry) {
const item = document.createElement("button"); const item = document.createElement("button");
item.type = "button"; item.type = "button";
@ -1237,11 +1238,14 @@
viewInstanceId: context.viewInstanceId, viewInstanceId: context.viewInstanceId,
}).then(function (result) { }).then(function (result) {
if (!result || result.viewInstanceId !== context.viewInstanceId) return; if (!result || result.viewInstanceId !== context.viewInstanceId) return;
const succeeded = result.status === "success";
context.statusMessage = result.status === "success" context.statusMessage = result.status === "success"
? "Moved “" + card.title + "”." ? "Moved “" + card.title + "”."
: result.message || "The move could not be completed."; : result.message || "The move could not be completed.";
context.statusError = result.status !== "success"; context.statusError = result.status !== "success";
return refreshMatrix(interaction.root, context); return refreshMatrix(interaction.root, context).then(function () {
if (succeeded) scheduleMatrixRefresh(interaction.root, context);
});
}).catch(function () { }).catch(function () {
context.statusMessage = "The move could not be completed. Refresh and try again."; context.statusMessage = "The move could not be completed. Refresh and try again.";
context.statusError = true; context.statusError = true;
@ -1286,6 +1290,20 @@
}); });
} }
// Joplin can briefly return the pre-mutation folder/tag state immediately
// after a successful write. Follow the responsive first refresh with one
// settled refresh so moved and completed cards reliably leave their old slot.
function scheduleMatrixRefresh(root, context) {
if (context.postMoveRefreshTimer) clearTimeout(context.postMoveRefreshTimer);
context.postMoveRefreshTimer = setTimeout(function () {
context.postMoveRefreshTimer = null;
refreshMatrix(root, context).catch(function () {
// The move already succeeded and the first refresh already rendered;
// a transient follow-up read failure should not turn it into an error.
});
}, 400);
}
function formatCardDate(dateISO) { function formatCardDate(dateISO) {
const date = isoToLocalDate(dateISO); const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {

View File

@ -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.2.0", "version": "2.2.1",
"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",

View File

@ -32,7 +32,9 @@ function setup(current: RawNote | null = card(), attached: RawTag[] = []) {
createTag: async (title) => ({ id: title, title }), createTag: async (title) => ({ id: title, title }),
attachTag: async (tagId) => { writes.push(`attach:${tagId}`); }, attachTag: async (tagId) => { writes.push(`attach:${tagId}`); },
detachTag: async (tagId) => { writes.push(`detach:${tagId}`); }, detachTag: async (tagId) => { writes.push(`detach:${tagId}`); },
setTodoCompleted: async () => { throw new Error("completion write forbidden"); }, setTodoCompleted: async (_noteId, completedTime) => {
writes.push(`complete:${completedTime > 0}`);
},
}; };
return { read, mutation, writes }; return { read, mutation, writes };
} }
@ -58,7 +60,7 @@ describe("validateAndMoveMatrixCard", () => {
}); });
test.each([ test.each([
{}, intent({ view: "kanban" }), intent({ destination: "done" }), {}, intent({ view: "kanban" }), intent({ destination: "diagonal" }),
intent({ cardId: "" }), intent({ rawConfig: { editable: "yes" } }), intent({ cardId: "" }), intent({ rawConfig: { editable: "yes" } }),
intent({ patch: { tags: ["urgent"] } }), intent({ patch: { tags: ["urgent"] } }),
])("rejects malformed or expanded input without writes", async (request) => { ])("rejects malformed or expanded input without writes", async (request) => {
@ -89,6 +91,19 @@ describe("validateAndMoveMatrixCard", () => {
expect(writes).toEqual(["attach:on-deck"]); expect(writes).toEqual(["attach:on-deck"]);
}); });
test.each(["skeleton", "eisenhower"] as const)(
"accepts Done for an editable %s matrix",
async (mode) => {
const { read, mutation, writes } = setup(card({ is_todo: 1 }));
const moveResult = await validateAndMoveMatrixCard(intent({
rawConfig: `mode: ${mode}\neditable: yes\nnotes: all\ntodos: all`,
destination: "done",
}), host, read, mutation);
expect(moveResult.status).toBe("success");
expect(writes).toEqual(["complete:true"]);
}
);
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();

View File

@ -19,7 +19,9 @@ function fakeAdapter(current: RawNote | null, attached: RawTag[], all?: RawTag[]
createTag: async (title) => { calls.push(`create:${title}`); return { id: title, title }; }, createTag: async (title) => { calls.push(`create:${title}`); return { id: title, title }; },
attachTag: async (tagId) => { calls.push(`attach:${tagId}`); }, attachTag: async (tagId) => { calls.push(`attach:${tagId}`); },
detachTag: async (tagId) => { calls.push(`detach:${tagId}`); }, detachTag: async (tagId) => { calls.push(`detach:${tagId}`); },
setTodoCompleted: async () => { throw new Error("matrix must not patch completion"); }, setTodoCompleted: async (_noteId, completedTime) => {
calls.push(`complete:${completedTime > 0}`);
},
}; };
return { adapter, calls }; return { adapter, calls };
} }
@ -38,7 +40,7 @@ const states: Array<{
const config = { const config = {
mode: "eisenhower" as const, mode: "eisenhower" as const,
urgentTag: "urgent", importantTag: "important", urgentTag: "urgent", importantTag: "important",
inProgressTag: "in-progress", onDeckTag: "on-deck", inProgressTag: "in-progress", onDeckTag: "on-deck", doneTag: "done",
}; };
describe("moveMatrixItem", () => { describe("moveMatrixItem", () => {
@ -81,6 +83,30 @@ describe("moveMatrixItem", () => {
expect(calls).toEqual(["create:urgent", "attach:urgent"]); expect(calls).toEqual(["create:urgent", "attach:urgent"]);
}); });
test.each(["skeleton", "eisenhower"] as const)(
"%s Done completes a native to-do and removes its in-progress tag",
async (mode) => {
const { adapter, calls } = fakeAdapter(note(true), [
{ id: "p", title: "in-progress" }, { id: "u", title: "urgent" },
]);
await moveMatrixItem(adapter, "card", "done", { ...config, mode });
expect(calls).toEqual(["complete:true", "detach:p"]);
}
);
test.each(["skeleton", "eisenhower"] as const)(
"%s Done applies the configured done tag to an ordinary note",
async (mode) => {
const { adapter, calls } = fakeAdapter(note(false), [
{ id: "p", title: "in-progress" },
], [{ id: "d", title: "Finished" }]);
await moveMatrixItem(adapter, "card", "done", {
...config, mode, doneTag: "finished",
});
expect(calls).toEqual(["attach:d", "detach:p"]);
}
);
test.each([ test.each([
["topLeft", "urgent"], ["topRight", "in-progress"], ["topLeft", "urgent"], ["topRight", "in-progress"],
["bottomLeft", "on-deck"], ["bottomRight", null], ["bottomLeft", "on-deck"], ["bottomRight", null],

View File

@ -17,12 +17,12 @@ describe("kanban move protocol", () => {
}); });
describe("matrix move protocol", () => { describe("matrix move protocol", () => {
test.each(["topLeft", "topRight", "bottomLeft", "bottomRight"])( test.each(["topLeft", "topRight", "bottomLeft", "bottomRight", "done"])(
"accepts fixed destination %s", "accepts fixed destination %s",
(destination) => expect(isMatrixDestination(destination)).toBe(true) (destination) => expect(isMatrixDestination(destination)).toBe(true)
); );
test.each(["", "urgent", "done", null, 1, { tags: ["urgent"] }])( test.each(["", "urgent", "diagonal", null, 1, { tags: ["urgent"] }])(
"rejects arbitrary destination %p", "rejects arbitrary destination %p",
(destination) => expect(isMatrixDestination(destination)).toBe(false) (destination) => expect(isMatrixDestination(destination)).toBe(false)
); );

View File

@ -51,6 +51,7 @@ describe("Slice 15 editable matrix interaction contract", () => {
expect(webview).toContain(`["${destination}", interaction.labels.${destination}]`); expect(webview).toContain(`["${destination}", interaction.labels.${destination}]`);
expect(webview).toContain(`"${destination}"`); expect(webview).toContain(`"${destination}"`);
} }
expect(webview).toContain('["done", "Done"]');
expect(webview).toContain('data-matrix-destination'); expect(webview).toContain('data-matrix-destination');
}); });
@ -60,6 +61,8 @@ describe("Slice 15 editable matrix interaction contract", () => {
expect(webview).toContain('type: "getMatrix"'); expect(webview).toContain('type: "getMatrix"');
expect(webview).toContain("viewInstanceId: context.viewInstanceId"); expect(webview).toContain("viewInstanceId: context.viewInstanceId");
expect(webview).toContain("sequence !== context.requestSequence"); expect(webview).toContain("sequence !== context.requestSequence");
expect(webview).toContain("scheduleMatrixRefresh(interaction.root, context)");
expect(webview).toContain("context.postMoveRefreshTimer");
}); });
test("styles pointer targets, pending state, and live status", () => { test("styles pointer targets, pending state, and live status", () => {