Update layout
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
PORT=3000
|
||||
CALURL=https://calendar.google.com/calendar/ical/xxxxx/basic.ics
|
||||
@@ -66,7 +66,7 @@ one runtime dependency (`node-ical`).
|
||||
- **`tags.json`** — user-editable, not code. Maps an emoji to
|
||||
`{ label, color }`. `color` must be one of the names in `TAG_COLOR_VAR` in
|
||||
`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
|
||||
here won't crash anything but will look wrong.
|
||||
- **`.env`** (gitignored) — optional `CALURL=<ics url>`, used only to
|
||||
|
||||
@@ -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:00–12:00), afternoon
|
||||
(12:00–18:00), evening (18:00–24: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.
|
||||
@@ -36,9 +36,11 @@ function addDays(dateStr, n) {
|
||||
function localParts(date, tz) {
|
||||
// ponytail: no TZID means "floating" time per RFC 5545 - interpret in the
|
||||
// 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', {
|
||||
...(tz ? { timeZone: tz } : {}),
|
||||
...(tz ? { timeZone: 'Europe/Amsterdam' } : {}),
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
@@ -58,9 +60,9 @@ function formatHour(hour) {
|
||||
}
|
||||
|
||||
function segmentsForDay(dayStr, startLocal, lastDayStr, endLocal, isFullDay) {
|
||||
if (isFullDay) return [0, 1, 2];
|
||||
const dayStartHour = dayStr === startLocal.dateStr ? startLocal.hour : 0;
|
||||
const dayEndHour = dayStr === lastDayStr ? endLocal.hour : 24;
|
||||
const dayStartHour = isFullDay ? 0 : (dayStr === startLocal.dateStr ? startLocal.hour : 0);
|
||||
const dayEndHour = isFullDay ? 24 : (dayStr === lastDayStr ? endLocal.hour : 24);
|
||||
if (isFullDay) return { lit: [0, 1, 2], start: dayStartHour, end: dayEndHour };
|
||||
const lit = [];
|
||||
SEGMENT_RANGES.forEach(([segStart, segEnd], 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);
|
||||
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) {
|
||||
@@ -79,7 +91,7 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
|
||||
const days = {};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
@@ -99,6 +111,8 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
|
||||
const summary = inst.summary || '(untitled)';
|
||||
const colors = leadingEmojis(summary).map((e) => (tagsConfig[e] ? tagsConfig[e].color : UNKNOWN_EMOJI_COLOR));
|
||||
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 endInstant = isFullDay
|
||||
@@ -111,11 +125,12 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
|
||||
while (true) {
|
||||
if (dayStr >= rangeStartStr && dayStr <= rangeEndStr) {
|
||||
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) {
|
||||
if (color) day.segments[segIdx] = color;
|
||||
else if (!day.segments[segIdx]) day.segments[segIdx] = 'muted';
|
||||
}
|
||||
if (color && !noConflict) day.taggedIntervals.push({ start, end });
|
||||
day.events.push({
|
||||
hour: dayStr === startLocal.dateStr ? startLocal.hour : 0,
|
||||
isFullDay,
|
||||
@@ -133,7 +148,7 @@ function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) {
|
||||
const lines = day.events
|
||||
.sort((a, b) => a.hour - b.hour)
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"prestart": "test -d node_modules || npm install",
|
||||
"start": "node --env-file-if-exists=.env server.js",
|
||||
"test": "node test_days.js"
|
||||
},
|
||||
|
||||
+166
-18
@@ -22,6 +22,7 @@
|
||||
--orange: #eb6834;
|
||||
--gray: #6e6c66;
|
||||
--lightblue: #3fa9dc;
|
||||
--white: #ffffff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
@@ -96,14 +97,6 @@
|
||||
color: var(--ink-2);
|
||||
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; }
|
||||
|
||||
.year-grid {
|
||||
@@ -117,10 +110,16 @@
|
||||
border-radius: 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-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
margin: 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.weekdays, .days {
|
||||
@@ -143,6 +142,7 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
.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.blank .num { visibility: hidden; }
|
||||
.segs { display: flex; gap: 1px; }
|
||||
@@ -153,6 +153,54 @@
|
||||
background: transparent;
|
||||
}
|
||||
.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>
|
||||
|
||||
<h1>Year Overview</h1>
|
||||
@@ -163,6 +211,7 @@
|
||||
<div id="status"></div>
|
||||
<div id="legend" class="legend"></div>
|
||||
<div id="years"></div>
|
||||
<div id="report"></div>
|
||||
|
||||
<script>
|
||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
@@ -170,13 +219,14 @@
|
||||
const TAG_COLOR_VAR = {
|
||||
blue: '--blue', aqua: '--aqua', yellow: '--yellow', green: '--green',
|
||||
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 statusEl = document.getElementById('status');
|
||||
const yearsEl = document.getElementById('years');
|
||||
const legendEl = document.getElementById('legend');
|
||||
const reportEl = document.getElementById('report');
|
||||
|
||||
urlInput.value = localStorage.getItem('icsUrl') || '';
|
||||
|
||||
@@ -194,7 +244,7 @@
|
||||
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 startWeekday = (first.getDay() + 6) % 7; // getDay() is Sunday-first; shift to Monday-first
|
||||
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>`;
|
||||
}).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="segs">${segHtml}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="month">
|
||||
<h3>${MONTH_NAMES[month]}</h3>
|
||||
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>
|
||||
<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="days">${cells}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderYear(year, days, today) {
|
||||
function renderYear(year, days, today, monthTotals, colorLabels) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
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, '"')}"></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() {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
@@ -242,6 +382,7 @@
|
||||
statusEl.className = '';
|
||||
statusEl.textContent = 'Loading…';
|
||||
yearsEl.innerHTML = '';
|
||||
reportEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const [overviewRes, tags] = await Promise.all([
|
||||
@@ -254,11 +395,18 @@
|
||||
const today = todayStr();
|
||||
const startYear = Number(overviewRes.rangeStart.slice(0, 4));
|
||||
const endYear = Number(overviewRes.rangeEnd.slice(0, 4));
|
||||
const colorLabels = buildColorLabels(tags);
|
||||
|
||||
let html = '';
|
||||
let totalsHtml = '';
|
||||
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;
|
||||
reportEl.innerHTML = `<div class="year-totals">${totalsHtml}</div>`;
|
||||
|
||||
statusEl.textContent = '';
|
||||
} catch (err) {
|
||||
statusEl.className = 'error';
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"💼": { "label": "Work", "color": "gray" },
|
||||
"🦊": { "label": "Abunai!", "color": "orange" },
|
||||
"⛏️": { "label": "Xam", "color": "red" },
|
||||
"🎲": { "label": "DND", "color": "yellow" }
|
||||
"🎲": { "label": "DND", "color": "yellow" },
|
||||
"💬": { "label": "Social", "color": "white" }
|
||||
}
|
||||
|
||||
+54
-5
@@ -57,6 +57,41 @@ DTSTART;VALUE=DATE:20260122
|
||||
DTEND;VALUE=DATE:20260123
|
||||
SUMMARY:✈️ 💜 Madeira
|
||||
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
|
||||
`;
|
||||
|
||||
@@ -69,7 +104,7 @@ const overview = buildOverview(ICS, tagsConfig, '2026-01-01', '2026-01-31');
|
||||
|
||||
// plain weekly recurrence: morning segment, untagged -> muted
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 —
|
||||
// the split path only triggers for a genuine two-emoji run.
|
||||
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.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');
|
||||
|
||||
Reference in New Issue
Block a user