Phase 3: calendar grid rendering with hover cards and Unscheduled section

- src/Gtd/groupEvents.ts: render-ready day/week/month grouping mirroring
  upstream semantics (empty groups across the span, month-coloured tiles,
  primary labels on month/year boundaries, current-period flag). Divergence:
  the span always includes today so the highlight is visible.
- Grouping runs in the main process; payload now carries calendar.groups +
  calendar.unscheduled. Webview is a pure DOM builder.
- Webview rebuilds upstream's .event-calendar / .group / .hover-card
  structure so existing CSS applies: tile per period, first event's
  icon/2-chars (+N for multiples), bg/fg colours, hover card listing
  events with type glyphs and strikethrough for completed todos.
- Drilldown: single-event tiles open the note directly; hover-card rows
  and Unscheduled chips are individually clickable.
- Stable pastel from note-id hash replaces upstream's per-render random
  colour for events without bg-colour.
- Unscheduled section: chip list below the grid (the GTD bucket).
- 9 new grouping tests (42 total)
This commit is contained in:
Victor Wiebe 2026-06-12 22:20:37 +00:00 committed by vwiebe
parent fc1b2fcabf
commit 57eae2b7f8
5 changed files with 575 additions and 56 deletions

156
src/Gtd/groupEvents.ts Normal file
View File

@ -0,0 +1,156 @@
import {
addDays,
addWeeks,
addMonths,
differenceInCalendarDays,
differenceInCalendarWeeks,
differenceInCalendarMonths,
getWeek,
isSameDay,
isSameWeek,
isSameMonth,
} from "date-fns";
import { CalendarConfig, GtdEvent } from "./types";
export interface CalendarGroup {
/** Representative (start) date of the group, ISO yyyy-mm-dd. */
dateISO: string;
/** 0-based month of the group date — drives upstream's month-N CSS. */
monthIndex: number;
/** Tile label when the group has no events (day number, week number, month). */
label: string;
/** Bold label (month name on the 1st, year on week 1 / January). */
labelPrimary: boolean;
/** True for today / this week / this month. */
isCurrent: boolean;
events: GtdEvent[];
}
export interface GroupedCalendar {
groups: CalendarGroup[];
unscheduled: GtdEvent[];
}
/** Parse canonical yyyy-mm-dd as a local Date. */
export function isoToLocalDate(iso: string): Date {
const [year, month, day] = iso.split("-").map(Number);
return new Date(year, month - 1, day);
}
function localDateToISO(date: Date): string {
return (
String(date.getFullYear()).padStart(4, "0") +
"-" +
String(date.getMonth() + 1).padStart(2, "0") +
"-" +
String(date.getDate()).padStart(2, "0")
);
}
interface ViewStrategy {
diff(later: Date, earlier: Date): number;
add(date: Date, amount: number): Date;
isCurrent(date: Date, today: Date): boolean;
label(date: Date): { text: string; primary: boolean };
}
const STRATEGIES: Record<CalendarConfig["view"], ViewStrategy> = {
day: {
diff: differenceInCalendarDays,
add: addDays,
isCurrent: (date, today) => isSameDay(date, today),
label: (date) => {
if (date.getDate() === 1) {
return {
text: date
.toLocaleDateString(undefined, { month: "long" })
.slice(0, 3),
primary: true,
};
}
return { text: String(date.getDate()), primary: false };
},
},
week: {
diff: differenceInCalendarWeeks,
add: addWeeks,
isCurrent: (date, today) => isSameWeek(date, today),
label: (date) => {
const week = getWeek(date);
if (week === 1) {
return { text: String(date.getFullYear()), primary: true };
}
return { text: String(week), primary: false };
},
},
month: {
diff: differenceInCalendarMonths,
add: addMonths,
isCurrent: (date, today) => isSameMonth(date, today),
label: (date) => {
if (date.getMonth() === 0) {
return { text: String(date.getFullYear()), primary: true };
}
return {
text: date
.toLocaleDateString(undefined, { month: "long" })
.slice(0, 3),
primary: false,
};
},
},
};
/**
* Group events into render-ready day/week/month buckets, upstream-style:
* one group per period across the whole span, empty groups included.
*
* Divergence from upstream: the span always includes *today*, so the
* highlighted current period is visible even when all events are in the
* past or future. Dateless events come back in `unscheduled`, preserving
* their incoming (already config-sorted) order.
*/
export default function groupEvents(
events: GtdEvent[],
view: CalendarConfig["view"],
today: Date = new Date()
): GroupedCalendar {
const scheduled = events.filter((event) => event.date !== null);
const unscheduled = events.filter((event) => event.date === null);
if (scheduled.length === 0) {
return { groups: [], unscheduled };
}
const strategy = STRATEGIES[view];
const dates = scheduled.map((event) => isoToLocalDate(event.date!));
let start = new Date(Math.min(...dates.map((d) => d.getTime())));
let end = new Date(Math.max(...dates.map((d) => d.getTime())));
if (today < start) start = today;
if (today > end) end = today;
const groupCount = strategy.diff(end, start) + 1;
const groups: CalendarGroup[] = [];
for (let index = 0; index < groupCount; index += 1) {
const groupDate = strategy.add(start, index);
const { text, primary } = strategy.label(groupDate);
groups.push({
dateISO: localDateToISO(groupDate),
monthIndex: groupDate.getMonth(),
label: text,
labelPrimary: primary,
isCurrent: strategy.isCurrent(groupDate, today),
events: [],
});
}
for (const event of scheduled) {
const index = strategy.diff(isoToLocalDate(event.date!), start);
groups[index].events.push(event);
}
return { groups, unscheduled };
}

