Update layout

This commit is contained in:
2026-07-17 20:23:52 +02:00
parent 74284ada97
commit b0e873c4c7
8 changed files with 307 additions and 34 deletions
+2
View File
@@ -0,0 +1,2 @@
PORT=3000
CALURL=https://calendar.google.com/calendar/ical/xxxxx/basic.ics
+1 -1
View File
@@ -66,7 +66,7 @@ one runtime dependency (`node-ical`).
- **`tags.json`** — user-editable, not code. Maps an emoji to - **`tags.json`** — user-editable, not code. Maps an emoji to
`{ label, color }`. `color` must be one of the names in `TAG_COLOR_VAR` in `{ label, color }`. `color` must be one of the names in `TAG_COLOR_VAR` in
`index.html` (`blue`, `aqua`, `yellow`, `green`, `violet`, `red`, `index.html` (`blue`, `aqua`, `yellow`, `green`, `violet`, `red`,
`magenta`, `orange`, `gray`, `purple`, `lightblue`) — an unrecognized `magenta`, `orange`, `gray`, `purple`, `lightblue`, `white`) — an unrecognized
color name silently falls back to blue rather than erroring, so a typo color name silently falls back to blue rather than erroring, so a typo
here won't crash anything but will look wrong. here won't crash anything but will look wrong.
- **`.env`** (gitignored) — optional `CALURL=<ics url>`, used only to - **`.env`** (gitignored) — optional `CALURL=<ics url>`, used only to
+57
View File
@@ -0,0 +1,57 @@
# CalendarOverview
Paste a Google Calendar ICS URL, see a two-year (current + next) overview.
Each day is split into three segments — morning (00:0012:00), afternoon
(12:0018:00), evening (18:0024:00) — that light up based on your events.
## Running it
```
npm install
npm start # http://localhost:3000
./start.sh # same, on port 4000
```
Optionally set `CALURL=<your ics url>` in a `.env` file (copy
`.env.example`) to have the URL prefilled on load. It's only used to prefill
the input server-side and is never logged.
## Tagging events with emoji
Start an event title with an emoji to color its segment. Emoji-to-color
mappings live in `tags.json`:
```json
{ "💜": { "label": "Tessa", "color": "magenta" } }
```
- One leading emoji not in `tags.json` still lights up (in a fallback
color) so it stands out from plain events.
- Two leading emoji (with or without a space between them, e.g. `✈️💜` or
`✈️ 💜`) split the segment into both colors.
- Events without a leading emoji still light their segment, just muted/grey.
- All-day events light all three segments for every day they span.
## Conflicts
If two tagged events overlap in time on the same day, that day is flagged
as a conflict. Add the standalone word `nc` anywhere in the title (e.g.
"Videobellen Caroline & Bas nc") to mark that event as intentionally
overlapping — it's excluded from conflict detection but still shows and
still gets colored normally.
## Timezones
All event times are displayed in Amsterdam local time, regardless of the
timezone the source event was created in (UTC, another IANA zone, etc.).
Events with no timezone info at all (a "floating" time per the ICS spec)
are shown as-is, unconverted.
## Tests
```
npm test
```
Plain `assert`-based script (`test_days.js`) covering recurrence expansion,
segment lighting, emoji tagging, and conflict detection.
+24 -9
View File
@@ -36,9 +36,11 @@ function addDays(dateStr, n) {
function localParts(date, tz) { function localParts(date, tz) {
// ponytail: no TZID means "floating" time per RFC 5545 - interpret in the // ponytail: no TZID means "floating" time per RFC 5545 - interpret in the
// system's local zone (matches how node-ical encodes floating DATE values), // system's local zone (matches how node-ical encodes floating DATE values),
// not UTC. // not UTC. A real tz (incl. "Etc/UTC" for Z-suffixed timestamps) is an
// absolute instant - always display it in Amsterdam, not whatever zone
// the source event happened to carry.
const fmt = new Intl.DateTimeFormat('en-CA', { const fmt = new Intl.DateTimeFormat('en-CA', {
...(tz ? { timeZone: tz } : {}), ...(tz ? { timeZone: 'Europe/Amsterdam' } : {}),
year: 'numeric', month: '2-digit', day: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false, hour: '2-digit', minute: '2-digit', hour12: false,
}); });
@@ -58,9 +60,9 @@ function formatHour(hour) {
} }
function segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay) { function segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay) {
if (isFullDay) return [0, 1, 2]; const dayStartHour = isFullDay ? 0 : (dayStr === startLocal.dateStr ? startLocal.hour : 0);
const dayStartHour = dayStr === startLocal.dateStr ? startLocal.hour : 0; const dayEndHour = isFullDay ? 24 : (dayStr === lastDayStr ? endLocal.hour : 24);
const dayEndHour = dayStr === lastDayStr ? endLocal.hour : 24; if (isFullDay) return { lit: [0, 1, 2], start: dayStartHour, end: dayEndHour };
const lit = []; const lit = [];
SEGMENT_RANGES.forEach(([segStart, segEnd], i) => { SEGMENT_RANGES.forEach(([segStart, segEnd], i) => {
if (dayStartHour < segEnd && dayEndHour > segStart) lit.push(i); if (dayStartHour < segEnd && dayEndHour > segStart) lit.push(i);
@@ -69,7 +71,17 @@ function segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay) {
const idx = SEGMENT_RANGES.findIndex(([, segEnd]) => dayStartHour < segEnd); const idx = SEGMENT_RANGES.findIndex(([, segEnd]) => dayStartHour < segEnd);
lit.push(idx === -1 ? 2 : idx); lit.push(idx === -1 ? 2 : idx);
} }
return lit; return { lit, start: dayStartHour, end: dayEndHour };
}
// two tagged events sharing a day overlap if their local-hour spans intersect
function hasOverlap(intervals) {
for (let i = 0; i < intervals.length; i++) {
for (let j = i + 1; j < intervals.length; j++) {
if (intervals[i].start < intervals[j].end && intervals[j].start < intervals[i].end) return true;
}
}
return false;
} }
function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) { function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
@@ -79,7 +91,7 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
const days = {}; const days = {};
const getDay = (dateStr) => { const getDay = (dateStr) => {
if (!days[dateStr]) days[dateStr] = { segments: [null, null, null], events: [] }; if (!days[dateStr]) days[dateStr] = { segments: [null, null, null], events: [], taggedIntervals: [] };
return days[dateStr]; return days[dateStr];
}; };
@@ -99,6 +111,8 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
const summary = inst.summary || '(untitled)'; const summary = inst.summary || '(untitled)';
const colors = leadingEmojis(summary).map((e) => (tagsConfig[e] ? tagsConfig[e].color : UNKNOWN_EMOJI_COLOR)); const colors = leadingEmojis(summary).map((e) => (tagsConfig[e] ? tagsConfig[e].color : UNKNOWN_EMOJI_COLOR));
const color = colors.length === 2 ? colors : colors[0] || null; const color = colors.length === 2 ? colors : colors[0] || null;
// "nc" as a standalone word anywhere in the title marks a conflict as resolved
const noConflict = /\bnc\b/i.test(summary);
const startLocal = localParts(inst.start, inst.start.tz); const startLocal = localParts(inst.start, inst.start.tz);
const endInstant = isFullDay const endInstant = isFullDay
@@ -111,11 +125,12 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
while (true) { while (true) {
if (dayStr >= rangeStartStr && dayStr <= rangeEndStr) { if (dayStr >= rangeStartStr && dayStr <= rangeEndStr) {
const day = getDay(dayStr); const day = getDay(dayStr);
const lit = segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay); const { lit, start, end } = segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay);
for (const segIdx of lit) { for (const segIdx of lit) {
if (color) day.segments[segIdx] = color; if (color) day.segments[segIdx] = color;
else if (!day.segments[segIdx]) day.segments[segIdx] = 'muted'; else if (!day.segments[segIdx]) day.segments[segIdx] = 'muted';
} }
if (color && !noConflict) day.taggedIntervals.push({ start, end });
day.events.push({ day.events.push({
hour: dayStr === startLocal.dateStr ? startLocal.hour : 0, hour: dayStr === startLocal.dateStr ? startLocal.hour : 0,
isFullDay, isFullDay,
@@ -133,7 +148,7 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
const lines = day.events const lines = day.events
.sort((a, b) => a.hour - b.hour) .sort((a, b) => a.hour - b.hour)
.map((e) => `${e.isFullDay ? 'All day' : formatHour(e.hour)} ${e.text}`); .map((e) => `${e.isFullDay ? 'All day' : formatHour(e.hour)} ${e.text}`);
result[dateStr] = { segments: day.segments, tooltip: lines.join('\n') }; result[dateStr] = { segments: day.segments, tooltip: lines.join('\n'), conflict: hasOverlap(day.taggedIntervals) };
} }
return result; return result;
} }
+1
View File
@@ -4,6 +4,7 @@
"description": "", "description": "",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"prestart": "test -d node_modules || npm install",
"start": "node --env-file-if-exists=.env server.js", "start": "node --env-file-if-exists=.env server.js",
"test": "node test_days.js" "test": "node test_days.js"
}, },
+165 -17
View File
@@ -22,6 +22,7 @@
--orange: #eb6834; --orange: #eb6834;
--gray: #6e6c66; --gray: #6e6c66;
--lightblue: #3fa9dc; --lightblue: #3fa9dc;
--white: #ffffff;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
@@ -96,14 +97,6 @@
color: var(--ink-2); color: var(--ink-2);
font-size: 12px; font-size: 12px;
} }
.legend .swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 5px;
vertical-align: middle;
}
.legend .item { display: inline-flex; align-items: center; } .legend .item { display: inline-flex; align-items: center; }
.year-grid { .year-grid {
@@ -117,10 +110,16 @@
border-radius: 8px; border-radius: 8px;
padding: 8px; padding: 8px;
} }
.month h3 { .month-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.month-head h3 {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
margin: 0 0 6px; margin: 0;
color: var(--ink-2); color: var(--ink-2);
} }
.weekdays, .days { .weekdays, .days {
@@ -143,6 +142,7 @@
border-radius: 3px; border-radius: 3px;
} }
.day.today { outline: 1.5px solid var(--today); outline-offset: -1.5px; } .day.today { outline: 1.5px solid var(--today); outline-offset: -1.5px; }
.day.conflict { box-shadow: inset 0 0 0 1.5px var(--red); }
.day .num { font-size: 8px; color: var(--ink-muted); line-height: 1; } .day .num { font-size: 8px; color: var(--ink-muted); line-height: 1; }
.day.blank .num { visibility: hidden; } .day.blank .num { visibility: hidden; }
.segs { display: flex; gap: 1px; } .segs { display: flex; gap: 1px; }
@@ -153,6 +153,54 @@
background: transparent; background: transparent;
} }
.seg.muted { background: var(--ink-muted); opacity: 0.35; } .seg.muted { background: var(--ink-muted); opacity: 0.35; }
.month.past { position: relative; }
.month.past::after {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
pointer-events: none;
}
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 5px;
vertical-align: middle;
}
.cat-block { display: flex; align-items: center; gap: 10px; }
.pie { border-radius: 50%; flex: none; }
.pie-empty { background: var(--gridline); }
.cat-list { list-style: none; margin: 0; padding: 0; font-size: 12px; color: var(--ink-2); }
.cat-list li { display: flex; align-items: center; margin-bottom: 2px; }
.cat-empty { font-size: 12px; color: var(--ink-muted); }
.pie-trigger { position: relative; }
.pie-trigger > .pie { border: 1px solid var(--border); }
.pie-popover {
display: none;
position: absolute;
top: 18px;
right: 0;
z-index: 10;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.pie-trigger:hover .pie-popover { display: block; }
.pie-popover .cat-block { gap: 16px; }
.pie-popover .cat-list { font-size: 14px; white-space: nowrap; }
.pie-popover .swatch { width: 12px; height: 12px; }
.year-totals { display: flex; gap: 28px; flex-wrap: wrap; }
.year-total h4 { font-size: 12px; margin: 0 0 6px; color: var(--ink-2); font-weight: 600; }
</style> </style>
<h1>Year Overview</h1> <h1>Year Overview</h1>
@@ -163,6 +211,7 @@
<div id="status"></div> <div id="status"></div>
<div id="legend" class="legend"></div> <div id="legend" class="legend"></div>
<div id="years"></div> <div id="years"></div>
<div id="report"></div>
<script> <script>
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -170,13 +219,14 @@
const TAG_COLOR_VAR = { const TAG_COLOR_VAR = {
blue: '--blue', aqua: '--aqua', yellow: '--yellow', green: '--green', blue: '--blue', aqua: '--aqua', yellow: '--yellow', green: '--green',
violet: '--violet', red: '--red', magenta: '--magenta', orange: '--orange', violet: '--violet', red: '--red', magenta: '--magenta', orange: '--orange',
gray: '--gray', purple: '--violet', lightblue: '--lightblue', gray: '--gray', purple: '--violet', lightblue: '--lightblue', free: '--gridline', white: '--white',
}; };
const urlInput = document.getElementById('url'); const urlInput = document.getElementById('url');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const yearsEl = document.getElementById('years'); const yearsEl = document.getElementById('years');
const legendEl = document.getElementById('legend'); const legendEl = document.getElementById('legend');
const reportEl = document.getElementById('report');
urlInput.value = localStorage.getItem('icsUrl') || ''; urlInput.value = localStorage.getItem('icsUrl') || '';
@@ -194,7 +244,7 @@
legendEl.innerHTML = items.join(''); legendEl.innerHTML = items.join('');
} }
function renderMonth(year, month, days, today) { function renderMonth(year, month, days, today, monthCounts, colorLabels) {
const first = new Date(year, month, 1); const first = new Date(year, month, 1);
const startWeekday = (first.getDay() + 6) % 7; // getDay() is Sunday-first; shift to Monday-first const startWeekday = (first.getDay() + 6) % 7; // getDay() is Sunday-first; shift to Monday-first
const numDays = new Date(year, month + 1, 0).getDate(); const numDays = new Date(year, month + 1, 0).getDate();
@@ -215,25 +265,115 @@
} }
return `<span class="seg" style="background:var(${TAG_COLOR_VAR[s] || '--blue'})"></span>`; return `<span class="seg" style="background:var(${TAG_COLOR_VAR[s] || '--blue'})"></span>`;
}).join(''); }).join('');
cells += `<div class="day${dateStr === today ? ' today' : ''}" title="${title}"> const cls = `day${dateStr === today ? ' today' : ''}${info && info.conflict && dateStr >= today ? ' conflict' : ''}`;
cells += `<div class="${cls}" title="${title}">
<span class="num">${d}</span> <span class="num">${d}</span>
<span class="segs">${segHtml}</span> <span class="segs">${segHtml}</span>
</div>`; </div>`;
} }
return `<div class="month"> const isPast = `${year}-${String(month + 1).padStart(2, '0')}` < today.slice(0, 7);
const entries = sortedEntries(monthCounts, colorLabels);
const iconHtml = buildPieHtml(entries, colorLabels, 14);
const popoverHtml = renderCategoryBlock(monthCounts, colorLabels, 130);
return `<div class="month${isPast ? ' past' : ''}">
<div class="month-head">
<h3>${MONTH_NAMES[month]}</h3> <h3>${MONTH_NAMES[month]}</h3>
<div class="pie-trigger">
${iconHtml}
<div class="pie-popover">${popoverHtml}</div>
</div>
</div>
<div class="weekdays">${WEEKDAY_NAMES.map((w) => `<div>${w}</div>`).join('')}</div> <div class="weekdays">${WEEKDAY_NAMES.map((w) => `<div>${w}</div>`).join('')}</div>
<div class="days">${cells}</div> <div class="days">${cells}</div>
</div>`; </div>`;
} }
function renderYear(year, days, today) { function renderYear(year, days, today, monthTotals, colorLabels) {
let months = ''; let months = '';
for (let m = 0; m < 12; m++) months += renderMonth(year, m, days, today); for (let m = 0; m < 12; m++) months += renderMonth(year, m, days, today, monthTotals[m], colorLabels);
return `<h2>${year}</h2><div class="year-grid">${months}</div>`; return `<h2>${year}</h2><div class="year-grid">${months}</div>`;
} }
function buildColorLabels(tagsConfig) {
const map = { free: 'Free' };
for (const t of Object.values(tagsConfig)) map[t.color] = t.label;
return map;
}
// a color string adds 1 block; a split segment (2-color array) counts only the 2nd emoji;
// an empty segment adds 1 to 'free'
function yearCategoryTotals(year, days) {
const total = {};
const months = Array.from({ length: 12 }, () => ({}));
const add = (bucket, color, amt) => { bucket[color] = (bucket[color] || 0) + amt; };
for (let m = 0; m < 12; m++) {
const numDays = new Date(year, m + 1, 0).getDate();
for (let d = 1; d <= numDays; d++) {
const dateStr = `${year}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const segments = days[dateStr] ? days[dateStr].segments : [null, null, null];
for (const seg of segments) {
if (seg === 'muted') continue;
const color = seg ? (Array.isArray(seg) ? seg[1] : seg) : 'free';
add(total, color, 1); add(months[m], color, 1);
}
}
}
return { total, months };
}
// entries pre-sorted by tag name, shared by pie wedges, tooltip and list so all three line up
function conicGradient(entries) {
const total = entries.reduce((s, [, n]) => s + n, 0);
if (total === 0) return null;
let acc = 0;
const stops = entries.map(([color, n]) => {
const from = (acc / total) * 360;
acc += n;
return `var(${TAG_COLOR_VAR[color] || '--blue'}) ${from}deg ${(acc / total) * 360}deg`;
});
return `conic-gradient(${stops.join(', ')})`;
}
function pieTitle(entries, colorLabels) {
return entries.map(([color, n]) => `${colorLabels[color] || color}: ${n}`).join('\n');
}
// pin Free last and Work second-last, everything else alphabetical by label
const LABEL_SORT_RANK = { Work: 1, Free: 2 };
function labelSortKey(label) { return [LABEL_SORT_RANK[label] || 0, label]; }
function sortedEntries(counts, colorLabels) {
return Object.entries(counts).filter(([, n]) => n > 0)
.sort((a, b) => {
const [ra, la] = labelSortKey(colorLabels[a[0]] || a[0]);
const [rb, lb] = labelSortKey(colorLabels[b[0]] || b[0]);
return ra - rb || la.localeCompare(lb);
});
}
function buildPieHtml(entries, colorLabels, size) {
const bg = conicGradient(entries);
return bg
? `<div class="pie" style="width:${size}px;height:${size}px;background:${bg}" title="${pieTitle(entries, colorLabels).replace(/"/g, '&quot;')}"></div>`
: `<div class="pie pie-empty" style="width:${size}px;height:${size}px"></div>`;
}
function renderCategoryBlock(counts, colorLabels, size) {
const entries = sortedEntries(counts, colorLabels);
const pieHtml = buildPieHtml(entries, colorLabels, size);
const listHtml = entries.length
? `<ul class="cat-list">${entries.map(([c, n]) =>
`<li><span class="swatch" style="background:var(${TAG_COLOR_VAR[c] || '--blue'})"></span>${colorLabels[c] || c}: ${n}</li>`
).join('')}</ul>`
: `<div class="cat-empty">No events</div>`;
return `<div class="cat-block">${pieHtml}${listHtml}</div>`;
}
function renderYearTotal(year, total, colorLabels) {
return `<div class="year-total"><h4>${year}</h4>${renderCategoryBlock(total, colorLabels, 90)}</div>`;
}
async function load() { async function load() {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) return; if (!url) return;
@@ -242,6 +382,7 @@
statusEl.className = ''; statusEl.className = '';
statusEl.textContent = 'Loading…'; statusEl.textContent = 'Loading…';
yearsEl.innerHTML = ''; yearsEl.innerHTML = '';
reportEl.innerHTML = '';
try { try {
const [overviewRes, tags] = await Promise.all([ const [overviewRes, tags] = await Promise.all([
@@ -254,11 +395,18 @@
const today = todayStr(); const today = todayStr();
const startYear = Number(overviewRes.rangeStart.slice(0, 4)); const startYear = Number(overviewRes.rangeStart.slice(0, 4));
const endYear = Number(overviewRes.rangeEnd.slice(0, 4)); const endYear = Number(overviewRes.rangeEnd.slice(0, 4));
const colorLabels = buildColorLabels(tags);
let html = ''; let html = '';
let totalsHtml = '';
for (let y = startYear; y <= endYear; y++) { for (let y = startYear; y <= endYear; y++) {
html += renderYear(y, overviewRes.days, today); const { total, months } = yearCategoryTotals(y, overviewRes.days);
html += renderYear(y, overviewRes.days, today, months, colorLabels);
totalsHtml += renderYearTotal(y, total, colorLabels);
} }
yearsEl.innerHTML = html; yearsEl.innerHTML = html;
reportEl.innerHTML = `<div class="year-totals">${totalsHtml}</div>`;
statusEl.textContent = ''; statusEl.textContent = '';
} catch (err) { } catch (err) {
statusEl.className = 'error'; statusEl.className = 'error';
+2 -1
View File
@@ -5,5 +5,6 @@
"💼": { "label": "Work", "color": "gray" }, "💼": { "label": "Work", "color": "gray" },
"🦊": { "label": "Abunai!", "color": "orange" }, "🦊": { "label": "Abunai!", "color": "orange" },
"⛏️": { "label": "Xam", "color": "red" }, "⛏️": { "label": "Xam", "color": "red" },
"🎲": { "label": "DND", "color": "yellow" } "🎲": { "label": "DND", "color": "yellow" },
"💬": { "label": "Social", "color": "white" }
} }
+54 -5
View File
@@ -57,6 +57,41 @@ DTSTART;VALUE=DATE:20260122
DTEND;VALUE=DATE:20260123 DTEND;VALUE=DATE:20260123
SUMMARY:✈️ 💜 Madeira SUMMARY:✈️ 💜 Madeira
END:VEVENT END:VEVENT
BEGIN:VEVENT
UID:overlap-a@test
DTSTAMP:20260101T000000Z
DTSTART;TZID=UTC:20260109T090000
DTEND;TZID=UTC:20260109T100000
SUMMARY:✈️ Overlap A
END:VEVENT
BEGIN:VEVENT
UID:overlap-b@test
DTSTAMP:20260101T000000Z
DTSTART;TZID=UTC:20260109T093000
DTEND;TZID=UTC:20260109T110000
SUMMARY:💜 Overlap B
END:VEVENT
BEGIN:VEVENT
UID:overlap-resolved-a@test
DTSTAMP:20260101T000000Z
DTSTART;TZID=UTC:20260113T090000
DTEND;TZID=UTC:20260113T100000
SUMMARY:✈️ Overlap C nc
END:VEVENT
BEGIN:VEVENT
UID:quickadd-1@test
DTSTAMP:20260101T000000Z
DTSTART:20260116T070000Z
DTEND:20260116T080000Z
SUMMARY:Quick add
END:VEVENT
BEGIN:VEVENT
UID:overlap-resolved-b@test
DTSTAMP:20260101T000000Z
DTSTART;TZID=UTC:20260113T093000
DTEND;TZID=UTC:20260113T110000
SUMMARY:💜 Overlap D
END:VEVENT
END:VCALENDAR END:VCALENDAR
`; `;
@@ -69,7 +104,7 @@ const overview = buildOverview(ICS, tagsConfig, '2026-01-01', '2026-01-31');
// plain weekly recurrence: morning segment, untagged -> muted // plain weekly recurrence: morning segment, untagged -> muted
assert.deepEqual(overview['2026-01-05'].segments, ['muted', null, null]); assert.deepEqual(overview['2026-01-05'].segments, ['muted', null, null]);
assert.match(overview['2026-01-05'].tooltip, /09:00 Standup/); assert.match(overview['2026-01-05'].tooltip, /10:00 Standup/);
// EXDATE removes this occurrence entirely (nothing else touches this day) // EXDATE removes this occurrence entirely (nothing else touches this day)
assert.equal(overview['2026-01-19'], undefined); assert.equal(overview['2026-01-19'], undefined);
@@ -79,7 +114,7 @@ assert.equal(overview['2026-01-26'], undefined);
// ...to Jan 27, 14:00 -> afternoon segment // ...to Jan 27, 14:00 -> afternoon segment
assert.deepEqual(overview['2026-01-27'].segments, [null, 'muted', null]); assert.deepEqual(overview['2026-01-27'].segments, [null, 'muted', null]);
assert.match(overview['2026-01-27'].tooltip, /14:00 Standup \(moved\)/); assert.match(overview['2026-01-27'].tooltip, /15:00 Standup \(moved\)/);
// all-day multi-day event with a known leading emoji lights every segment, both spanned days // all-day multi-day event with a known leading emoji lights every segment, both spanned days
assert.deepEqual(overview['2026-01-10'].segments, ['magenta', 'magenta', 'magenta']); assert.deepEqual(overview['2026-01-10'].segments, ['magenta', 'magenta', 'magenta']);
@@ -91,15 +126,15 @@ assert.deepEqual(overview['2026-01-12'].segments, ['muted', null, null]);
// leading emoji that IS in tagsConfig takes that entry's color, not re-prefixed in tooltip // leading emoji that IS in tagsConfig takes that entry's color, not re-prefixed in tooltip
assert.deepEqual(overview['2026-01-06'].segments, ['aqua', null, null]); assert.deepEqual(overview['2026-01-06'].segments, ['aqua', null, null]);
assert.equal(overview['2026-01-06'].tooltip, '10:00 ✈️ Flight to Spain'); assert.equal(overview['2026-01-06'].tooltip, '11:00 ✈️ Flight to Spain');
// leading emoji NOT in tagsConfig still stands out (not muted) via the fallback color // leading emoji NOT in tagsConfig still stands out (not muted) via the fallback color
assert.deepEqual(overview['2026-01-08'].segments, [null, null, 'blue']); assert.deepEqual(overview['2026-01-08'].segments, [null, null, 'blue']);
assert.equal(overview['2026-01-08'].tooltip, '20:00 🎉 Party'); assert.equal(overview['2026-01-08'].tooltip, '21:00 🎉 Party');
// two consecutive leading emoji (no space between) split the segment into both colors // two consecutive leading emoji (no space between) split the segment into both colors
assert.deepEqual(overview['2026-01-15'].segments, [['aqua', 'magenta'], null, null]); assert.deepEqual(overview['2026-01-15'].segments, [['aqua', 'magenta'], null, null]);
assert.equal(overview['2026-01-15'].tooltip, '09:00 ✈️💜 Trip with Tessa'); assert.equal(overview['2026-01-15'].tooltip, '10:00 ✈️💜 Trip with Tessa');
// single-emoji case (Jan 6, asserted above) still resolves to a plain string, not an array — // single-emoji case (Jan 6, asserted above) still resolves to a plain string, not an array —
// the split path only triggers for a genuine two-emoji run. // the split path only triggers for a genuine two-emoji run.
assert.equal(Array.isArray(overview['2026-01-06'].segments[0]), false); assert.equal(Array.isArray(overview['2026-01-06'].segments[0]), false);
@@ -108,4 +143,18 @@ assert.equal(Array.isArray(overview['2026-01-06'].segments[0]), false);
assert.deepEqual(overview['2026-01-22'].segments, [['aqua', 'magenta'], ['aqua', 'magenta'], ['aqua', 'magenta']]); assert.deepEqual(overview['2026-01-22'].segments, [['aqua', 'magenta'], ['aqua', 'magenta'], ['aqua', 'magenta']]);
assert.equal(overview['2026-01-22'].tooltip, 'All day ✈️ 💜 Madeira'); assert.equal(overview['2026-01-22'].tooltip, 'All day ✈️ 💜 Madeira');
// two tagged events overlapping in time -> conflict flagged on their shared day
assert.equal(overview['2026-01-09'].conflict, true);
// a day with only one tagged event -> no conflict
assert.equal(overview['2026-01-06'].conflict, false);
// untagged (muted) events don't count as a conflict even when they overlap
assert.equal(overview['2026-01-05'].conflict, false);
// "nc" as a standalone word in the title marks that event's conflict as resolved
assert.equal(overview['2026-01-13'].conflict, false);
// bare Z-suffixed timestamp (no TZID, how Google's quick-add events are exported) is an
// absolute instant too -> displayed in Amsterdam local time (08:00 CET), not raw UTC (07:00)
assert.equal(overview['2026-01-16'].tooltip, '08:00 Quick add');
console.log('all assertions passed'); console.log('all assertions passed');