Update layout

This commit is contained in:
2026-07-17 20:23:52 +02:00
parent 74284ada97
commit b0e873c4c7
8 changed files with 307 additions and 34 deletions
+166 -18
View File
@@ -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; }
</style>
<h1>Year Overview</h1>
@@ -163,6 +211,7 @@
<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'];
@@ -170,13 +219,14 @@
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',
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') || '';
@@ -194,7 +244,7 @@
legendEl.innerHTML = items.join('');
}
function renderMonth(year, month, days, today) {
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();
@@ -215,25 +265,115 @@
}
return `<span class="seg" style="background:var(${TAG_COLOR_VAR[s] || '--blue'})"></span>`;
}).join('');
cells += `<div class="day${dateStr === today ? ' today' : ''}" title="${title}">
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>`;
}
return `<div class="month">
<h3>${MONTH_NAMES[month]}</h3>
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) {
function renderYear(year, days, today, monthTotals, colorLabels) {
let months = '';
for (let m = 0; m < 12; m++) months += renderMonth(year, m, days, today);
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, '&quot;')}"></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;
@@ -242,6 +382,7 @@
statusEl.className = '';
statusEl.textContent = 'Loading…';
yearsEl.innerHTML = '';
reportEl.innerHTML = '';
try {
const [overviewRes, tags] = await Promise.all([
@@ -254,11 +395,18 @@
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++) {
html += renderYear(y, overviewRes.days, today);
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';