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:
parent
57eae2b7f8
commit
360e246262
4
SPEC.md
4
SPEC.md
@ -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). |
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user