diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..431e893 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=3000 +CALURL=https://calendar.google.com/calendar/ical/xxxxx/basic.ics diff --git a/CLAUDE.md b/CLAUDE.md index aa27b09..f1d9cdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`, used only to diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9818ec --- /dev/null +++ b/README.md @@ -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=` 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. diff --git a/days.js b/days.js index fae427d..1e7106d 100644 --- a/days.js +++ b/days.js @@ -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; } diff --git a/package.json b/package.json index 3bf8931..09cd971 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/index.html b/public/index.html index 67e1b09..7605484 100644 --- a/public/index.html +++ b/public/index.html @@ -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; }

Year Overview

@@ -163,6 +211,7 @@
+