From 57eae2b7f83f6ec40193a3a6a810dc8552f2ae03 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 12 Jun 2026 22:20:37 +0000 Subject: [PATCH] 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) --- src/Gtd/groupEvents.ts | 156 +++++++++++++++++ src/event-calendar.css | 58 +++++++ src/gtd-calendar-webview.js | 278 ++++++++++++++++++++++++------ src/index.ts | 6 +- src/tests/Gtd/groupEvents.test.ts | 133 ++++++++++++++ 5 files changed, 575 insertions(+), 56 deletions(-) create mode 100644 src/Gtd/groupEvents.ts create mode 100644 src/tests/Gtd/groupEvents.test.ts diff --git a/src/Gtd/groupEvents.ts b/src/Gtd/groupEvents.ts new file mode 100644 index 0000000..28c9427 --- /dev/null +++ b/src/Gtd/groupEvents.ts @@ -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 = { + 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 }; +} diff --git a/src/event-calendar.css b/src/event-calendar.css index 48f5bb7..997777d 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -172,3 +172,61 @@ border-radius: 4px; 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; +} diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 3684803..81dc81b 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -3,11 +3,12 @@ * * Runs inside Joplin's rendered-note webview. Finds gtd-calendar * 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 - * simple debug view proving the full round trip, including click-to-open - * drilldown. + * Drilldown: clicking a single-event tile, or any event row in a hover + * card or the Unscheduled list, opens the source note. */ (function () { "use strict"; @@ -63,82 +64,249 @@ 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) { el.innerHTML = ""; - const box = document.createElement("div"); - box.className = "gtd-calendar-debug"; + const wrapper = document.createElement("div"); + wrapper.className = "gtd-calendar"; - const heading = document.createElement("h3"); - heading.textContent = payload.title || "GTD Calendar"; - box.appendChild(heading); + if (payload.title) { + const heading = document.createElement("h3"); + heading.className = "gtd-calendar-title"; + heading.textContent = payload.title; + wrapper.appendChild(heading); + } - const meta = document.createElement("p"); - meta.className = "gtd-calendar-debug-meta"; - const stats = payload.stats - ? " ยท scanned " + - payload.stats.scannedNotes + - " 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); + if (payload.configError) { + const error = document.createElement("p"); + error.className = "gtd-calendar-error"; + error.textContent = "Config problem: " + payload.configError; + wrapper.appendChild(error); + } (payload.warnings || []).forEach(function (warning) { const warn = document.createElement("p"); warn.className = "gtd-calendar-warning"; warn.textContent = "โš  " + warning; - box.appendChild(warn); + wrapper.appendChild(warn); }); - if (payload.configError) { - const warn = document.createElement("p"); - warn.className = "gtd-calendar-error"; - warn.textContent = "Config problem: " + payload.configError; - box.appendChild(warn); + const calendar = payload.calendar || { groups: [], unscheduled: [] }; + + if (calendar.groups.length > 0) { + wrapper.appendChild( + 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"); - 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 glyph = event.isTodo ? (event.completed ? "โ˜‘ " : "โ˜ ") : "๐Ÿ“„ "; - const dateLabel = event.date ? event.date : "unscheduled"; + item.className = "gtd-calendar-clickable"; item.textContent = - (event.icon ? event.icon + " " : glyph) + - event.title + - " โ€” " + - dateLabel; + (event.icon ? event.icon : typeGlyph(event)) + + " " + + event.title; if (event.bgColour) item.style.backgroundColor = event.bgColour; if (event.fgColour) item.style.color = event.fgColour; if (event.completed) item.style.textDecoration = "line-through"; if (event.text) item.title = event.text; - - if (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, - }); - }); - } - + item.addEventListener("click", function () { + openNote(contentScriptId, event.id); + }); list.appendChild(item); }); - box.appendChild(list); - el.appendChild(box); + section.appendChild(list); + return section; } processPlaceholders(); diff --git a/src/index.ts b/src/index.ts index 1f0a41c..223926a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ const YAML = require("yaml"); import parseCalendarConfig from "./Gtd/parseCalendarConfig"; import collectEvents from "./Gtd/collectEvents"; +import groupEvents from "./Gtd/groupEvents"; import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; @@ -122,16 +123,19 @@ async function handleGetEvents(message: { rawConfig?: string }) { config ); + const calendar = groupEvents(result.events, config.view); + return { title: config.title, view: config.view, configError, warnings: [...config.warnings, ...result.warnings], sourceNote, - events: result.events, + calendar, stats: { scannedFolders: result.scannedFolders, scannedNotes: result.scannedNotes, + eventCount: result.events.length, }, }; } diff --git a/src/tests/Gtd/groupEvents.test.ts b/src/tests/Gtd/groupEvents.test.ts new file mode 100644 index 0000000..241bb19 --- /dev/null +++ b/src/tests/Gtd/groupEvents.test.ts @@ -0,0 +1,133 @@ +import groupEvents from "../../Gtd/groupEvents"; +import { GtdEvent } from "../../Gtd/types"; + +function makeEvent(overrides: Partial): 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"]); + }); +});