Release v2.2.1 with matrix completion
This commit is contained in:
parent
2b8b17f4fe
commit
1e2bb69bb6
12
CHANGELOG.md
12
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
|
||||
|
||||
|
||||
14
README.md
14
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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,6 +16,7 @@ const MATRIX_DESTINATIONS: MatrixDestination[] = [
|
||||
"topRight",
|
||||
"bottomLeft",
|
||||
"bottomRight",
|
||||
"done",
|
||||
];
|
||||
|
||||
export function isMatrixDestination(value: unknown): value is MatrixDestination {
|
||||
|
||||
@ -276,7 +276,8 @@ export type MatrixDestination =
|
||||
| "topLeft"
|
||||
| "topRight"
|
||||
| "bottomLeft"
|
||||
| "bottomRight";
|
||||
| "bottomRight"
|
||||
| "done";
|
||||
|
||||
export interface MoveMatrixCardIntent {
|
||||
type: "moveMatrixCard";
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user