anchor-based zoom levels, week separators in chart, UI polish
This commit is contained in:
1 parent
c5ce7c24f7
commit
75b324e388
3 files changed
+112
-17
No files matched your search
@@ -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
@@ -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
@@ -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%;
|
||||||
|
|||||||
Reference in new issue
Block a user