428 lines
14 KiB
HTML
428 lines
14 KiB
HTML
<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;
|
|
--white: #ffffff;
|
|
}
|
|
@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 .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-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 6px;
|
|
}
|
|
.month-head h3 {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
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.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; }
|
|
.seg {
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 1px;
|
|
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; }
|
|
</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>
|
|
<div id="report"></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', free: '--gridline', white: '--white',
|
|
};
|
|
|
|
const urlInput = document.getElementById('url');
|
|
const statusEl = document.getElementById('status');
|
|
const yearsEl = document.getElementById('years');
|
|
const legendEl = document.getElementById('legend');
|
|
const reportEl = document.getElementById('report');
|
|
|
|
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, monthCounts, colorLabels) {
|
|
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('');
|
|
const cls = `day${dateStr === today ? ' today' : ''}${info && info.conflict && dateStr >= today ? ' conflict' : ''}`;
|
|
cells += `<div class="${cls}" title="${title}">
|
|
<span class="num">${d}</span>
|
|
<span class="segs">${segHtml}</span>
|
|
</div>`;
|
|
}
|
|
|
|
const isPast = `${year}-${String(month + 1).padStart(2, '0')}` < today.slice(0, 7);
|
|
const entries = sortedEntries(monthCounts, colorLabels);
|
|
const iconHtml = buildPieHtml(entries, colorLabels, 14);
|
|
const popoverHtml = renderCategoryBlock(monthCounts, colorLabels, 130);
|
|
return `<div class="month${isPast ? ' past' : ''}">
|
|
<div class="month-head">
|
|
<h3>${MONTH_NAMES[month]}</h3>
|
|
<div class="pie-trigger">
|
|
${iconHtml}
|
|
<div class="pie-popover">${popoverHtml}</div>
|
|
</div>
|
|
</div>
|
|
<div class="weekdays">${WEEKDAY_NAMES.map((w) => `<div>${w}</div>`).join('')}</div>
|
|
<div class="days">${cells}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderYear(year, days, today, monthTotals, colorLabels) {
|
|
let months = '';
|
|
for (let m = 0; m < 12; m++) months += renderMonth(year, m, days, today, monthTotals[m], colorLabels);
|
|
return `<h2>${year}</h2><div class="year-grid">${months}</div>`;
|
|
}
|
|
|
|
function buildColorLabels(tagsConfig) {
|
|
const map = { free: 'Free' };
|
|
for (const t of Object.values(tagsConfig)) map[t.color] = t.label;
|
|
return map;
|
|
}
|
|
|
|
// a color string adds 1 block; a split segment (2-color array) counts only the 2nd emoji;
|
|
// an empty segment adds 1 to 'free'
|
|
function yearCategoryTotals(year, days) {
|
|
const total = {};
|
|
const months = Array.from({ length: 12 }, () => ({}));
|
|
const add = (bucket, color, amt) => { bucket[color] = (bucket[color] || 0) + amt; };
|
|
for (let m = 0; m < 12; m++) {
|
|
const numDays = new Date(year, m + 1, 0).getDate();
|
|
for (let d = 1; d <= numDays; d++) {
|
|
const dateStr = `${year}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
const segments = days[dateStr] ? days[dateStr].segments : [null, null, null];
|
|
for (const seg of segments) {
|
|
if (seg === 'muted') continue;
|
|
const color = seg ? (Array.isArray(seg) ? seg[1] : seg) : 'free';
|
|
add(total, color, 1); add(months[m], color, 1);
|
|
}
|
|
}
|
|
}
|
|
return { total, months };
|
|
}
|
|
|
|
// entries pre-sorted by tag name, shared by pie wedges, tooltip and list so all three line up
|
|
function conicGradient(entries) {
|
|
const total = entries.reduce((s, [, n]) => s + n, 0);
|
|
if (total === 0) return null;
|
|
let acc = 0;
|
|
const stops = entries.map(([color, n]) => {
|
|
const from = (acc / total) * 360;
|
|
acc += n;
|
|
return `var(${TAG_COLOR_VAR[color] || '--blue'}) ${from}deg ${(acc / total) * 360}deg`;
|
|
});
|
|
return `conic-gradient(${stops.join(', ')})`;
|
|
}
|
|
|
|
function pieTitle(entries, colorLabels) {
|
|
return entries.map(([color, n]) => `${colorLabels[color] || color}: ${n}`).join('\n');
|
|
}
|
|
|
|
// pin Free last and Work second-last, everything else alphabetical by label
|
|
const LABEL_SORT_RANK = { Work: 1, Free: 2 };
|
|
function labelSortKey(label) { return [LABEL_SORT_RANK[label] || 0, label]; }
|
|
|
|
function sortedEntries(counts, colorLabels) {
|
|
return Object.entries(counts).filter(([, n]) => n > 0)
|
|
.sort((a, b) => {
|
|
const [ra, la] = labelSortKey(colorLabels[a[0]] || a[0]);
|
|
const [rb, lb] = labelSortKey(colorLabels[b[0]] || b[0]);
|
|
return ra - rb || la.localeCompare(lb);
|
|
});
|
|
}
|
|
|
|
function buildPieHtml(entries, colorLabels, size) {
|
|
const bg = conicGradient(entries);
|
|
return bg
|
|
? `<div class="pie" style="width:${size}px;height:${size}px;background:${bg}" title="${pieTitle(entries, colorLabels).replace(/"/g, '"')}"></div>`
|
|
: `<div class="pie pie-empty" style="width:${size}px;height:${size}px"></div>`;
|
|
}
|
|
|
|
function renderCategoryBlock(counts, colorLabels, size) {
|
|
const entries = sortedEntries(counts, colorLabels);
|
|
const pieHtml = buildPieHtml(entries, colorLabels, size);
|
|
const listHtml = entries.length
|
|
? `<ul class="cat-list">${entries.map(([c, n]) =>
|
|
`<li><span class="swatch" style="background:var(${TAG_COLOR_VAR[c] || '--blue'})"></span>${colorLabels[c] || c}: ${n}</li>`
|
|
).join('')}</ul>`
|
|
: `<div class="cat-empty">No events</div>`;
|
|
return `<div class="cat-block">${pieHtml}${listHtml}</div>`;
|
|
}
|
|
|
|
function renderYearTotal(year, total, colorLabels) {
|
|
return `<div class="year-total"><h4>${year}</h4>${renderCategoryBlock(total, colorLabels, 90)}</div>`;
|
|
}
|
|
|
|
async function load() {
|
|
const url = urlInput.value.trim();
|
|
if (!url) return;
|
|
localStorage.setItem('icsUrl', url);
|
|
|
|
statusEl.className = '';
|
|
statusEl.textContent = 'Loading…';
|
|
yearsEl.innerHTML = '';
|
|
reportEl.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));
|
|
const colorLabels = buildColorLabels(tags);
|
|
|
|
let html = '';
|
|
let totalsHtml = '';
|
|
for (let y = startYear; y <= endYear; y++) {
|
|
const { total, months } = yearCategoryTotals(y, overviewRes.days);
|
|
html += renderYear(y, overviewRes.days, today, months, colorLabels);
|
|
totalsHtml += renderYearTotal(y, total, colorLabels);
|
|
}
|
|
yearsEl.innerHTML = html;
|
|
reportEl.innerHTML = `<div class="year-totals">${totalsHtml}</div>`;
|
|
|
|
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>
|