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;
|
||||
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
|
||||
* 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();
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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