anchor-based zoom levels, week separators in chart, UI polish

This commit is contained in:
Meleki committed 2026-02-22 18:21:30 +01:00
1 parent c5ce7c24f7
commit 75b324e388
3 files changed
+112 -17

No files matched your search

+8
View File
@@ -15,6 +15,14 @@ var app = builder.Build();
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); 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= // GET /api/cost-buckets?from=&to=&bucketMinutes=
// Returns N buckets of aggregated cost // Returns N buckets of aggregated cost
app.MapGet("/api/cost-buckets", (Func<SqliteConnection> factory, string from, string to, int bucketMinutes) => app.MapGet("/api/cost-buckets", (Func<SqliteConnection> factory, string from, string to, int bucketMinutes) =>
+88 -16
View File
@@ -1,15 +1,18 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const ZOOM_LEVELS = [ const ZOOM_LEVELS = [
{ name: '1 Monat', days: 30 }, { name: '1 Monat', weeks: 4 },
{ name: '2 Wochen', days: 14 }, { name: '2 Wochen', weeks: 2 },
{ name: '1 Woche', days: 7 }, { name: '1 Woche', weeks: 1 },
{ name: '3 Tage', days: 3 },
{ name: '1 Tag', days: 1 }, { name: '1 Tag', days: 1 },
{ name: '12 Stunden', days: 0.5 }, { name: '12 Stunden', days: 0.5 },
]; ];
const WEEKLY_BUDGET = 800; // $ pro Woche const WEEKLY_BUDGET = 500; // $ pro Woche
const BAR_COUNT = 30;
// 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 = [ const CHAIN_COLORS = [
'#4ade80','#60a5fa','#f97316','#c084fc','#f472b6', '#4ade80','#60a5fa','#f97316','#c084fc','#f472b6',
'#facc15','#34d399','#818cf8','#fb7185','#a78bfa', '#facc15','#34d399','#818cf8','#fb7185','#a78bfa',
@@ -21,6 +24,42 @@ let viewFrom, viewTo;
let zoomStack = []; let zoomStack = [];
let entryMode = false; 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(); init();
async function init() { async function init() {
@@ -30,10 +69,17 @@ async function init() {
$('modal').addEventListener('click', e => { if (e.target === $('modal')) closeModal(); }); $('modal').addEventListener('click', e => { if (e.target === $('modal')) closeModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
viewTo = new Date(); // Load anchor settings
viewFrom = new Date(viewTo); try {
viewFrom.setDate(viewFrom.getDate() - ZOOM_LEVELS[0].days); 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; zoomIndex = 0;
const range = getAnchoredRange(zoomIndex);
viewFrom = range.from;
viewTo = range.to;
restoreFromHash(); restoreFromHash();
await loadView(); await loadView();
@@ -110,6 +156,9 @@ function renderBucketChart(buckets, bucketMinutes) {
const maxCost = Math.max(...buckets.map(b => b.cost), 0.001); const maxCost = Math.max(...buckets.map(b => b.cost), 0.001);
const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1; const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1;
// Determine week boundaries for grouping
let lastWeekId = null;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
const b = bucketMap[i]; const b = bucketMap[i];
const cost = b ? b.cost : 0; const cost = b ? b.cost : 0;
@@ -119,6 +168,17 @@ function renderBucketChart(buckets, bucketMinutes) {
const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000);
const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * 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'); const wrapper = document.createElement('div');
wrapper.className = 'bar-wrapper'; wrapper.className = 'bar-wrapper';
wrapper.innerHTML = ` wrapper.innerHTML = `
@@ -171,7 +231,7 @@ function renderEntryChart(entries) {
if (ci > 0) { if (ci > 0) {
const spacer = document.createElement('div'); const spacer = document.createElement('div');
spacer.className = 'bar-spacer'; spacer.className = 'bar-spacer chain-spacer';
chart.appendChild(spacer); chart.appendChild(spacer);
labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-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 }); zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false });
const nextLevel = ZOOM_LEVELS[zoomIndex + 1]; const nextLevel = ZOOM_LEVELS[zoomIndex + 1];
const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2); const clickMid = new Date((clickStart.getTime() + clickEnd.getTime()) / 2);
const halfMs = (nextLevel.days * 86400000) / 2;
viewFrom = new Date(clickMid.getTime() - halfMs); if (nextLevel.weeks) {
viewTo = new Date(clickMid.getTime() + halfMs); // 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++; zoomIndex++;
entryMode = false; entryMode = false;
loadView(); loadView();
@@ -334,11 +406,11 @@ function zoomOut() {
entryMode = prev.entry; entryMode = prev.entry;
} else { } else {
// Reset to default view // Reset to default view
viewTo = new Date();
viewFrom = new Date(viewTo);
viewFrom.setDate(viewFrom.getDate() - ZOOM_LEVELS[0].days);
zoomIndex = 0; zoomIndex = 0;
entryMode = false; entryMode = false;
const range = getAnchoredRange(zoomIndex);
viewFrom = range.from;
viewTo = range.to;
} }
loadView(); loadView();
} }
+16 -1
View File
@@ -119,7 +119,22 @@ header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
} }
.bar-in-chain { gap: 0; } .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 { .bar {
width: 100%; width: 100%;