Add view: month-grid — full current month, 7-column layout

- groupEvents: buildMonthGrid covers the entire current calendar month
  padded to full weeks; adjacent-month padding days are flagged (dimmed)
  but can still hold events; events outside the window are counted
- Localised short weekday names as column headers; month/year label
- Webview renders a CSS grid: day cells with date number, today border
  highlight, clickable event chips (colour, icon/glyph, strikethrough,
  ellipsis overflow)
- Config accepts view: month-grid (alias: grid)
- SPEC.md updated; 5 new tests (47 total)
This commit is contained in:
Victor Wiebe 2026-06-12 22:34:13 +00:00 committed by vwiebe
parent 57eae2b7f8
commit 360e246262
7 changed files with 293 additions and 8 deletions

View File

@ -21,7 +21,7 @@ Dateless items are not lost — they collect in an **Unscheduled** section below
``` ```
```gtd-calendar ```gtd-calendar
view: month # day | week | month (default: day, matching upstream) view: month # day | week | month | month-grid (default: day)
title: Editorial Schedule title: Editorial Schedule
scope: this-folder # this-folder | children | <integer depth> (default: this-folder) scope: this-folder # this-folder | children | <integer depth> (default: this-folder)
notes: gtd-only # gtd-only | all | none (default: gtd-only) notes: gtd-only # gtd-only | all | none (default: gtd-only)
@ -32,7 +32,7 @@ sort-type: title # title | modified_date (default: title)
| Key | Values | Default | Notes | | Key | Values | Default | Notes |
|---|---|---|---| |---|---|---|---|
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`, as upstream) | `day` | Grouping granularity of the grid. | | `view` | `day`, `week`, `month` (also `d`/`w`/`m`), `month-grid` (also `grid`) | `day` | Grouping granularity. `day`/`week`/`month` render the compact tile strip spanning all events; `month-grid` renders a classic 7-column grid of the entire *current* calendar month with weekday headers, dimmed adjacent-month days, today highlighted, and clickable event chips in each day cell. Events outside the displayed month are counted in a footnote. |
| `title` | string | none | Rendered as a heading above the calendar. | | `title` | string | none | Rendered as a heading above the calendar. |
| `scope` | `this-folder`, `children`, or an integer | `this-folder` | `children` = unlimited recursion into subfolders. An integer *n* = this folder plus *n* levels of descendants (`0``this-folder`). Documentation will caution that broad scopes scan every note body in the tree. | | `scope` | `this-folder`, `children`, or an integer | `this-folder` | `children` = unlimited recursion into subfolders. An integer *n* = this folder plus *n* levels of descendants (`0``this-folder`). Documentation will caution that broad scopes scan every note body in the tree. |
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes participate. `all` includes notes without a `gtd` block (they receive default styling and are Unscheduled unless... see §3.2). | | `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes participate. `all` includes notes without a `gtd` block (they receive default styling and are Unscheduled unless... see §3.2). |

View File

