Skip to content

Commit e3e975d

Browse files
feat: add CATEGORIES support
- parse `categories` into a string array on each event object - add `category-{slug}` CSS classes + `data-categories` attribute on event DOM - colour-coded left border + background tint for common category names - document in Event-Object.md and Styling.md; add 3 unit tests closes #86
1 parent f689031 commit e3e975d

8 files changed

Lines changed: 202 additions & 1 deletion

File tree

CALEXT2_Event.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class Event {
123123
eventDom.dataset.title = event.title;
124124
eventDom.dataset.location = event.location;
125125
eventDom.dataset.busystatus = event.ms_busystatus;
126+
Event.applyCategories(event, eventDom, null);
126127

127128
const mainDom = document.createElement("div");
128129
mainDom.classList.add("eventMain");
@@ -150,6 +151,7 @@ class Event {
150151
location.classList.add("eventLocation");
151152
location.innerHTML = event.location;
152153
subDom.appendChild(location);
154+
Event.applyCategories(event, null, subDom);
153155

154156
// Add attendees if present and enabled
155157
if (this.showAttendees && event.attendees && event.attendees.length > 0) {
@@ -178,6 +180,34 @@ class Event {
178180
return eventDom;
179181
}
180182

183+
/**
184+
* Applies category CSS classes and data-attribute to eventDom,
185+
* and appends an eventCategories div to subDom.
186+
* Pass null for targetDom or slotDom to skip that part.
187+
* @param {object} event - The event data object
188+
* @param {HTMLElement|null} targetDom - The event wrapper element
189+
* @param {HTMLElement|null} slotDom - The sub-content element
190+
*/
191+
static applyCategories (event, targetDom, slotDom) {
192+
const cats = Array.isArray(event.categories) ? event.categories : [];
193+
if (targetDom) {
194+
for (const cat of cats) {
195+
if (cat) {
196+
targetDom.classList.add(`category-${cat.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "")}`);
197+
}
198+
}
199+
200+
targetDom.dataset.categories = cats.join(",");
201+
}
202+
203+
if (slotDom && cats.length > 0) {
204+
const categoriesDom = document.createElement("div");
205+
categoriesDom.classList.add("eventCategories");
206+
categoriesDom.innerHTML = cats.join(", ");
207+
slotDom.appendChild(categoriesDom);
208+
}
209+
}
210+
181211
createEventTime () {
182212
const {locale} = this;
183213
const event = this.data;

MMM-CalendarExt2.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,57 @@
489489
color: #000;
490490
text-shadow: 0 0 2px rgb(255 255 255 / 50%);
491491
}
492+
493+
/** CSS for categories **/
494+
.CX2 .eventCategories {
495+
font-size: 0.85em;
496+
margin-top: 4px;
497+
opacity: 0.75;
498+
font-style: italic;
499+
}
500+
501+
/* Category color coding via left border + tint – visible in all view modes */
502+
.CX2 .event[class*="category-"] {
503+
border-left: 5px solid transparent;
504+
}
505+
506+
.CX2 .event.category-work {
507+
border-left-color: #2196f3;
508+
background-color: rgb(33 150 243 / 12%);
509+
}
510+
511+
.CX2 .event.category-leisure {
512+
border-left-color: #ff9800;
513+
background-color: rgb(255 152 0 / 12%);
514+
}
515+
516+
.CX2 .event.category-deadline {
517+
border-left-color: #ff5722;
518+
background-color: rgb(255 87 34 / 12%);
519+
}
520+
521+
.CX2 .event.category-family {
522+
border-left-color: #9c27b0;
523+
background-color: rgb(156 39 176 / 12%);
524+
}
525+
526+
.CX2 .event.category-birthday {
527+
border-left-color: #e91e63;
528+
background-color: rgb(233 30 99 / 12%);
529+
}
530+
531+
.CX2 .event.category-health {
532+
border-left-color: #4caf50;
533+
background-color: rgb(76 175 80 / 12%);
534+
}
535+
536+
.CX2 .event.category-vacation {
537+
border-left-color: #00bcd4;
538+
background-color: rgb(0 188 212 / 12%);
539+
color: white;
540+
}
541+
542+
.CX2 .event.category-important {
543+
border-left-color: #f44336;
544+
background-color: rgb(244 67 54 / 12%);
545+
}

docs/Event-Object.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ startDateJ: "2019-01-04T19:45:00.000Z";
2323
title: "Team T.B.A. - Tottenham Hotspur";
2424
uid: "1:1546631100:1546638300:op54vk5s1r0ivl8i165ampip88@google.com";
2525
ms_busystatus: "BUSY"; // Only for calendar from MS Outlook. Available : "BUSY", "FREE", "TENTATIVE", "OOF"
26+
categories: ["Work", "Important"]; // Array of category strings from CATEGORIES property; empty array if absent
2627
```
2728

2829
You can use these properties for filtering, sorting, or external usage by notification.

docs/Styling.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,34 @@ Each event could have these values as dataset.
104104
- data-duration
105105
- data-title
106106
- data-location
107+
- data-categories (comma-separated list of categories, e.g. `"Work,Important"`)
108+
109+
## category-based styling
110+
111+
Events with a `CATEGORIES` property in the iCal source automatically receive:
112+
113+
- A CSS class `category-{slug}` on the event wrapper (e.g. `category-work`, `category-family`). The slug is the category name lowercased, with non-alphanumeric runs replaced by `-` — so `"Doctor's Appointment"` becomes `category-doctor-s-appointment` and `"Freizeit & Sport"` becomes `category-freizeit-sport`.
114+
- A `data-categories` attribute containing all categories as a comma-separated string.
115+
- A `.eventCategories` div inside the event's `eventSub` area (visible in agenda view).
116+
117+
The module ships built-in colour rules for common English category names (`work`, `health`, `vacation`, `leisure`, `family`, `birthday`, `deadline`, `important`). Add your own in `css/custom.css`:
118+
119+
```css
120+
/* Example – add to css/custom.css */
121+
.CX2 .event.category-sports {
122+
border-left-color: #8bc34a;
123+
background-color: rgb(139 195 74 / 12%);
124+
}
125+
.CX2 .event.category-finance {
126+
border-left-color: #ffc107;
127+
background-color: rgb(255 193 7 / 12%);
128+
}
129+
```
130+
131+
Because categories are free-form text defined by your calendar, the class is derived from whatever your iCal files contain. The general selector below targets any event that carries at least one category:
132+
133+
```css
134+
.CX2 .event[class*="category-"] {
135+
border-left: 5px solid transparent;
136+
}
137+
```

lib/ical-utils.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ const eventProp = (event, name) => {
5050
);
5151
};
5252

53+
const extractCategories = (primary, fallback = {}) => {
54+
if (Array.isArray(primary?.categories)) {
55+
return primary.categories;
56+
}
57+
58+
return Array.isArray(fallback.categories) ? fallback.categories : [];
59+
};
60+
5361
const getAllDayDurationSeconds = (startDate, endDate) => {
5462
const rawDayCount = (endDate.getTime() - startDate.getTime()) / 86400000;
5563
const dayCount = Math.max(1, Math.round(rawDayCount));
@@ -156,6 +164,7 @@ const parseAndExpandEvents = (iCalData, startDate, endDate, maxIterations = 1000
156164
duration: durationSeconds,
157165
status: eventProp(occurrence, "status") || eventProp(occurrenceEvent, "status"),
158166
ms_busystatus: eventProp(occurrence, "x-microsoft-cdo-busystatus") || eventProp(occurrenceEvent, "x-microsoft-cdo-busystatus"),
167+
categories: extractCategories(occurrence, occurrenceEvent),
159168
attendees: extractAttendees(occurrenceEvent)
160169
});
161170
}
@@ -176,6 +185,7 @@ const parseAndExpandEvents = (iCalData, startDate, endDate, maxIterations = 1000
176185
duration: getDurationSeconds(event),
177186
status: eventProp(event, "status"),
178187
ms_busystatus: eventProp(event, "x-microsoft-cdo-busystatus"),
188+
categories: extractCategories(event),
179189
attendees: extractAttendees(event)
180190
});
181191
}

node_helper.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ module.exports = NodeHelper.create({
136136
ev.title = item.summary;
137137
ev.isRecurring = item.isRecurring;
138138
ev.attendees = item.attendees || [];
139+
ev.categories = item.categories || [];
139140
ev.isCancelled = item.status?.toUpperCase() === "CANCELLED";
140141
if (
141142
Array.isArray(calendar.replaceTitle) &&

stylelint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const pattern = "^(MMM-CalendarExt2|CX2|fake_module|module-content|[a-z][a-zA-Z0-9]+(_\\d+)?)$";
1+
const pattern = "^(MMM-CalendarExt2|CX2|fake_module|module-content|[a-z][a-zA-Z0-9-]+(_\\d+)?)$";
22
const config = {
33
"extends": ["stylelint-config-standard", "stylelint-prettier/recommended"],
44
"root": true,

test/ical-utils.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,77 @@ test("exposes status on a recurring occurrence (from parent event)", () => {
366366
assert.equal(occurrence.status, "CANCELLED");
367367
}
368368
});
369+
370+
test("exposes categories as array on a non-recurring event", () => {
371+
const iCalData = joinIcs(
372+
"BEGIN:VCALENDAR",
373+
"VERSION:2.0",
374+
"BEGIN:VEVENT",
375+
"UID:cat-single-1",
376+
"SUMMARY:Zahnarzt",
377+
"CATEGORIES:Health,Important",
378+
"DTSTART:20260301T100000Z",
379+
"DTEND:20260301T110000Z",
380+
"END:VEVENT",
381+
"END:VCALENDAR"
382+
);
383+
384+
const result = parseAndExpandEvents(
385+
iCalData,
386+
new Date("2026-03-01T00:00:00Z"),
387+
new Date("2026-03-02T00:00:00Z")
388+
);
389+
390+
assert.equal(result.length, 1);
391+
assert.deepEqual(result[0].categories, ["Health", "Important"]);
392+
});
393+
394+
test("exposes empty categories array when CATEGORIES is absent", () => {
395+
const iCalData = joinIcs(
396+
"BEGIN:VCALENDAR",
397+
"VERSION:2.0",
398+
"BEGIN:VEVENT",
399+
"UID:cat-absent-1",
400+
"SUMMARY:No Categories",
401+
"DTSTART:20260301T100000Z",
402+
"DTEND:20260301T110000Z",
403+
"END:VEVENT",
404+
"END:VCALENDAR"
405+
);
406+
407+
const result = parseAndExpandEvents(
408+
iCalData,
409+
new Date("2026-03-01T00:00:00Z"),
410+
new Date("2026-03-02T00:00:00Z")
411+
);
412+
413+
assert.equal(result.length, 1);
414+
assert.deepEqual(result[0].categories, []);
415+
});
416+
417+
test("propagates categories from parent event to recurring occurrences", () => {
418+
const iCalData = joinIcs(
419+
"BEGIN:VCALENDAR",
420+
"VERSION:2.0",
421+
"BEGIN:VEVENT",
422+
"UID:cat-rec-1",
423+
"SUMMARY:Weekly Meeting",
424+
"CATEGORIES:Work,Team",
425+
"DTSTART:20260302T090000Z",
426+
"DTEND:20260302T100000Z",
427+
"RRULE:FREQ=WEEKLY;COUNT=3",
428+
"END:VEVENT",
429+
"END:VCALENDAR"
430+
);
431+
432+
const result = parseAndExpandEvents(
433+
iCalData,
434+
new Date("2026-03-01T00:00:00Z"),
435+
new Date("2026-03-31T00:00:00Z")
436+
);
437+
438+
assert.equal(result.length, 3);
439+
for (const occurrence of result) {
440+
assert.deepEqual(occurrence.categories, ["Work", "Team"]);
441+
}
442+
});

0 commit comments

Comments
 (0)