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

+88 -16
View File
@@ -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();
}