@ -1,5 +1,9 @@
import { import {
addDays, addDays,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addWeeks, addWeeks,
addMonths, addMonths,
differenceInCalendarDays, differenceInCalendarDays,
@ -27,9 +31,29 @@ export interface CalendarGroup {
events: GtdEvent[]; events: GtdEvent[];
} }
export interface MonthGridCell {
dateISO: string;
dayNumber: number;
/** False for leading/trailing days belonging to adjacent months. */
inMonth: boolean;
isToday: boolean;
events: GtdEvent[];
}
export interface MonthGrid {
/** Localised short weekday names, one per column. */
weekdays: string[];
cells: MonthGridCell[];
/** e.g. "June 2026". */
monthLabel: string;
/** Scheduled events falling outside the displayed window. */
outsideCount: number;
}
export interface GroupedCalendar { export interface GroupedCalendar {
groups: CalendarGroup[]; groups: CalendarGroup[];
unscheduled: GtdEvent[]; unscheduled: GtdEvent[];
grid: MonthGrid | null;
} }
/** Parse canonical yyyy-mm-dd as a local Date. */ /** Parse canonical yyyy-mm-dd as a local Date. */
@ -55,7 +79,9 @@ interface ViewStrategy {
label(date: Date): { text: string; primary: boolean }; label(date: Date): { text: string; primary: boolean };
} }
const STRATEGIES: Record<CalendarConfig["view"], ViewStrategy> = { type StripView = "day" | "week" | "month";
const STRATEGIES: Record<StripView, ViewStrategy> = {
day: { day: {
diff: differenceInCalendarDays, diff: differenceInCalendarDays,
add: addDays, add: addDays,
@ -119,8 +145,16 @@ export default function groupEvents(
const scheduled = events.filter((event) => event.date !== null); const scheduled = events.filter((event) => event.date !== null);
const unscheduled = events.filter((event) => event.date === null); const unscheduled = events.filter((event) => event.date === null);
if (view === "month-grid") {
return {
groups: [],
unscheduled,
grid: buildMonthGrid(scheduled, today),
};
}
if (scheduled.length === 0) { if (scheduled.length === 0) {
return { groups: [], unscheduled }; return { groups: [], unscheduled, grid: null };
} }
const strategy = STRATEGIES[view]; const strategy = STRATEGIES[view];
@ -152,5 +186,62 @@ export default function groupEvents(
groups[index].events.push(event); groups[index].events.push(event);
} }
return { groups, unscheduled }; return { groups, unscheduled, grid: null };
}
/**
* Build a classic 7-column grid covering the entire current calendar
* month (padded to full weeks with dimmed adjacent-month days).
*/
function buildMonthGrid(scheduled: GtdEvent[], today: Date): MonthGrid {
const windowStart = startOfWeek(startOfMonth(today));
const windowEnd = endOfWeek(endOfMonth(today));
const cells: MonthGridCell[] = [];
const cellIndexByISO = new Map<string, number>();
for (
let date = windowStart;
date <= windowEnd;
date = addDays(date, 1)
) {
const iso = localDateToISO(date);
cellIndexByISO.set(iso, cells.length);
cells.push({
dateISO: iso,
dayNumber: date.getDate(),
inMonth: isSameMonth(date, today),
isToday: isSameDay(date, today),
events: [],
});
}
let outsideCount = 0;
for (const event of scheduled) {
const index = cellIndexByISO.get(event.date!);
if (index === undefined) {
outsideCount += 1;
continue;
}
cells[index].events.push(event);
}
const weekdays: string[] = [];
for (let i = 0; i < 7; i += 1) {
weekdays.push(
addDays(windowStart, i).toLocaleDateString(undefined, {
weekday: "short",
})
);
}
return {
weekdays,
cells,
monthLabel: today.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
outsideCount,
};
} }

View File

@ -7,6 +7,8 @@ const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
w: "week", w: "week",
month: "month", month: "month",
m: "month", m: "month",
"month-grid": "month-grid",
grid: "month-grid",
}; };
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];

View File

