From 74284ada977b98f26a1e054bb28357aaac4e9181 Mon Sep 17 00:00:00 2001 From: Bas Grolleman Date: Fri, 17 Jul 2026 11:26:39 +0200 Subject: [PATCH] Initial commit: year overview app for Google Calendar ICS feeds Local Node app that fetches a Google Calendar ICS URL server-side (to avoid browser CORS restrictions) and renders a two-year overview with each day split into morning/afternoon/evening segments, colored by emoji found at the start of event titles (configured in tags.json). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + CLAUDE.md | 74 ++++++++++++ days.js | 141 +++++++++++++++++++++++ package-lock.json | 78 +++++++++++++ package.json | 17 +++ public/index.html | 279 ++++++++++++++++++++++++++++++++++++++++++++++ server.js | 87 +++++++++++++++ start.sh | 2 + tags.json | 9 ++ test_days.js | 111 ++++++++++++++++++ 10 files changed, 800 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 days.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server.js create mode 100755 start.sh create mode 100644 tags.json create mode 100644 test_days.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..713d500 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa27b09 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A single-user local web app: paste a Google Calendar ICS URL, see a two-year +(current + next) calendar 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 events, colored by emoji found at the start of the event +title (configured in `tags.json`). No build step, no frontend framework, +one runtime dependency (`node-ical`). + +## Commands + +- `npm start` (or `./start.sh`, which runs on port 4000 instead of the + default 3000) — starts the server. Loads `.env` if present via Node's + native `--env-file-if-exists` flag (no dotenv dependency). +- `npm test` (or `node test_days.js`) — runs the one test file, a plain + `assert`-based script (no test framework) covering the recurrence/segment + logic in `days.js`. +- `PORT= npm start` — override the port (default 3000). + +## Architecture + +- **`days.js`** — the only file with real logic, and the only one covered + by tests. `buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr)` + is a pure function: parses ICS text via `node-ical`, expands every VEVENT + (recurring or not) with `ical.expandRecurringEvent(..., { expandOngoing: true })` + — this one call correctly handles EXDATE and RECURRENCE-ID overrides, so + there's no manual recurrence-expansion or override-merging code here. + Returns `{ "YYYY-MM-DD": { segments: [...], tooltip: "..." } }`. + - Each `segments[i]` is `null` (nothing), `'muted'` (untagged event + present but not called out), a color name string (one leading emoji + matched, from `tagsConfig`), or a 2-element array of color names (two + leading emoji — see below). + - **Emoji detection**: `leadingEmojis()` reads up to 2 emoji from the + start of the event title, tolerating a single space between them (so + both `"✈️💜 Title"` and `"✈️ 💜 Title"` count as a pair). A title with + one leading emoji not present in `tagsConfig` still gets a color (the + `UNKNOWN_EMOJI_COLOR` fallback), so it stands out from plain + untagged/muted events. + - **All-day events**: DTEND is exclusive per RFC 5545, handled by + subtracting 1ms before computing the local day. + - **No-TZID timestamps**: treated as floating local time per RFC 5545 + (formatted in the system's own timezone, not UTC) — this matches how + `node-ical` itself encodes date-only DTSTART values, so the two stay + consistent. Don't default these to UTC; that was a real bug once. +- **`server.js`** — a bare `node:http` server, no framework (routes are + simple enough not to need one). `GET /api/overview?url=` fetches the + URL server-side and calls `buildOverview` — this exists only because + Google's ICS endpoint can't be fetched from the browser (CORS). Validates + the URL is `http`/`https` before fetching (the one trust-boundary check, + since the server will fetch whatever URL it's given). `tags.json` is read + fresh on every request (no caching), so editing it takes effect on the + next reload with no restart needed — only changes to `.js` files need a + server restart. +- **`public/index.html`** — the entire frontend: HTML, CSS, and JS inline in + one file, no build step. Fetches `/api/overview` and `/tags.json`, renders + a 12-month grid per year. Colors are CSS custom properties + (`TAG_COLOR_VAR` maps a color name from `tags.json` to a `--var`); a + segment holding a 2-color array renders as a `linear-gradient` split + top/bottom instead of a flat fill. Tooltips use the native `title` + attribute (no custom tooltip JS) — hover text is built server-side in + `days.js`. +- **`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 + 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=`, used only to + prefill/auto-load the URL input on page load via `GET /api/default-url`. + Never log or print its value; it's a private calendar URL. diff --git a/days.js b/days.js new file mode 100644 index 0000000..fae427d --- /dev/null +++ b/days.js @@ -0,0 +1,141 @@ +'use strict'; +const ical = require('node-ical'); + +// ponytail: pre-6am folded into "morning" — personal calendars rarely have events there +const SEGMENT_RANGES = [[0, 12], [12, 18], [18, 24]]; + +// color used for a leading emoji that isn't in tagsConfig +const UNKNOWN_EMOJI_COLOR = 'blue'; +const EMOJI_RE = /\p{Extended_Pictographic}/u; +const grapheme = new Intl.Segmenter('en', { granularity: 'grapheme' }); + +// leading emoji, capped at 2, tolerating one space between them ("✈️ 💚 Title" and "✈️💚 Title" both count) +function leadingEmojis(text, max = 2) { + const segs = [...grapheme.segment(text.trimStart())].map((s) => s.segment); + const result = []; + let i = 0; + while (result.length < max && i < segs.length && EMOJI_RE.test(segs[i])) { + result.push(segs[i]); + i++; + if (segs[i] === ' ' && EMOJI_RE.test(segs[i + 1])) i++; + } + return result; +} + +function dateStrToUTCNoon(dateStr) { + const [y, m, d] = dateStr.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d, 12)); +} + +function addDays(dateStr, n) { + const dt = dateStrToUTCNoon(dateStr); + dt.setUTCDate(dt.getUTCDate() + n); + return dt.toISOString().slice(0, 10); +} + +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. + const fmt = new Intl.DateTimeFormat('en-CA', { + ...(tz ? { timeZone: tz } : {}), + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }); + const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value])); + let hour = Number(parts.hour); + if (hour === 24) hour = 0; // ICU quirk: midnight can format as "24" + return { + dateStr: `${parts.year}-${parts.month}-${parts.day}`, + hour: hour + Number(parts.minute) / 60, + }; +} + +function formatHour(hour) { + const h = Math.floor(hour); + const m = Math.round((hour - h) * 60); + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +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 lit = []; + SEGMENT_RANGES.forEach(([segStart, segEnd], i) => { + if (dayStartHour < segEnd && dayEndHour > segStart) lit.push(i); + }); + if (lit.length === 0) { + const idx = SEGMENT_RANGES.findIndex(([, segEnd]) => dayStartHour < segEnd); + lit.push(idx === -1 ? 2 : idx); + } + return lit; +} + +function buildOverview(icsText, tagsConfig, rangeStartStr, rangeEndStr) { + const parsed = ical.sync.parseICS(icsText); + const rangeStart = dateStrToUTCNoon(rangeStartStr); + const rangeEnd = dateStrToUTCNoon(rangeEndStr); + const days = {}; + + const getDay = (dateStr) => { + if (!days[dateStr]) days[dateStr] = { segments: [null, null, null], events: [] }; + return days[dateStr]; + }; + + for (const event of Object.values(parsed)) { + if (event.type !== 'VEVENT' || !event.start) continue; + + let instances; + try { + instances = ical.expandRecurringEvent(event, { from: rangeStart, to: rangeEnd, expandOngoing: true }); + } catch { + continue; // ponytail: skip a malformed event rather than fail the whole import + } + + for (const inst of instances) { + if (!inst.start) continue; + const isFullDay = !!(inst.isFullDay || inst.start.dateOnly); + 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; + + const startLocal = localParts(inst.start, inst.start.tz); + const endInstant = isFullDay + ? new Date((inst.end || inst.start).getTime() - 1) + : inst.end || inst.start; + const endLocal = localParts(endInstant, (inst.end && inst.end.tz) || inst.start.tz); + const lastDayStr = endLocal.dateStr < startLocal.dateStr ? startLocal.dateStr : endLocal.dateStr; + + let dayStr = startLocal.dateStr; + while (true) { + if (dayStr >= rangeStartStr && dayStr <= rangeEndStr) { + const day = getDay(dayStr); + const lit = 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'; + } + day.events.push({ + hour: dayStr === startLocal.dateStr ? startLocal.hour : 0, + isFullDay, + text: summary, + }); + } + if (dayStr === lastDayStr) break; + dayStr = addDays(dayStr, 1); + } + } + } + + const result = {}; + for (const [dateStr, day] of Object.entries(days)) { + 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') }; + } + return result; +} + +module.exports = { buildOverview }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a59fd0a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "calenderoverview", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calenderoverview", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "node-ical": "^0.26.1" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/node-ical": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz", + "integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==", + "license": "Apache-2.0", + "dependencies": { + "rrule-temporal": "^1.5.3", + "temporal-polyfill": "^0.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/rrule-temporal": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.6.0.tgz", + "integrity": "sha512-tlhiNroletItRVdVP3knk92MCPNdFmPpehQMxf+jSo0CHZpmp1WUsvdVpg2iS++J9+O7/89J64BldN21kbcBqA==", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "temporal-spec": "^1.0.0" + } + }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-polyfill/node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, + "node_modules/temporal-spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.0.tgz", + "integrity": "sha512-00Ahj1e1ifaERTMOIIGpOCdOo9IEk2m6GGSMedsn9a2SIsGLdOTbmME1Htv6IM82b6VHrzSUTIVc7YHy6hdhFQ==", + "license": "Apache-2.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bf8931 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "calenderoverview", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "start": "node --env-file-if-exists=.env server.js", + "test": "node test_days.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "node-ical": "^0.26.1" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..67e1b09 --- /dev/null +++ b/public/index.html @@ -0,0 +1,279 @@ + +Year Overview + + + +

