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:
parent
fc1b2fcabf
commit
57eae2b7f8
156
src/Gtd/groupEvents.ts
Normal file
156
src/Gtd/groupEvents.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/tests/Gtd/groupEvents.test.ts
Normal file
133
src/tests/Gtd/groupEvents.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user