View File

@ -172,3 +172,61 @@
border-radius: 4px; border-radius: 4px;
padding: 0.1em 0.4em; padding: 0.1em 0.4em;
} }
/* Phase 3: calendar grid + unscheduled section */
.gtd-calendar-title {
margin: 0.2em 0 0.5em 0;
}
.gtd-calendar-empty {
font-style: italic;
opacity: 0.7;
}
.gtd-calendar .gtd-calendar-clickable {
cursor: pointer;
text-decoration: none;
}
.event-calendar .group .hover-card .event.gtd-calendar-clickable:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.gtd-unscheduled {
margin-top: 0.75em;
border-top: 1px dashed currentColor;
padding-top: 0.5em;
}
.gtd-unscheduled-title {
margin: 0 0 0.3em 0;
font-size: 0.95em;
opacity: 0.85;
}
.gtd-unscheduled-list {
list-style: none;
margin: 0;
padding: 0;
}
.gtd-unscheduled-list li {
display: inline-block;
border: 1px solid rgba(20, 20, 20, 0.4);
border-radius: 4px;
padding: 0.1em 0.5em;
margin: 0 0.3em 0.3em 0;
background-color: aliceblue;
color: black;
}
.gtd-unscheduled-list li:hover {
border-color: rgba(20, 20, 20, 1);
}
.gtd-calendar-debug-meta {
font-size: 0.8em;
opacity: 0.6;
margin: 0.5em 0 0 0;
}

View File