Year Overview

+
+ + +
+
+
+
+ + diff --git a/server.js b/server.js new file mode 100644 index 0000000..a05d7ba --- /dev/null +++ b/server.js @@ -0,0 +1,87 @@ +'use strict'; +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const { buildOverview } = require('./days.js'); + +const PORT = process.env.PORT || 3000; +const TAGS_PATH = path.join(__dirname, 'tags.json'); +const INDEX_PATH = path.join(__dirname, 'public', 'index.html'); + +function readTags() { + return JSON.parse(fs.readFileSync(TAGS_PATH, 'utf8')); +} + +function sendJSON(res, status, body) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function handleOverview(res, query) { + const icsUrl = query.get('url'); + if (!icsUrl) return sendJSON(res, 400, { error: 'missing url parameter' }); + + let parsedUrl; + try { + parsedUrl = new URL(icsUrl); + } catch { + return sendJSON(res, 400, { error: 'invalid url' }); + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return sendJSON(res, 400, { error: 'url must be http or https' }); + } + + let icsText; + try { + const fetchRes = await fetch(icsUrl); + if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`); + icsText = await fetchRes.text(); + } catch (err) { + return sendJSON(res, 502, { error: `could not fetch calendar: ${err.message}` }); + } + + const now = new Date(); + const rangeStart = `${now.getFullYear()}-01-01`; + const rangeEnd = `${now.getFullYear() + 1}-12-31`; + + let overview; + try { + overview = buildOverview(icsText, readTags(), rangeStart, rangeEnd); + } catch (err) { + return sendJSON(res, 500, { error: `could not parse calendar: ${err.message}` }); + } + + sendJSON(res, 200, { rangeStart, rangeEnd, days: overview }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + if (req.method === 'GET' && url.pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(fs.readFileSync(INDEX_PATH)); + return; + } + + if (req.method === 'GET' && url.pathname === '/tags.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(fs.readFileSync(TAGS_PATH)); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/default-url') { + return sendJSON(res, 200, { url: process.env.CALURL || null }); + } + + if (req.method === 'GET' && url.pathname === '/api/overview') { + await handleOverview(res, url.searchParams); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); +}); + +server.listen(PORT, () => { + console.log(`http://localhost:${PORT}`); +}); diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c8aa4f8 --- /dev/null +++ b/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +PORT=4000 exec npm start diff --git a/tags.json b/tags.json new file mode 100644 index 0000000..933e059 --- /dev/null +++ b/tags.json @@ -0,0 +1,9 @@ +{ + "💜": { "label": "Tessa", "color": "magenta" }, + "💚": { "label": "Caroline", "color": "green" }, + "✈️": { "label": "Holidays", "color": "lightblue" }, + "💼": { "label": "Work", "color": "gray" }, + "🦊": { "label": "Abunai!", "color": "orange" }, + "⛏️": { "label": "Xam", "color": "red" }, + "🎲": { "label": "DND", "color": "yellow" } +} diff --git a/test_days.js b/test_days.js new file mode 100644 index 0000000..50fd7b7 --- /dev/null +++ b/test_days.js @@ -0,0 +1,111 @@ +'use strict'; +const assert = require('node:assert/strict'); +const { buildOverview } = require('./days.js'); + +const ICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test//test//EN +BEGIN:VEVENT +UID:standup-1@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=UTC:20260105T090000 +DTEND;TZID=UTC:20260105T093000 +SUMMARY:Standup +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5 +EXDATE;TZID=UTC:20260119T090000 +END:VEVENT +BEGIN:VEVENT +UID:standup-1@test +RECURRENCE-ID;TZID=UTC:20260126T090000 +DTSTAMP:20260101T000000Z +DTSTART;TZID=UTC:20260127T140000 +DTEND;TZID=UTC:20260127T143000 +SUMMARY:Standup (moved) +END:VEVENT +BEGIN:VEVENT +UID:trip-1@test +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260110 +DTEND;VALUE=DATE:20260112 +SUMMARY:💜 Weekend trip +END:VEVENT +BEGIN:VEVENT +UID:flight-1@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=UTC:20260106T100000 +DTEND;TZID=UTC:20260106T103000 +SUMMARY:✈️ Flight to Spain +END:VEVENT +BEGIN:VEVENT +UID:party-1@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=UTC:20260108T200000 +DTEND;TZID=UTC:20260108T220000 +SUMMARY:🎉 Party +END:VEVENT +BEGIN:VEVENT +UID:combo-1@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=UTC:20260115T090000 +DTEND;TZID=UTC:20260115T093000 +SUMMARY:✈️💜 Trip with Tessa +END:VEVENT +BEGIN:VEVENT +UID:combo-2@test +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260122 +DTEND;VALUE=DATE:20260123 +SUMMARY:✈️ 💜 Madeira +END:VEVENT +END:VCALENDAR +`; + +const tagsConfig = { + '💜': { label: 'Partner Tessa', color: 'magenta' }, + '✈️': { label: 'Holidays', color: 'aqua' }, +}; + +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/); + +// EXDATE removes this occurrence entirely (nothing else touches this day) +assert.equal(overview['2026-01-19'], undefined); + +// RECURRENCE-ID moved the Jan 26 occurrence away +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\)/); + +// 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-11'].segments, ['magenta', 'magenta', 'magenta']); +assert.match(overview['2026-01-10'].tooltip, /All day 💜 Weekend trip/); + +// DTEND is exclusive: Jan 12 is NOT part of the trip, but it IS a Monday standup +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'); + +// 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'); + +// 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'); +// 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); + +// two leading emoji WITH a space between them (how this calendar actually types combos) still split +assert.deepEqual(overview['2026-01-22'].segments, [['aqua', 'magenta'], ['aqua', 'magenta'], ['aqua', 'magenta']]); +assert.equal(overview['2026-01-22'].tooltip, 'All day ✈️ 💜 Madeira'); + +console.log('all assertions passed');