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.
## 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

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
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
Backlog removes both. Moves never change completion, dates, content, notebook,
or unrelated tags, and sorting plus pagination remain authoritative after the
matrix refreshes. In Skeleton mode, destinations keep exactly one of the three
workflow tags (or none for Backlog); due dates are never changed.
Backlog removes both. **Done** completes a native to-do or applies the configured
`done-tag` to an ordinary note, so the card leaves the matrix. Quadrant moves
never change completion, dates, content, notebook, or unrelated tags, and
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 📄
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",
"version": "2.2.0",
"version": "2.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "joplin-plugin-gtd-calendar",
"version": "2.2.0",
"version": "2.2.1",
"license": "MIT",
"dependencies": {
"date-fns": "^2.29.3",

View File

@ -1,6 +1,6 @@
{
"name": "joplin-plugin-gtd-calendar",
"version": "2.2.0",
"version": "2.2.1",
"scripts": {
"test": "jest",
"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;
inProgressTag: string;
onDeckTag: string;
doneTag: string;
}
export interface MatrixMutationOutcome {
@ -19,7 +20,7 @@ export interface MatrixMutationOutcome {
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 },
topRight: { urgent: false, important: true },
bottomLeft: { urgent: true, important: false },
@ -38,10 +39,24 @@ export async function moveMatrixItem(
if (!note) throw new Error("The card no longer exists.");
const noteTags = await adapter.getNoteTags(noteId);
if (validateFreshState) validateFreshState(note, noteTags);
const target = TARGETS[destination];
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") {
const targetTag: Record<MatrixDestination, string | null> = {
const targetTag: Record<Exclude<MatrixDestination, "done">, string | null> = {
topLeft: config.urgentTag,
topRight: config.inProgressTag,
bottomLeft: config.onDeckTag,

View File

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

View File

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

View File

@ -942,6 +942,7 @@
["topRight", interaction.labels.topRight],
["bottomLeft", interaction.labels.bottomLeft],
["bottomRight", interaction.labels.bottomRight],
["done", "Done"],
].forEach(function (entry) {
const item = document.createElement("button");
item.type = "button";
@ -1237,11 +1238,14 @@
viewInstanceId: context.viewInstanceId,
}).then(function (result) {
if (!result || result.viewInstanceId !== context.viewInstanceId) return;
const succeeded = result.status === "success";
context.statusMessage = result.status === "success"
? "Moved “" + card.title + "”."
: result.message || "The move could not be completed.";
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 () {
context.statusMessage = "The move could not be completed. Refresh and try again.";
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) {
const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, {

View File

@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
"app_min_version": "2.7",
"version": "2.2.0",
"version": "2.2.1",
"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.",
"author": "Victor Wiebe",

View File

@ -32,7 +32,9 @@ function setup(current: RawNote | null = card(), attached: RawTag[] = []) {
createTag: async (title) => ({ id: title, title }),
attachTag: async (tagId) => { writes.push(`attach:${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 };
}
@ -58,7 +60,7 @@ describe("validateAndMoveMatrixCard", () => {
});
test.each([
{}, intent({ view: "kanban" }), intent({ destination: "done" }),
{}, intent({ view: "kanban" }), intent({ destination: "diagonal" }),
intent({ cardId: "" }), intent({ rawConfig: { editable: "yes" } }),
intent({ patch: { tags: ["urgent"] } }),
])("rejects malformed or expanded input without writes", async (request) => {
@ -89,6 +91,19 @@ describe("validateAndMoveMatrixCard", () => {
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 () => {
for (const selected of [null, { id: "other", parentId: "board" }]) {
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 }; },
attachTag: async (tagId) => { calls.push(`attach:${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 };
}
@ -38,7 +40,7 @@ const states: Array<{
const config = {
mode: "eisenhower" as const,
urgentTag: "urgent", importantTag: "important",
inProgressTag: "in-progress", onDeckTag: "on-deck",
inProgressTag: "in-progress", onDeckTag: "on-deck", doneTag: "done",
};
describe("moveMatrixItem", () => {
@ -81,6 +83,30 @@ describe("moveMatrixItem", () => {
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([
["topLeft", "urgent"], ["topRight", "in-progress"],
["bottomLeft", "on-deck"], ["bottomRight", null],

View File

@ -17,12 +17,12 @@ describe("kanban move protocol", () => {
});
describe("matrix move protocol", () => {
test.each(["topLeft", "topRight", "bottomLeft", "bottomRight"])(
test.each(["topLeft", "topRight", "bottomLeft", "bottomRight", "done"])(
"accepts fixed destination %s",
(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",
(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}"`);
}
expect(webview).toContain('["done", "Done"]');
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("viewInstanceId: context.viewInstanceId");
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", () => {