From 75b324e38842b3a8ce1b05bf308e7ebba4aa7fac Mon Sep 17 00:00:00 2001 From: Meleki Date: Sun, 22 Feb 2026 18:21:30 +0100 Subject: [PATCH] anchor-based zoom levels, week separators in chart, UI polish --- Program.cs | 8 ++++ wwwroot/app.js | 104 +++++++++++++++++++++++++++++++++++++++------- wwwroot/style.css | 17 +++++++- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/Program.cs b/Program.cs index 90b2f46..b18ef53 100644 --- a/Program.cs +++ b/Program.cs @@ -15,6 +15,14 @@ var app = builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); +// GET /api/settings +app.MapGet("/api/settings", (IConfiguration config) => +{ + var anchorDay = int.TryParse(config["AnchorDay"], out var d) ? d : 4; // 0=Sun, 4=Thu + var anchorHour = int.TryParse(config["AnchorHour"], out var h) ? h : 23; + return Results.Ok(new { anchorDay, anchorHour }); +}); + // GET /api/cost-buckets?from=&to=&bucketMinutes= // Returns N buckets of aggregated cost app.MapGet("/api/cost-buckets", (Func factory, string from, string to, int bucketMinutes) => diff --git a/wwwroot/app.js b/wwwroot/app.js index fb10e96..a7638f0 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,15 +1,18 @@ const $ = id => document.getElementById(id); const ZOOM_LEVELS = [ - { name: '1 Monat', days: 30 }, - { name: '2 Wochen', days: 14 }, - { name: '1 Woche', days: 7 }, - { name: '3 Tage', days: 3 }, + { name: '1 Monat', weeks: 4 }, + { name: '2 Wochen', weeks: 2 }, + { name: '1 Woche', weeks: 1 }, { name: '1 Tag', days: 1 }, { name: '12 Stunden', days: 0.5 }, ]; -const WEEKLY_BUDGET = 800; // $ pro Woche -const BAR_COUNT = 30; +const WEEKLY_BUDGET = 500; // $ pro Woche + +// Anchor config - loaded from /api/settings, defaults: Thursday 23:00 +let ANCHOR_DAY = 4; // 0=Sun, 1=Mon, ..., 4=Thu +let ANCHOR_HOUR = 23; +const BAR_COUNT = 50; const CHAIN_COLORS = [ '#4ade80','#60a5fa','#f97316','#c084fc','#f472b6', '#facc15','#34d399','#818cf8','#fb7185','#a78bfa', @@ -21,6 +24,42 @@ let viewFrom, viewTo; let zoomStack = []; let entryMode = false; +// Find the most recent anchor point before or at `date` +function getLastAnchor(date) { + const d = new Date(date); + // Walk back to the anchor weekday + const diff = (d.getDay() - ANCHOR_DAY + 7) % 7; + d.setDate(d.getDate() - diff); + d.setHours(ANCHOR_HOUR, 0, 0, 0); + // If we landed in the future, go back one week + if (d > date) d.setDate(d.getDate() - 7); + return d; +} + +// Get view range for a zoom level anchored to the cycle +function getAnchoredRange(zoomIdx) { + const level = ZOOM_LEVELS[zoomIdx]; + const now = new Date(); + const anchor = getLastAnchor(now); + + if (level.weeks) { + const from = new Date(anchor); + from.setDate(from.getDate() - (level.weeks - 1) * 7); + const to = new Date(anchor); + to.setDate(to.getDate() + 7); + return { from, to }; + } else { + // Days/hours: subdivide current week, find the slot containing now + const weekStart = anchor; + const slotMs = level.days * 86400000; + const elapsed = now - weekStart; + const slotIndex = Math.floor(elapsed / slotMs); + const from = new Date(weekStart.getTime() + slotIndex * slotMs); + const to = new Date(from.getTime() + slotMs); + return { from, to }; + } +} + init(); async function init() { @@ -30,10 +69,17 @@ async function init() { $('modal').addEventListener('click', e => { if (e.target === $('modal')) closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); - viewTo = new Date(); - viewFrom = new Date(viewTo); - viewFrom.setDate(viewFrom.getDate() - ZOOM_LEVELS[0].days); + // Load anchor settings + try { + const settings = await api('/api/settings'); + if (settings.anchorDay != null) ANCHOR_DAY = settings.anchorDay; + if (settings.anchorHour != null) ANCHOR_HOUR = settings.anchorHour; + } catch {} + zoomIndex = 0; + const range = getAnchoredRange(zoomIndex); + viewFrom = range.from; + viewTo = range.to; restoreFromHash(); await loadView(); @@ -110,6 +156,9 @@ function renderBucketChart(buckets, bucketMinutes) { const maxCost = Math.max(...buckets.map(b => b.cost), 0.001); const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1; + // Determine week boundaries for grouping + let lastWeekId = null; + for (let i = 0; i < BAR_COUNT; i++) { const b = bucketMap[i]; const cost = b ? b.cost : 0; @@ -119,6 +168,17 @@ function renderBucketChart(buckets, bucketMinutes) { const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000); + // Check if this bucket crosses into a new week + const weekAnchor = getLastAnchor(bucketStart); + const weekId = weekAnchor.getTime(); + if (lastWeekId !== null && weekId !== lastWeekId) { + const spacer = document.createElement('div'); + spacer.className = 'bar-spacer'; + chart.appendChild(spacer); + labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' })); + } + lastWeekId = weekId; + const wrapper = document.createElement('div'); wrapper.className = 'bar-wrapper'; wrapper.innerHTML = ` @@ -171,7 +231,7 @@ function renderEntryChart(entries) { if (ci > 0) { const spacer = document.createElement('div'); - spacer.className = 'bar-spacer'; + spacer.className = 'bar-spacer chain-spacer'; chart.appendChild(spacer); labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' })); } @@ -309,9 +369,21 @@ function zoomInto(clickStart, clickEnd) { zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); const nextLevel = ZOOM_LEVELS[zoomIndex + 1]; const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2); - const halfMs = (nextLevel.days * 86400000) / 2; - viewFrom = new Date(clickMid.getTime() - halfMs); - viewTo = new Date(clickMid.getTime() + halfMs); + + if (nextLevel.weeks) { + // Anchor-based: find the week-aligned range containing clickMid + const anchor = getLastAnchor(clickMid); + viewFrom = anchor; + viewTo = new Date(anchor.getTime() + nextLevel.weeks * 7 * 86400000); + } else { + // Day/hour slots within the parent range + const slotMs = nextLevel.days * 86400000; + const elapsed = clickMid - viewFrom; + const slotIndex = Math.floor(elapsed / slotMs); + viewFrom = new Date(viewFrom.getTime() + slotIndex * slotMs); + viewTo = new Date(viewFrom.getTime() + slotMs); + } + zoomIndex++; entryMode = false; loadView(); @@ -334,11 +406,11 @@ function zoomOut() { entryMode = prev.entry; } else { // Reset to default view - viewTo = new Date(); - viewFrom = new Date(viewTo); - viewFrom.setDate(viewFrom.getDate() - ZOOM_LEVELS[0].days); zoomIndex = 0; entryMode = false; + const range = getAnchoredRange(zoomIndex); + viewFrom = range.from; + viewTo = range.to; } loadView(); } diff --git a/wwwroot/style.css b/wwwroot/style.css index 311e0d0..bd14a35 100644 --- a/wwwroot/style.css +++ b/wwwroot/style.css @@ -119,7 +119,22 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); } } .bar-in-chain { gap: 0; } -.bar-spacer { width: 10px; min-width: 10px; flex: 0 0 10px; } +.bar-spacer { + width: 12px; min-width: 12px; flex: 0 0 12px; + position: relative; + align-self: stretch; +} +.bar-spacer::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: var(--muted); +} +#chart-labels .bar-spacer { display: none; } +.bar-spacer.chain-spacer::before { display: none; } .bar { width: 100%;