diff --git a/SPEC.md b/SPEC.md index 7eca313..96cc66d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -21,7 +21,7 @@ Dateless items are not lost — they collect in an **Unscheduled** section below ``` ```gtd-calendar -view: month # day | week | month (default: day, matching upstream) +view: month # day | week | month | month-grid (default: day) title: Editorial Schedule scope: this-folder # this-folder | children | (default: this-folder) 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 | |---|---|---|---| -| `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. | | `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). | diff --git a/src/Gtd/groupEvents.ts b/src/Gtd/groupEvents.ts index 28c9427..65e7825 100644 --- a/src/Gtd/groupEvents.ts +++ b/src/Gtd/groupEvents.ts @@ -1,5 +1,9 @@ import { addDays, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, addWeeks, addMonths, differenceInCalendarDays, @@ -27,9 +31,29 @@ export interface CalendarGroup { 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 { groups: CalendarGroup[]; unscheduled: GtdEvent[]; + grid: MonthGrid | null; } /** Parse canonical yyyy-mm-dd as a local Date. */ @@ -55,7 +79,9 @@ interface ViewStrategy { label(date: Date): { text: string; primary: boolean }; } -const STRATEGIES: Record = { +type StripView = "day" | "week" | "month"; + +const STRATEGIES: Record = { day: { diff: differenceInCalendarDays, add: addDays, @@ -119,8 +145,16 @@ export default function groupEvents( const scheduled = 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) { - return { groups: [], unscheduled }; + return { groups: [], unscheduled, grid: null }; } const strategy = STRATEGIES[view]; @@ -152,5 +186,62 @@ export default function groupEvents( 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(); + + 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, + }; } diff --git a/src/Gtd/parseCalendarConfig.ts b/src/Gtd/parseCalendarConfig.ts index 8597d84..c5fadce 100644 --- a/src/Gtd/parseCalendarConfig.ts +++ b/src/Gtd/parseCalendarConfig.ts @@ -7,6 +7,8 @@ const VIEW_ALIASES: Record = { w: "week", month: "month", m: "month", + "month-grid": "month-grid", + grid: "month-grid", }; const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index 601be65..ce8b1d3 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -1,6 +1,6 @@ /** Parsed and normalised ```gtd-calendar config. */ export interface CalendarConfig { - view: "day" | "week" | "month"; + view: "day" | "week" | "month" | "month-grid"; title: string | null; /** Folder recursion depth: 0 = this folder only, Infinity = all children. */ scopeDepth: number; diff --git a/src/event-calendar.css b/src/event-calendar.css index 997777d..8cefe65 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -230,3 +230,57 @@ opacity: 0.6; 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; +} diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 81dc81b..df5ec3b 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -113,9 +113,17 @@ 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( renderStrip(calendar.groups, contentScriptId) ); @@ -272,6 +280,76 @@ 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) // ------------------------------------------------------------------ diff --git a/src/tests/Gtd/groupEvents.test.ts b/src/tests/Gtd/groupEvents.test.ts index 241bb19..74d2ab5 100644 --- a/src/tests/Gtd/groupEvents.test.ts +++ b/src/tests/Gtd/groupEvents.test.ts @@ -131,3 +131,63 @@ describe("groupEvents — unscheduled", () => { 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); + }); +});