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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:26:39 +02:00
co-authored by Claude Sonnet 5
commit 74284ada97
10 changed files with 800 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+74
View File
@@ -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:0012:00), afternoon (12:0018:00), evening (18:0024: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=<n> 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=<ics>` 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=<ics url>`, 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.
+141
View File
@@ -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 };
+78
View File
@@ -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"
}
}
}
+17
View File
@@ -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"
}
}
+279
View File
@@ -0,0 +1,279 @@
<meta charset="utf-8">
<title>Year Overview</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--surface: #fcfcfb;
--page: #f9f9f7;
--ink: #0b0b0b;
--ink-2: #52514e;
--ink-muted: #898781;
--gridline: #e1e0d9;
--border: rgba(11, 11, 11, 0.10);
--today: #256abf;
--blue: #2a78d6;
--aqua: #1baf7a;
--yellow: #eda100;
--green: #008300;
--violet: #4a3aa7;
--red: #e34948;
--magenta: #e87ba4;
--orange: #eb6834;
--gray: #6e6c66;
--lightblue: #3fa9dc;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #1a1a19;
--page: #0d0d0d;
--ink: #ffffff;
--ink-2: #c3c2b7;
--ink-muted: #898781;
--gridline: #2c2c2a;
--border: rgba(255, 255, 255, 0.10);
--today: #3987e5;
--blue: #3987e5;
--aqua: #199e70;
--yellow: #c98500;
--green: #008300;
--violet: #9085e9;
--red: #e66767;
--magenta: #d55181;
--orange: #d95926;
--gray: #6e6c66;
--lightblue: #6cc7ea;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--page);
color: var(--ink);
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
}
h1 { font-size: 18px; margin: 0 0 16px; }
h2 { font-size: 15px; margin: 24px 0 10px; color: var(--ink-2); }
.controls {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
#url {
flex: 1;
max-width: 640px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
font: inherit;
}
button {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
font: inherit;
cursor: pointer;
}
button:hover { background: var(--gridline); }
#status { color: var(--ink-2); min-height: 1.4em; margin: 4px 0 16px; }
#status.error { color: var(--red); }
.legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-bottom: 8px;
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
gap: 14px;
}
.month {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
}
.month h3 {
font-size: 12px;
font-weight: 600;
margin: 0 0 6px;
color: var(--ink-2);
}
.weekdays, .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.weekdays div {
font-size: 9px;
color: var(--ink-muted);
text-align: center;
padding-bottom: 2px;
}
.day {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border-radius: 3px;
}
.day.today { outline: 1.5px solid var(--today); outline-offset: -1.5px; }
.day .num { font-size: 8px; color: var(--ink-muted); line-height: 1; }
.day.blank .num { visibility: hidden; }
.segs { display: flex; gap: 1px; }
.seg {
width: 4px;
height: 4px;
border-radius: 1px;
background: transparent;
}
.seg.muted { background: var(--ink-muted); opacity: 0.35; }
</style>
<h1>Year Overview</h1>
<div class="controls">
<input id="url" type="text" placeholder="Paste a Google Calendar ICS URL…">
<button id="load">Load</button>
</div>
<div id="status"></div>
<div id="legend" class="legend"></div>
<div id="years"></div>
<script>
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const WEEKDAY_NAMES = ['M','T','W','T','F','S','S']; // week starts Monday (NL convention)
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',
};
const urlInput = document.getElementById('url');
const statusEl = document.getElementById('status');
const yearsEl = document.getElementById('years');
const legendEl = document.getElementById('legend');
urlInput.value = localStorage.getItem('icsUrl') || '';
function todayStr() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function buildLegend(tagsConfig) {
const items = Object.entries(tagsConfig).map(([emoji, t]) =>
`<span class="item"><span class="swatch" style="background:var(${TAG_COLOR_VAR[t.color] || '--blue'})"></span>${emoji} ${t.label}</span>`
);
items.push('<span class="item"><span class="swatch" style="background:var(--blue)"></span>other emoji</span>');
items.push('<span class="item"><span class="swatch" style="background:var(--ink-muted);opacity:.35"></span>planned</span>');
legendEl.innerHTML = items.join('');
}
function renderMonth(year, month, days, today) {
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();
let cells = '';
for (let i = 0; i < startWeekday; i++) cells += '<div class="day blank"></div>';
for (let d = 1; d <= numDays; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const info = days[dateStr];
const segs = info ? info.segments : [null, null, null];
const title = info && info.tooltip ? info.tooltip.replace(/"/g, '&quot;') : '';
const segHtml = segs.map((s) => {
if (!s) return '<span class="seg"></span>';
if (s === 'muted') return '<span class="seg muted"></span>';
if (Array.isArray(s)) {
const [c1, c2] = s.map((c) => TAG_COLOR_VAR[c] || '--blue');
return `<span class="seg" style="background:linear-gradient(to bottom, var(${c1}) 50%, var(${c2}) 50%)"></span>`;
}
return `<span class="seg" style="background:var(${TAG_COLOR_VAR[s] || '--blue'})"></span>`;
}).join('');
cells += `<div class="day${dateStr === today ? ' today' : ''}" title="${title}">
<span class="num">${d}</span>
<span class="segs">${segHtml}</span>
</div>`;
}
return `<div class="month">
<h3>${MONTH_NAMES[month]}</h3>
<div class="weekdays">${WEEKDAY_NAMES.map((w) => `<div>${w}</div>`).join('')}</div>
<div class="days">${cells}</div>
</div>`;
}
function renderYear(year, days, today) {
let months = '';
for (let m = 0; m < 12; m++) months += renderMonth(year, m, days, today);
return `<h2>${year}</h2><div class="year-grid">${months}</div>`;
}
async function load() {
const url = urlInput.value.trim();
if (!url) return;
localStorage.setItem('icsUrl', url);
statusEl.className = '';
statusEl.textContent = 'Loading…';
yearsEl.innerHTML = '';
try {
const [overviewRes, tags] = await Promise.all([
fetch(`/api/overview?url=${encodeURIComponent(url)}`).then((r) => r.json()),
fetch('/tags.json').then((r) => r.json()),
]);
if (overviewRes.error) throw new Error(overviewRes.error);
buildLegend(tags);
const today = todayStr();
const startYear = Number(overviewRes.rangeStart.slice(0, 4));
const endYear = Number(overviewRes.rangeEnd.slice(0, 4));
let html = '';
for (let y = startYear; y <= endYear; y++) {
html += renderYear(y, overviewRes.days, today);
}
yearsEl.innerHTML = html;
statusEl.textContent = '';
} catch (err) {
statusEl.className = 'error';
statusEl.textContent = err.message;
}
}
document.getElementById('load').addEventListener('click', load);
urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') load(); });
(async () => {
if (!urlInput.value) {
const { url } = await fetch('/api/default-url').then((r) => r.json());
if (url) urlInput.value = url;
}
if (urlInput.value) load();
})();
</script>
+87
View File
@@ -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}`);
});
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
PORT=4000 exec npm start
+9
View File
@@ -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" }
}
+111
View File
@@ -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');