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:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
@@ -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=<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.
|
||||||
@@ -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 };
|
||||||
Generated
+78
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '"') : '';
|
||||||
|
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>
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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
@@ -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');
|
||||||
Reference in New Issue
Block a user