diff --git a/CHANGELOG.md b/CHANGELOG.md index 8297d98..30676ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index d5f58f2..1c31d75 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 80d40f2..fb31cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b030455..fe1c694 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Gtd/matrixMutation.ts b/src/Gtd/matrixMutation.ts index 837dc11..33c637d 100644 --- a/src/Gtd/matrixMutation.ts +++ b/src/Gtd/matrixMutation.ts @@ -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 = { +const TARGETS: Record, { 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 = { + const targetTag: Record, string | null> = { topLeft: config.urgentTag, topRight: config.inProgressTag, bottomLeft: config.onDeckTag, diff --git a/src/Gtd/moveProtocol.ts b/src/Gtd/moveProtocol.ts index 58e0cd3..99ede52 100644 --- a/src/Gtd/moveProtocol.ts +++ b/src/Gtd/moveProtocol.ts @@ -16,6 +16,7 @@ const MATRIX_DESTINATIONS: MatrixDestination[] = [ "topRight", "bottomLeft", "bottomRight", + "done", ]; export function isMatrixDestination(value: unknown): value is MatrixDestination { diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 58b014e..d06bc72 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -276,7 +276,8 @@ export type MatrixDestination = | "topLeft" | "topRight" | "bottomLeft" - | "bottomRight"; + | "bottomRight" + | "done"; export interface MoveMatrixCardIntent { type: "moveMatrixCard"; diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index c812f58..3080e53 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -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, { diff --git a/src/manifest.json b/src/manifest.json index f0c79b9..995060e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -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", diff --git a/src/tests/Gtd/matrixMoveHandler.test.ts b/src/tests/Gtd/matrixMoveHandler.test.ts index 7598825..e3ff1bf 100644 --- a/src/tests/Gtd/matrixMoveHandler.test.ts +++ b/src/tests/Gtd/matrixMoveHandler.test.ts @@ -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(); diff --git a/src/tests/Gtd/matrixMutation.test.ts b/src/tests/Gtd/matrixMutation.test.ts index 4521bf4..dc30117 100644 --- a/src/tests/Gtd/matrixMutation.test.ts +++ b/src/tests/Gtd/matrixMutation.test.ts @@ -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], diff --git a/src/tests/Gtd/moveProtocol.test.ts b/src/tests/Gtd/moveProtocol.test.ts index 8f943c5..222d364 100644 --- a/src/tests/Gtd/moveProtocol.test.ts +++ b/src/tests/Gtd/moveProtocol.test.ts @@ -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) ); diff --git a/src/tests/Gtd/webviewInteraction.test.ts b/src/tests/Gtd/webviewInteraction.test.ts index eff2c54..28ba84c 100644 --- a/src/tests/Gtd/webviewInteraction.test.ts +++ b/src/tests/Gtd/webviewInteraction.test.ts @@ -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", () => {