@ -3,11 +3,12 @@
* *
* Runs inside Joplin's rendered-note webview. Finds gtd-calendar * Runs inside Joplin's rendered-note webview. Finds gtd-calendar
* placeholders emitted by the markdown-it content script, asks the main * placeholders emitted by the markdown-it content script, asks the main
* plugin process for event data, and renders the result. * plugin process for grouped event data, and builds the calendar DOM
* using upstream Event Calendar's structure and CSS (.event-calendar /
* .group / .hover-card), plus a GTD Unscheduled section.
* *
* Phase 1: the plugin returns a hardcoded payload; this script renders a * Drilldown: clicking a single-event tile, or any event row in a hover
* simple debug view proving the full round trip, including click-to-open * card or the Unscheduled list, opens the source note.
* drilldown.
*/ */
(function () { (function () {
"use strict"; "use strict";
@ -63,82 +64,249 @@
el.appendChild(box); el.appendChild(box);
} }
function openNote(contentScriptId, noteId) {
webviewApi.postMessage(contentScriptId, {
type: "openNote",
noteId: noteId,
});
}
/** Deterministic pastel from the note id, replacing upstream's
* per-render random colour (stable across refreshes). */
function colourFromId(id) {
let hash = 0;
for (let i = 0; i < id.length; i += 1) {
hash = (hash * 31 + id.charCodeAt(i)) % 360;
}
return "hsl(" + hash + ", 55%, 78%)";
}
function typeGlyph(event) {
if (!event.isTodo) return "📄";
return event.completed ? "☑" : "☐";
}
function renderPayload(el, payload, contentScriptId) { function renderPayload(el, payload, contentScriptId) {
el.innerHTML = ""; el.innerHTML = "";
const box = document.createElement("div"); const wrapper = document.createElement("div");
box.className = "gtd-calendar-debug"; wrapper.className = "gtd-calendar";
const heading = document.createElement("h3"); if (payload.title) {
heading.textContent = payload.title || "GTD Calendar"; const heading = document.createElement("h3");
box.appendChild(heading); heading.className = "gtd-calendar-title";
heading.textContent = payload.title;
wrapper.appendChild(heading);
}
const meta = document.createElement("p"); if (payload.configError) {
meta.className = "gtd-calendar-debug-meta"; const error = document.createElement("p");
const stats = payload.stats error.className = "gtd-calendar-error";
? " · scanned " + error.textContent = "Config problem: " + payload.configError;
payload.stats.scannedNotes + wrapper.appendChild(error);
" notes in " + }
payload.stats.scannedFolders +
" folder(s)"
: "";
meta.textContent =
"view: " +
(payload.view || "?") +
stats +
" · " +
(payload.events || []).length +
" event(s) · debug view (Phase 3 brings the calendar grid)";
box.appendChild(meta);
(payload.warnings || []).forEach(function (warning) { (payload.warnings || []).forEach(function (warning) {
const warn = document.createElement("p"); const warn = document.createElement("p");
warn.className = "gtd-calendar-warning"; warn.className = "gtd-calendar-warning";
warn.textContent = "⚠ " + warning; warn.textContent = "⚠ " + warning;
box.appendChild(warn); wrapper.appendChild(warn);
}); });
if (payload.configError) { const calendar = payload.calendar || { groups: [], unscheduled: [] };
const warn = document.createElement("p");
warn.className = "gtd-calendar-error"; if (calendar.groups.length > 0) {
warn.textContent = "Config problem: " + payload.configError; wrapper.appendChild(
box.appendChild(warn); renderStrip(calendar.groups, contentScriptId)
);
} else {
const empty = document.createElement("p");
empty.className = "gtd-calendar-empty";
empty.textContent = "No scheduled events in scope.";
wrapper.appendChild(empty);
} }
if (calendar.unscheduled.length > 0) {
wrapper.appendChild(
renderUnscheduled(calendar.unscheduled, contentScriptId)
);
}
if (payload.stats) {
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
meta.textContent =
payload.stats.eventCount +
" event(s) from " +
payload.stats.scannedNotes +
" note(s) in " +
payload.stats.scannedFolders +
" folder(s) · view: " +
payload.view;
wrapper.appendChild(meta);
}
el.appendChild(wrapper);
}
// ------------------------------------------------------------------
// Tile strip (upstream .event-calendar structure)
// ------------------------------------------------------------------
function renderStrip(groups, contentScriptId) {
const strip = document.createElement("div");
strip.className = "event-calendar";
groups.forEach(function (group) {
strip.appendChild(renderGroup(group, contentScriptId));
});
return strip;
}
function renderGroup(group, contentScriptId) {
const tile = document.createElement("div");
tile.className = "group month-" + group.monthIndex;
if (group.isCurrent) tile.className += " highlight";
if (group.events.length > 0) {
tile.className += " icon";
tile.appendChild(renderHoverCard(group, contentScriptId));
const first = group.events[0];
tile.style.backgroundColor =
first.bgColour || colourFromId(first.id);
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);
if (group.events.length > 1) {
icon.textContent += "+" + (group.events.length - 1);
}
tile.appendChild(icon);
// Single event: the tile itself drills down.
if (group.events.length === 1) {
tile.classList.add("gtd-calendar-clickable");
tile.addEventListener("click", function () {
openNote(contentScriptId, first.id);
});
}
} else {
const label = document.createElement("span");
label.className = "icon";
if (group.labelPrimary) label.className += " primary";
if (group.isCurrent) label.className += " highlighted";
label.textContent = group.label;
tile.appendChild(label);
}
return tile;
}
function renderHoverCard(group, contentScriptId) {
const card = document.createElement("div");
card.className = "hover-card";
const header = document.createElement("p");
header.className = "month-year";
header.textContent = formatCardHeader(group.dateISO);
card.appendChild(header);
group.events.forEach(function (event) {
const row = document.createElement("div");
row.className = "event gtd-calendar-clickable";
const date = document.createElement("p");
date.className = "date";
date.textContent = event.date
? formatEventDate(event.date)
: "";
row.appendChild(date);
const title = document.createElement("p");
title.className = "title";
title.textContent = typeGlyph(event) + " " + event.title;
if (event.completed) title.style.textDecoration = "line-through";
row.appendChild(title);
if (event.text) {
const text = document.createElement("p");
text.className = "text";
text.textContent = event.text;
row.appendChild(text);
}
row.addEventListener("click", function (clickEvent) {
clickEvent.stopPropagation();
openNote(contentScriptId, event.id);
});
card.appendChild(row);
});
return card;
}
function formatCardHeader(dateISO) {
const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
}
function formatEventDate(dateISO) {
const date = isoToLocalDate(dateISO);
return date.toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
});
}
function isoToLocalDate(iso) {
const parts = iso.split("-").map(Number);
return new Date(parts[0], parts[1] - 1, parts[2]);
}
// ------------------------------------------------------------------
// Unscheduled section (GTD bucket)
// ------------------------------------------------------------------
function renderUnscheduled(events, contentScriptId) {
const section = document.createElement("div");
section.className = "gtd-unscheduled";
const heading = document.createElement("h4");
heading.className = "gtd-unscheduled-title";
heading.textContent = "Unscheduled (" + events.length + ")";
section.appendChild(heading);
const list = document.createElement("ul"); const list = document.createElement("ul");
list.className = "gtd-calendar-debug-events"; list.className = "gtd-unscheduled-list";
(payload.events || []).forEach(function (event) { events.forEach(function (event) {
const item = document.createElement("li"); const item = document.createElement("li");
item.className = "gtd-calendar-clickable";
const glyph = event.isTodo ? (event.completed ? "☑ " : "☐ ") : "📄 ";
const dateLabel = event.date ? event.date : "unscheduled";
item.textContent = item.textContent =
(event.icon ? event.icon + " " : glyph) + (event.icon ? event.icon : typeGlyph(event)) +
event.title + " " +
" — " + event.title;
dateLabel;
if (event.bgColour) item.style.backgroundColor = event.bgColour; if (event.bgColour) item.style.backgroundColor = event.bgColour;
if (event.fgColour) item.style.color = event.fgColour; if (event.fgColour) item.style.color = event.fgColour;
if (event.completed) item.style.textDecoration = "line-through"; if (event.completed) item.style.textDecoration = "line-through";
if (event.text) item.title = event.text; if (event.text) item.title = event.text;
item.addEventListener("click", function () {
if (event.id) { openNote(contentScriptId, event.id);
item.classList.add("gtd-calendar-clickable"); });
item.title = "Click to open this note";
item.addEventListener("click", function () {
webviewApi.postMessage(contentScriptId, {
type: "openNote",
noteId: event.id,
});
});
}
list.appendChild(item); list.appendChild(item);
}); });
box.appendChild(list); section.appendChild(list);
el.appendChild(box); return section;
} }
processPlaceholders(); processPlaceholders();

