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.
|
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
|
||||||
|
|
||||||
|
|||||||
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
|
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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user