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 Tag', days: 1 }, { name: '12 Stunden', days: 0.5 }, { name: '6 Stunden', days: 0.25 }, ]; const BAR_COUNT = 30; let zoomIndex = 0; let viewFrom, viewTo; let zoomStack = []; let entryMode = false; // true = max zoom, individual entries as bars let entryData = []; // cached entries for entry mode init(); async function init() { $('btn-zoom-out').addEventListener('click', zoomOut); $('btn-sync').addEventListener('click', syncData); $('modal-close').addEventListener('click', closeModal); $('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); zoomIndex = 0; await loadView(); } // --- Main load --- async function loadView() { const from = viewFrom.toISOString(); const to = viewTo.toISOString(); $('range-label').textContent = `${fmtDate(viewFrom)} - ${fmtDate(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`; $('btn-zoom-out').disabled = zoomStack.length === 0; if (entryMode) { const [entries, summary] = await Promise.all([ api(`/api/cost-entries?from=${enc(from)}&to=${enc(to)}`), api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), ]); entryData = entries; renderSummary(summary); renderEntryChart(entries); renderEntryList(entries); } else { const totalMinutes = (viewTo - viewFrom) / 60000; const bucketMinutes = Math.max(1, Math.round(totalMinutes / BAR_COUNT)); const [buckets, summary] = await Promise.all([ api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`), api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), ]); renderSummary(summary); renderBucketChart(buckets, bucketMinutes); renderBlocks(buckets, bucketMinutes); } } function renderSummary(s) { $('summary').innerHTML = ` Zeitraum: $${s.totalCost.toFixed(2)} Einträge: ${s.totalEntries} `; } // --- Bucket chart (normal zoom levels) --- function renderBucketChart(buckets, bucketMinutes) { const chart = $('chart'); const labels = $('chart-labels'); chart.innerHTML = ''; labels.innerHTML = ''; const bucketMap = {}; buckets.forEach(b => { bucketMap[b.bucket] = b; }); const maxCost = Math.max(...buckets.map(b => b.cost), 0.001); const isMaxZoom = zoomIndex >= ZOOM_LEVELS.length - 1; for (let i = 0; i < BAR_COUNT; i++) { const b = bucketMap[i]; const cost = b ? b.cost : 0; const count = b ? b.count : 0; const pct = (cost / maxCost) * 100; const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000); const wrapper = document.createElement('div'); wrapper.className = 'bar-wrapper'; wrapper.innerHTML = `
$${cost.toFixed(3)} / ${count}
`; if (isMaxZoom) { // At max zoom: click switches to entry mode centered on this bucket wrapper.addEventListener('click', () => enterEntryMode(bucketStart, bucketEnd)); } else { wrapper.addEventListener('click', () => zoomInto(bucketStart, bucketEnd)); } chart.appendChild(wrapper); const lbl = document.createElement('div'); lbl.className = 'chart-label'; if (i % Math.ceil(BAR_COUNT / 6) === 0) { lbl.textContent = fmtShort(bucketStart); } labels.appendChild(lbl); } } // --- Entry chart (individual entries as bars) --- function renderEntryChart(entries) { const chart = $('chart'); const labels = $('chart-labels'); chart.innerHTML = ''; labels.innerHTML = ''; if (!entries.length) return; const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001); entries.forEach((e, i) => { const cost = Number(e.cost); const pct = (cost / maxCost) * 100; const wrapper = document.createElement('div'); wrapper.className = 'bar-wrapper'; wrapper.innerHTML = `
$${cost.toFixed(4)} - ${e.model || '?'}
`; wrapper.addEventListener('click', () => openModal(e.data)); chart.appendChild(wrapper); const lbl = document.createElement('div'); lbl.className = 'chart-label'; if (entries.length <= 15 || i % Math.ceil(entries.length / 8) === 0) { lbl.textContent = fmtTime(e.timestamp); } labels.appendChild(lbl); }); } // --- Blocks (normal zoom) --- function renderBlocks(buckets, bucketMinutes) { const container = $('blocks'); container.innerHTML = ''; const bucketMap = {}; buckets.forEach(b => { bucketMap[b.bucket] = b; }); const sortedBuckets = []; for (let i = 0; i < BAR_COUNT; i++) { const b = bucketMap[i]; if (!b || b.cost === 0) continue; sortedBuckets.push({ ...b, idx: i }); } sortedBuckets.sort((a, b) => b.cost - a.cost); for (const { idx: i, ...b } of sortedBuckets) { const bucketStart = new Date(viewFrom.getTime() + i * bucketMinutes * 60000); const bucketEnd = new Date(viewFrom.getTime() + (i + 1) * bucketMinutes * 60000); const block = document.createElement('div'); block.className = 'block'; block.innerHTML = `
${fmtRange(bucketStart, bucketEnd)} ${b.count} Einträge $${b.cost.toFixed(4)}
`; block.querySelector('.block-header').addEventListener('click', () => toggleBlock(i, bucketStart, bucketEnd)); container.appendChild(block); } if (!container.children.length) { container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; } } // --- Entry list (entry mode - flat, no expandable blocks) --- function renderEntryList(entries) { const container = $('blocks'); container.innerHTML = ''; if (!entries.length) { container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; return; } // Sort by cost descending const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost)); sorted.forEach(e => { const row = document.createElement('div'); row.className = 'entry entry-flat'; row.innerHTML = ` ${fmtTime(e.timestamp)} ${e.model || '-'} ${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)} $${Number(e.cost).toFixed(4)} ${e.type} `; row.addEventListener('click', () => openModal(e.data)); container.appendChild(row); }); } // --- Toggle block entries --- async function toggleBlock(idx, from, to) { const el = $(`be-${idx}`); if (el.classList.contains('open')) { el.classList.remove('open'); return; } if (!el.dataset.loaded) { const entries = await api(`/api/cost-entries?from=${enc(from.toISOString())}&to=${enc(to.toISOString())}`); el.innerHTML = entries.map(e => `
${fmtTime(e.timestamp)} ${e.model || '-'} ${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)} $${Number(e.cost).toFixed(4)} ${e.type}
`).join(''); el.querySelectorAll('.entry').forEach(row => { row.addEventListener('click', () => openModal(row.dataset.json)); }); el.dataset.loaded = '1'; } el.classList.add('open'); } // --- Zoom --- function zoomInto(clickStart, clickEnd) { if (zoomIndex >= ZOOM_LEVELS.length - 1) return; 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); zoomIndex++; entryMode = false; loadView(); } function enterEntryMode(bucketStart, bucketEnd) { zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); viewFrom = bucketStart; viewTo = bucketEnd; entryMode = true; loadView(); } function zoomOut() { if (!zoomStack.length) return; const prev = zoomStack.pop(); viewFrom = prev.from; viewTo = prev.to; zoomIndex = prev.zoom; entryMode = prev.entry; loadView(); } // --- Sync --- async function syncData() { const btn = $('btn-sync'); btn.classList.add('syncing'); btn.textContent = '↻ Sync...'; try { const res = await api('/api/sync', { method: 'POST' }); btn.textContent = `↻ ${res.output}`; setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); await loadView(); } catch (e) { btn.textContent = '↻ Fehler!'; setTimeout(() => { btn.textContent = '↻ Sync'; }, 3000); } btn.classList.remove('syncing'); } // --- Modal --- function openModal(dataStr) { try { $('modal-content').textContent = JSON.stringify(JSON.parse(dataStr), null, 2); } catch { $('modal-content').textContent = dataStr; } $('modal').classList.remove('hidden'); } function closeModal() { $('modal').classList.add('hidden'); } // --- Helpers --- async function api(url, opts) { return (await fetch(url, opts)).json(); } function enc(s) { return encodeURIComponent(s); } function fmtDate(d) { return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function fmtShort(d) { return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' }); } function fmtRange(a, b) { return fmtShort(a) + ' - ' + fmtShort(b); } function fmtTime(ts) { return ts ? new Date(ts).toLocaleTimeString('de-AT', { hour:'2-digit', minute:'2-digit', second:'2-digit' }) : ''; } function shortNum(n) { if (n == null) return '-'; return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); } function escAttr(s) { return String(s).replace(/'/g, ''').replace(/"/g, '"'); }