@ -1,6 +1,6 @@
/** Parsed and normalised ```gtd-calendar config. */ /** Parsed and normalised ```gtd-calendar config. */
export interface CalendarConfig { export interface CalendarConfig {
view: "day" | "week" | "month"; view: "day" | "week" | "month" | "month-grid";
title: string | null; title: string | null;
/** Folder recursion depth: 0 = this folder only, Infinity = all children. */ /** Folder recursion depth: 0 = this folder only, Infinity = all children. */
scopeDepth: number; scopeDepth: number;

View File

@ -230,3 +230,57 @@
opacity: 0.6; opacity: 0.6;
margin: 0.5em 0 0 0; margin: 0.5em 0 0 0;
} }
/* view: month-grid */
.gtd-month-grid-label {
margin: 0 0 0.3em 0;
}
.gtd-month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.gtd-month-grid .gtd-grid-head {
text-align: center;
font-size: 0.8em;
font-weight: bold;
opacity: 0.8;
padding: 0.2em 0;
}
.gtd-month-grid .gtd-grid-cell {
min-height: 4.5em;
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 4px;
padding: 0.15em 0.25em;
overflow: hidden;
}
.gtd-month-grid .gtd-grid-cell.outside {
opacity: 0.45;
}
.gtd-month-grid .gtd-grid-cell.today {
border: 2px solid rgba(255, 200, 0, 0.95);
}
.gtd-month-grid .gtd-grid-day {
display: block;
text-align: right;
font-size: 0.75em;
opacity: 0.7;
}
.gtd-month-grid .gtd-grid-chip {
font-size: 0.72em;
border-radius: 3px;
padding: 0 0.3em;
margin-bottom: 2px;
color: black;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -113,9 +113,17 @@
wrapper.appendChild(warn); wrapper.appendChild(warn);
}); });
const calendar = payload.calendar || { groups: [], unscheduled: [] }; const calendar = payload.calendar || {
groups: [],
unscheduled: [],
grid: null,
};
if (calendar.groups.length > 0) { if (calendar.grid) {
wrapper.appendChild(
renderMonthGrid(calendar.grid, contentScriptId)
);
} else if (calendar.groups.length > 0) {
wrapper.appendChild( wrapper.appendChild(
renderStrip(calendar.groups, contentScriptId) renderStrip(calendar.groups, contentScriptId)
); );
@ -272,6 +280,76 @@
return new Date(parts[0], parts[1] - 1, parts[2]); return new Date(parts[0], parts[1] - 1, parts[2]);
} }
// ------------------------------------------------------------------
// Month grid (view: month-grid)
// ------------------------------------------------------------------
function renderMonthGrid(grid, contentScriptId) {
const section = document.createElement("div");
section.className = "gtd-month-grid-wrapper";
const monthHeading = document.createElement("h4");
monthHeading.className = "gtd-month-grid-label";
monthHeading.textContent = grid.monthLabel;
section.appendChild(monthHeading);
const table = document.createElement("div");
table.className = "gtd-month-grid";
grid.weekdays.forEach(function (weekday) {
const head = document.createElement("div");
head.className = "gtd-grid-head";
head.textContent = weekday;
table.appendChild(head);
});
grid.cells.forEach(function (cell) {
const cellEl = document.createElement("div");
cellEl.className = "gtd-grid-cell";
if (!cell.inMonth) cellEl.className += " outside";
if (cell.isToday) cellEl.className += " today";
const dayNumber = document.createElement("span");
dayNumber.className = "gtd-grid-day";
dayNumber.textContent = cell.dayNumber;
cellEl.appendChild(dayNumber);
cell.events.forEach(function (event) {
const chip = document.createElement("div");
chip.className = "gtd-grid-chip gtd-calendar-clickable";
chip.textContent =
(event.icon ? event.icon : typeGlyph(event)) +
" " +
event.title;
chip.style.backgroundColor =
event.bgColour || colourFromId(event.id);
if (event.fgColour) chip.style.color = event.fgColour;
if (event.completed)
chip.style.textDecoration = "line-through";
chip.title = event.title + (event.text ? " — " + event.text : "");
chip.addEventListener("click", function () {
openNote(contentScriptId, event.id);
});
cellEl.appendChild(chip);
});
table.appendChild(cellEl);
});
section.appendChild(table);
if (grid.outsideCount > 0) {
const note = document.createElement("p");
note.className = "gtd-calendar-debug-meta";
note.textContent =
grid.outsideCount +
" scheduled event(s) outside the displayed month";
section.appendChild(note);
}
return section;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Unscheduled section (GTD bucket) // Unscheduled section (GTD bucket)
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View File

@ -131,3 +131,63 @@ describe("groupEvents — unscheduled", () => {
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]); expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
}); });
}); });
describe("groupEvents — month-grid view", () => {
test("covers the whole current month padded to full weeks", () => {
const result = groupEvents([], "month-grid", TODAY);
const grid = result.grid!;
expect(grid.cells.length % 7).toBe(0);
expect(grid.weekdays).toHaveLength(7);
expect(grid.monthLabel).toContain("2026");
const inMonth = grid.cells.filter((c) => c.inMonth);
expect(inMonth).toHaveLength(30); // June 2026
expect(inMonth[0].dayNumber).toBe(1);
expect(inMonth[29].dayNumber).toBe(30);
});
test("flags today and buckets events into the right cells", () => {
const events = [
makeEvent({ id: "a", date: "2026-06-12" }),
makeEvent({ id: "b", date: "2026-06-12" }),
makeEvent({ id: "c", date: "2026-06-25" }),
];
const grid = groupEvents(events, "month-grid", TODAY).grid!;
const todayCell = grid.cells.find((c) => c.isToday)!;
expect(todayCell.dateISO).toBe("2026-06-12");
expect(todayCell.events.map((e) => e.id)).toEqual(["a", "b"]);
const cell25 = grid.cells.find((c) => c.dateISO === "2026-06-25")!;
expect(cell25.events.map((e) => e.id)).toEqual(["c"]);
});
test("events outside the displayed window are counted, not shown", () => {
const events = [
makeEvent({ id: "in", date: "2026-06-05" }),
makeEvent({ id: "out", date: "2026-09-01" }),
];
const grid = groupEvents(events, "month-grid", TODAY).grid!;
expect(grid.outsideCount).toBe(1);
const allShown = grid.cells.flatMap((c) => c.events.map((e) => e.id));
expect(allShown).toEqual(["in"]);
});
test("adjacent-month padding days can still hold events", () => {
// June 2026 starts on a Monday; the grid window starts Sunday May 31.
const events = [makeEvent({ id: "pad", date: "2026-05-31" })];
const grid = groupEvents(events, "month-grid", TODAY).grid!;
const padCell = grid.cells.find((c) => c.dateISO === "2026-05-31")!;
expect(padCell.inMonth).toBe(false);
expect(padCell.events.map((e) => e.id)).toEqual(["pad"]);
expect(grid.outsideCount).toBe(0);
});
test("unscheduled events still pass through in month-grid view", () => {
const events = [makeEvent({ id: "u" })];
const result = groupEvents(events, "month-grid", TODAY);
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
expect(result.groups).toHaveLength(0);
});
});