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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
- **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).
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,11 +194,11 @@
|
||||
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);
|
||||
icon.textContent += " +" + (group.events.length - 1);
|
||||
}
|
||||
tile.appendChild(icon);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -13,6 +13,7 @@ function makeEvent(overrides: Partial<GtdEvent>): GtdEvent {
|
||||
icon: null,
|
||||
text: null,
|
||||
updatedTime: 0,
|
||||
isRecurring: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user