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,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>
|
||||
Reference in New Issue
Block a user