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
+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>