View File

@ -5,6 +5,7 @@ const YAML = require("yaml");
import parseCalendarConfig from "./Gtd/parseCalendarConfig"; import parseCalendarConfig from "./Gtd/parseCalendarConfig";
import collectEvents from "./Gtd/collectEvents"; import collectEvents from "./Gtd/collectEvents";
import groupEvents from "./Gtd/groupEvents";
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
@ -122,16 +123,19 @@ async function handleGetEvents(message: { rawConfig?: string }) {
config config
); );
const calendar = groupEvents(result.events, config.view);
return { return {
title: config.title, title: config.title,
view: config.view, view: config.view,
configError, configError,
warnings: [...config.warnings, ...result.warnings], warnings: [...config.warnings, ...result.warnings],
sourceNote, sourceNote,
events: result.events, calendar,
stats: { stats: {
scannedFolders: result.scannedFolders, scannedFolders: result.scannedFolders,
scannedNotes: result.scannedNotes, scannedNotes: result.scannedNotes,
eventCount: result.events.length,
}, },
}; };
} }

View File

@ -0,0 +1,133 @@
import groupEvents from "../../Gtd/groupEvents";
import { GtdEvent } from "../../Gtd/types";
function makeEvent(overrides: Partial<GtdEvent>): GtdEvent {
return {
id: "id",
title: "Event",
date: null,
isTodo: false,
completed: false,
bgColour: null,
fgColour: null,
icon: null,
text: null,
updatedTime: 0,
...overrides,
};
}
const TODAY = new Date(2026, 5, 12); // Jun 12 2026
describe("groupEvents — day view", () => {
test("one group per calendar day across the span, empty groups included", () => {
const events = [
makeEvent({ id: "a", date: "2026-06-10" }),
makeEvent({ id: "b", date: "2026-06-14" }),
];
const result = groupEvents(events, "day", TODAY);
// Jun 10 .. Jun 14 (today Jun 12 is inside the span)
expect(result.groups).toHaveLength(5);
expect(result.groups[0].events.map((e) => e.id)).toEqual(["a"]);
expect(result.groups[1].events).toHaveLength(0);
expect(result.groups[4].events.map((e) => e.id)).toEqual(["b"]);
});
test("span always includes today", () => {
const events = [makeEvent({ id: "past", date: "2026-06-08" })];
const result = groupEvents(events, "day", TODAY);
// Jun 8 .. Jun 12
expect(result.groups).toHaveLength(5);
expect(result.groups[4].isCurrent).toBe(true);
expect(result.groups[4].dateISO).toBe("2026-06-12");
});
test("first-of-month label is the month name and primary", () => {
const events = [
makeEvent({ id: "a", date: "2026-06-30" }),
makeEvent({ id: "b", date: "2026-07-02" }),
];
const result = groupEvents(events, "day", new Date(2026, 6, 1));
const firstOfJuly = result.groups.find(
(g) => g.dateISO === "2026-07-01"
)!;
expect(firstOfJuly.labelPrimary).toBe(true);
expect(firstOfJuly.label.toLowerCase()).toContain("jul");
expect(firstOfJuly.monthIndex).toBe(6);
});
test("multiple events on one day share a group, order preserved", () => {
const events = [
makeEvent({ id: "x", date: "2026-06-12" }),
makeEvent({ id: "y", date: "2026-06-12" }),
];
const result = groupEvents(events, "day", TODAY);
expect(result.groups).toHaveLength(1);
expect(result.groups[0].events.map((e) => e.id)).toEqual(["x", "y"]);
});
});
describe("groupEvents — week and month views", () => {
test("week view groups by calendar week with current-week flag", () => {
const events = [
makeEvent({ id: "a", date: "2026-06-01" }),
makeEvent({ id: "b", date: "2026-06-19" }),
];
const result = groupEvents(events, "week", TODAY);
// Weeks of May 31, Jun 7, Jun 14 → 3 groups
expect(result.groups).toHaveLength(3);
expect(result.groups[1].isCurrent).toBe(true); // week containing Jun 12
expect(result.groups[0].events.map((e) => e.id)).toEqual(["a"]);
expect(result.groups[2].events.map((e) => e.id)).toEqual(["b"]);
});
test("month view groups by calendar month", () => {
const events = [
makeEvent({ id: "a", date: "2026-04-20" }),
makeEvent({ id: "b", date: "2026-08-03" }),
];
const result = groupEvents(events, "month", TODAY);
// Apr, May, Jun, Jul, Aug
expect(result.groups).toHaveLength(5);
expect(result.groups[2].isCurrent).toBe(true); // June
expect(result.groups[0].events.map((e) => e.id)).toEqual(["a"]);
expect(result.groups[4].events.map((e) => e.id)).toEqual(["b"]);
});
test("january month label is the year and primary", () => {
const events = [
makeEvent({ id: "a", date: "2025-12-15" }),
makeEvent({ id: "b", date: "2026-01-10" }),
];
const result = groupEvents(events, "month", new Date(2026, 0, 5));
const january = result.groups.find((g) => g.monthIndex === 0)!;
expect(january.label).toBe("2026");
expect(january.labelPrimary).toBe(true);
});
});
describe("groupEvents — unscheduled", () => {
test("dateless events pass through in order; no groups without scheduled events", () => {
const events = [
makeEvent({ id: "u1" }),
makeEvent({ id: "u2" }),
];
const result = groupEvents(events, "day", TODAY);
expect(result.groups).toHaveLength(0);
expect(result.unscheduled.map((e) => e.id)).toEqual(["u1", "u2"]);
});
test("mixed: scheduled grouped, dateless set aside", () => {
const events = [
makeEvent({ id: "s", date: "2026-06-12" }),
makeEvent({ id: "u" }),
];
const result = groupEvents(events, "day", TODAY);
expect(result.groups[0].events.map((e) => e.id)).toEqual(["s"]);
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
});
});