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:
Victor Wiebe 2026-06-13 13:47:49 +00:00 committed by vwiebe
parent 5d1173c78e
commit cdb5ad022c
10 changed files with 127 additions and 13 deletions

View File

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

View File

@ -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.
- **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.
- **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).

View File

@ -1,6 +1,6 @@
{
"name": "joplin-plugin-gtd-calendar",
"version": "0.1.0",
"version": "0.2.0",
"scripts": {
"test": "jest",
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",

View File

@ -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 extractGtdBlock from "./gtdBlock";
import { resolveEventDate } from "./resolveDate";
@ -44,7 +50,7 @@ export default async function collectEvents(
scannedNotes += 1;
if (note.id === calendarNoteId) continue;
const event = noteToEvent(note, config, warnings);
const event = await noteToEvent(adapter, note, config, warnings);
if (event) events.push(event);
}
}
@ -59,11 +65,12 @@ export default async function collectEvents(
};
}
function noteToEvent(
async function noteToEvent(
adapter: DataAdapter,
note: RawNote,
config: CalendarConfig,
warnings: string[]
): GtdEvent | null {
): Promise<GtdEvent | null> {
const mode = note.is_todo ? config.todos : config.notes;
if (mode === "none") return null;
@ -81,6 +88,13 @@ function noteToEvent(
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 {
id: note.id,
title: block && block.title ? block.title : note.title,
@ -92,6 +106,7 @@ function noteToEvent(
icon: block ? block.icon : null,
text: block ? block.text : null,
updatedTime: note.updated_time,
isRecurring,
};
}

View File

@ -55,6 +55,8 @@ export interface GtdEvent {
icon: string | null;
text: string | null;
updatedTime: number;
/** True when the to-do carries the recurrence index tag (see RECURRING_TAG). */
isRecurring: boolean;
}
export interface RawFolder {
@ -66,4 +68,14 @@ export interface RawFolder {
export interface DataAdapter {
getFolders(): Promise<RawFolder[]>;
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";

View File

@ -86,6 +86,12 @@
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) {
el.innerHTML = "";
@ -188,9 +194,9 @@
const icon = document.createElement("span");
icon.className = "event";
if (first.fgColour) icon.style.color = first.fgColour;
icon.textContent = first.icon
? first.icon
: first.title.slice(0, 2);
icon.textContent =
(first.icon ? first.icon : first.title.slice(0, 2)) +
recurSuffix(first);
if (group.events.length > 1) {
icon.textContent += " +" + (group.events.length - 1);
}
@ -237,7 +243,8 @@
const title = document.createElement("p");
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";
row.appendChild(title);
@ -319,6 +326,7 @@
chip.className = "gtd-grid-chip gtd-calendar-clickable";
chip.textContent =
(event.icon ? event.icon : typeGlyph(event)) +
recurSuffix(event) +
" " +
event.title;
chip.style.backgroundColor =
@ -371,6 +379,7 @@
item.className = "gtd-calendar-clickable";
item.textContent =
(event.icon ? event.icon : typeGlyph(event)) +
recurSuffix(event) +
" " +
event.title;
if (event.bgColour) item.style.backgroundColor = event.bgColour;

View File

@ -34,6 +34,14 @@ const joplinAdapter: DataAdapter = {
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[]> {

View File

@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
"app_min_version": "2.7",
"version": "0.1.0",
"version": "0.2.0",
"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

@ -13,6 +13,7 @@ function makeEvent(overrides: Partial<GtdEvent>): GtdEvent {
icon: null,
text: null,
updatedTime: 0,
isRecurring: false,
...overrides,
};
}

View File

@ -222,11 +222,18 @@ describe("date resolution", () => {
// 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 {
getFolders: async () => folders,
getNotesInFolder: async (folderId: string) =>
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"]);
});
});
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);
});
});