v0.2.0: mark recurring to-dos with a recurrence symbol
- Detect recurrence via the 'recurring' tag maintained by the Repeating To-Dos plugin (v2 stores recurrence in userData, not the note body, so tag-presence is the correct signal — not body scanning) - DataAdapter gains getNoteTagTitles; lookup runs for to-dos only (notes can't recur), keeping extra API calls minimal - GtdEvent.isRecurring threaded through collector and payload - Webview shows ↻ to the right of any icon/glyph across all views: strip tiles, hover-card rows, month-grid chips, unscheduled chips - RECURRING_TAG constant leaves room for a future configurable tag name - README documents the pairing; SPEC roadmap marks Option A shipped - Version bumped to 0.2.0; 2 new tests (49 total)
This commit is contained in:
parent
5d1173c78e
commit
cdb5ad022c
@ -75,6 +75,12 @@ Goes in any note or to-do that should appear on the calendar. **An empty block i
|
|||||||
|
|
||||||
To-dos keep being real Joplin to-dos throughout — due dates still sort, alarm, and sync exactly as before. Completed to-dos render checked and struck through.
|
To-dos keep being real Joplin to-dos throughout — due dates still sort, alarm, and sync exactly as before. Completed to-dos render checked and struck through.
|
||||||
|
|
||||||
|
## Repeating to-dos
|
||||||
|
|
||||||
|
GTD Calendar plays nicely with the **[Repeating To-Dos](https://joplinapp.org/plugins/plugin/com.github.TheScriptingGuy.joplin-plugin-repeating-todos/)** plugin (v2). Install it, set a to-do to repeat (e.g. every Tuesday), and that to-do appears on the calendar at its next due date like any other — no extra setup. Recurring to-dos are marked with a ↻ symbol so you can tell them apart at a glance.
|
||||||
|
|
||||||
|
Because that plugin keeps a recurring to-do as a single item at its *next* due date, the calendar shows the next occurrence rather than every future one. Recurrence is detected via the `recurring` tag the plugin maintains; if you use a different recurrence tool that tags its to-dos `recurring`, the ↻ will appear for those too.
|
||||||
|
|
||||||
## Drilldown
|
## Drilldown
|
||||||
|
|
||||||
Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note.
|
Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note.
|
||||||
|
|||||||
2
SPEC.md
2
SPEC.md
@ -188,7 +188,7 @@ Recorded direction, not commitments. Each gets a focused design pass before work
|
|||||||
|
|
||||||
Recurrence itself is delegated to an existing community plugin (open-environment approach; we don't reimplement it). Candidates evaluated: BeatLink's original *Repeating To-Dos* (unmaintained), the *[Revived]* fork (maintained, GitHub issues only), and *Repeating To-dos v2* by TheScriptingGuy (`com.github.TheScriptingGuy.joplin-plugin-repeating-todos`, best-documented). All share the same mechanic: on completion the to-do's alarm date is reset to the next occurrence and the to-do is unmarked — so at any moment a recurring to-do is a **single** to-do at its next due date, with the recurrence rule stored in the note body.
|
Recurrence itself is delegated to an existing community plugin (open-environment approach; we don't reimplement it). Candidates evaluated: BeatLink's original *Repeating To-Dos* (unmaintained), the *[Revived]* fork (maintained, GitHub issues only), and *Repeating To-dos v2* by TheScriptingGuy (`com.github.TheScriptingGuy.joplin-plugin-repeating-todos`, best-documented). All share the same mechanic: on completion the to-do's alarm date is reset to the next occurrence and the to-do is unmarked — so at any moment a recurring to-do is a **single** to-do at its next due date, with the recurrence rule stored in the note body.
|
||||||
|
|
||||||
- **Option A (chosen for 0.2.0):** detect the recurrence marker in the body and tag those events with a ↻ glyph. No series projection — the to-do shows at its next due date like any dated to-do. Minimal, robust, no hard coupling to another plugin's body format.
|
- **Option A (shipped in 0.2.0):** detect recurrence and mark those events with a ↻ glyph. *Implementation note:* v2 of the plugin stores recurrence in Joplin's `userData` (not the note body) and maintains a `recurring` **tag** as its query index — so detection is by tag presence on to-dos (constant `RECURRING_TAG`), not body-scanning. Cleaner than the originally-planned body parse, and reuses the tag-fetching the kanban work will need. No series projection — the to-do shows at its next due date.
|
||||||
- **Option B (in pocket):** parse the recurrence rule and project all future occurrences across the visible window. Deferred because the body format has proven to be a moving target across forks; promote to a 0.2.x only if Option A's single-occurrence view proves insufficient, and target a specific fork's format at that time.
|
- **Option B (in pocket):** parse the recurrence rule and project all future occurrences across the visible window. Deferred because the body format has proven to be a moving target across forks; promote to a 0.2.x only if Option A's single-occurrence view proves insufficient, and target a specific fork's format at that time.
|
||||||
- **README:** recommend the maintained fork (v2 first, Revived as fallback); warn about the known "recurrence block injected into all notes" failure mode; note coexistence (their plugin moves the anchor, ours draws the marker).
|
- **README:** recommend the maintained fork (v2 first, Revived as fallback); warn about the known "recurrence block injected into all notes" failure mode; note coexistence (their plugin moves the anchor, ours draws the marker).
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { CalendarConfig, DataAdapter, GtdEvent, RawNote } from "./types";
|
import {
|
||||||
|
CalendarConfig,
|
||||||
|
DataAdapter,
|
||||||
|
GtdEvent,
|
||||||
|
RawNote,
|
||||||
|
RECURRING_TAG,
|
||||||
|
} from "./types";
|
||||||
import resolveScopedFolderIds from "./folderScope";
|
import resolveScopedFolderIds from "./folderScope";
|
||||||
import extractGtdBlock from "./gtdBlock";
|
import extractGtdBlock from "./gtdBlock";
|
||||||
import { resolveEventDate } from "./resolveDate";
|
import { resolveEventDate } from "./resolveDate";
|
||||||
@ -44,7 +50,7 @@ export default async function collectEvents(
|
|||||||
scannedNotes += 1;
|
scannedNotes += 1;
|
||||||
if (note.id === calendarNoteId) continue;
|
if (note.id === calendarNoteId) continue;
|
||||||
|
|
||||||
const event = noteToEvent(note, config, warnings);
|
const event = await noteToEvent(adapter, note, config, warnings);
|
||||||
if (event) events.push(event);
|
if (event) events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,11 +65,12 @@ export default async function collectEvents(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function noteToEvent(
|
async function noteToEvent(
|
||||||
|
adapter: DataAdapter,
|
||||||
note: RawNote,
|
note: RawNote,
|
||||||
config: CalendarConfig,
|
config: CalendarConfig,
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
): GtdEvent | null {
|
): Promise<GtdEvent | null> {
|
||||||
const mode = note.is_todo ? config.todos : config.notes;
|
const mode = note.is_todo ? config.todos : config.notes;
|
||||||
if (mode === "none") return null;
|
if (mode === "none") return null;
|
||||||
|
|
||||||
@ -81,6 +88,13 @@ function noteToEvent(
|
|||||||
warnings.push(`"${note.title}": unparseable date "${block.date}"`);
|
warnings.push(`"${note.title}": unparseable date "${block.date}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only to-dos can recur; skip the tag lookup for plain notes.
|
||||||
|
let isRecurring = false;
|
||||||
|
if (note.is_todo) {
|
||||||
|
const tags = await adapter.getNoteTagTitles(note.id);
|
||||||
|
isRecurring = tags.includes(RECURRING_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
title: block && block.title ? block.title : note.title,
|
title: block && block.title ? block.title : note.title,
|
||||||
@ -92,6 +106,7 @@ function noteToEvent(
|
|||||||
icon: block ? block.icon : null,
|
icon: block ? block.icon : null,
|
||||||
text: block ? block.text : null,
|
text: block ? block.text : null,
|
||||||
updatedTime: note.updated_time,
|
updatedTime: note.updated_time,
|
||||||
|
isRecurring,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,8 @@ export interface GtdEvent {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
updatedTime: number;
|
updatedTime: number;
|
||||||
|
/** True when the to-do carries the recurrence index tag (see RECURRING_TAG). */
|
||||||
|
isRecurring: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawFolder {
|
export interface RawFolder {
|
||||||
@ -66,4 +68,14 @@ export interface RawFolder {
|
|||||||
export interface DataAdapter {
|
export interface DataAdapter {
|
||||||
getFolders(): Promise<RawFolder[]>;
|
getFolders(): Promise<RawFolder[]>;
|
||||||
getNotesInFolder(folderId: string): Promise<RawNote[]>;
|
getNotesInFolder(folderId: string): Promise<RawNote[]>;
|
||||||
|
/** Lowercased tag titles for a note. Used to detect recurrence. */
|
||||||
|
getNoteTagTitles(noteId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag maintained by the "Repeating To-Dos" plugin family as a query index
|
||||||
|
* for recurring to-dos. We treat its presence as the recurrence signal.
|
||||||
|
* Kept as a constant so a future `recurring-tag:` config option (or a
|
||||||
|
* switch to a different recurrence plugin) is a one-line change.
|
||||||
|
*/
|
||||||
|
export const RECURRING_TAG = "recurring";
|
||||||
|
|||||||
@ -86,6 +86,12 @@
|
|||||||
return event.completed ? "☑" : "☐";
|
return event.completed ? "☑" : "☐";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence mark, shown to the right of any icon/glyph.
|
||||||
|
var RECUR_MARK = "↻";
|
||||||
|
function recurSuffix(event) {
|
||||||
|
return event.isRecurring ? " " + RECUR_MARK : "";
|
||||||
|
}
|
||||||
|
|
||||||
function renderPayload(el, payload, contentScriptId) {
|
function renderPayload(el, payload, contentScriptId) {
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
|
|
||||||
@ -188,11 +194,11 @@
|
|||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "event";
|
icon.className = "event";
|
||||||
if (first.fgColour) icon.style.color = first.fgColour;
|
if (first.fgColour) icon.style.color = first.fgColour;
|
||||||
icon.textContent = first.icon
|
icon.textContent =
|
||||||
? first.icon
|
(first.icon ? first.icon : first.title.slice(0, 2)) +
|
||||||
: first.title.slice(0, 2);
|
recurSuffix(first);
|
||||||
if (group.events.length > 1) {
|
if (group.events.length > 1) {
|
||||||
icon.textContent += "+" + (group.events.length - 1);
|
icon.textContent += " +" + (group.events.length - 1);
|
||||||
}
|
}
|
||||||
tile.appendChild(icon);
|
tile.appendChild(icon);
|
||||||
|
|
||||||
@ -237,7 +243,8 @@
|
|||||||
|
|
||||||
const title = document.createElement("p");
|
const title = document.createElement("p");
|
||||||
title.className = "title";
|
title.className = "title";
|
||||||
title.textContent = typeGlyph(event) + " " + event.title;
|
title.textContent =
|
||||||
|
typeGlyph(event) + recurSuffix(event) + " " + event.title;
|
||||||
if (event.completed) title.style.textDecoration = "line-through";
|
if (event.completed) title.style.textDecoration = "line-through";
|
||||||
row.appendChild(title);
|
row.appendChild(title);
|
||||||
|
|
||||||
@ -319,6 +326,7 @@
|
|||||||
chip.className = "gtd-grid-chip gtd-calendar-clickable";
|
chip.className = "gtd-grid-chip gtd-calendar-clickable";
|
||||||
chip.textContent =
|
chip.textContent =
|
||||||
(event.icon ? event.icon : typeGlyph(event)) +
|
(event.icon ? event.icon : typeGlyph(event)) +
|
||||||
|
recurSuffix(event) +
|
||||||
" " +
|
" " +
|
||||||
event.title;
|
event.title;
|
||||||
chip.style.backgroundColor =
|
chip.style.backgroundColor =
|
||||||
@ -371,6 +379,7 @@
|
|||||||
item.className = "gtd-calendar-clickable";
|
item.className = "gtd-calendar-clickable";
|
||||||
item.textContent =
|
item.textContent =
|
||||||
(event.icon ? event.icon : typeGlyph(event)) +
|
(event.icon ? event.icon : typeGlyph(event)) +
|
||||||
|
recurSuffix(event) +
|
||||||
" " +
|
" " +
|
||||||
event.title;
|
event.title;
|
||||||
if (event.bgColour) item.style.backgroundColor = event.bgColour;
|
if (event.bgColour) item.style.backgroundColor = event.bgColour;
|
||||||
|
|||||||
@ -34,6 +34,14 @@ const joplinAdapter: DataAdapter = {
|
|||||||
fields: NOTE_FIELDS,
|
fields: NOTE_FIELDS,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getNoteTagTitles(noteId: string): Promise<string[]> {
|
||||||
|
const tags = await fetchAllPages<{ title: string }>(
|
||||||
|
["notes", noteId, "tags"],
|
||||||
|
{ fields: ["title"] }
|
||||||
|
);
|
||||||
|
return tags.map((tag) => (tag.title || "").toLowerCase());
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
||||||
|
|||||||
@ -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": "0.1.0",
|
"version": "0.2.0",
|
||||||
"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",
|
||||||
|
|||||||
@ -13,6 +13,7 @@ function makeEvent(overrides: Partial<GtdEvent>): GtdEvent {
|
|||||||
icon: null,
|
icon: null,
|
||||||
text: null,
|
text: null,
|
||||||
updatedTime: 0,
|
updatedTime: 0,
|
||||||
|
isRecurring: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,11 +222,18 @@ describe("date resolution", () => {
|
|||||||
// collectEvents (inclusion matrix + sorting, via a mock adapter)
|
// collectEvents (inclusion matrix + sorting, via a mock adapter)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function makeAdapter(folders: RawFolder[], notes: RawNote[]): DataAdapter {
|
function makeAdapter(
|
||||||
|
folders: RawFolder[],
|
||||||
|
notes: RawNote[],
|
||||||
|
recurringIds: string[] = []
|
||||||
|
): DataAdapter {
|
||||||
|
const recurring = new Set(recurringIds);
|
||||||
return {
|
return {
|
||||||
getFolders: async () => folders,
|
getFolders: async () => folders,
|
||||||
getNotesInFolder: async (folderId: string) =>
|
getNotesInFolder: async (folderId: string) =>
|
||||||
notes.filter((n) => n.parent_id === folderId),
|
notes.filter((n) => n.parent_id === folderId),
|
||||||
|
getNoteTagTitles: async (noteId: string) =>
|
||||||
|
recurring.has(noteId) ? ["recurring"] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,3 +355,59 @@ describe("collectEvents", () => {
|
|||||||
expect(result.events.map((e) => e.id)).toEqual(["n1", "t1", "n2"]);
|
expect(result.events.map((e) => e.id)).toEqual(["n1", "t1", "n2"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("collectEvents — recurrence detection", () => {
|
||||||
|
const folders: RawFolder[] = [{ id: "blogs", parent_id: "root" }];
|
||||||
|
|
||||||
|
const recurringTodoBody = "```gtd\n```";
|
||||||
|
const notes: RawNote[] = [
|
||||||
|
makeNote({
|
||||||
|
id: "rt",
|
||||||
|
title: "Repeating blog post",
|
||||||
|
parent_id: "blogs",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: new Date(2026, 6, 14).getTime(),
|
||||||
|
body: recurringTodoBody,
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "ot",
|
||||||
|
title: "One-off todo",
|
||||||
|
parent_id: "blogs",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: new Date(2026, 6, 15).getTime(),
|
||||||
|
body: "```gtd\n```",
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "rn",
|
||||||
|
title: "Note that happens to carry the tag",
|
||||||
|
parent_id: "blogs",
|
||||||
|
body: "```gtd\ndate: 2026-07-16\n```",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("a to-do with the recurring tag is flagged isRecurring", async () => {
|
||||||
|
const result = await collectEvents(
|
||||||
|
makeAdapter(folders, notes, ["rt"]),
|
||||||
|
"calendar-note",
|
||||||
|
"blogs",
|
||||||
|
parseCalendarConfig({ todos: "all", notes: "all" })
|
||||||
|
);
|
||||||
|
const recurring = result.events.find((e) => e.id === "rt")!;
|
||||||
|
const oneOff = result.events.find((e) => e.id === "ot")!;
|
||||||
|
expect(recurring.isRecurring).toBe(true);
|
||||||
|
expect(oneOff.isRecurring).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain notes are never flagged recurring even if tagged", async () => {
|
||||||
|
// "rn" is in the recurring set but is a note, not a todo — the tag
|
||||||
|
// lookup is skipped for notes, so it must come back false.
|
||||||
|
const result = await collectEvents(
|
||||||
|
makeAdapter(folders, notes, ["rn"]),
|
||||||
|
"calendar-note",
|
||||||
|
"blogs",
|
||||||
|
parseCalendarConfig({ todos: "all", notes: "all" })
|
||||||
|
);
|
||||||
|
const note = result.events.find((e) => e.id === "rn")!;
|
||||||
|
expect(note.isRecurring).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user