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 }, ]; const WEEKLY_BUDGET = 800; // $ pro Woche const BAR_COUNT = 30; const CHAIN_COLORS = [ '#4ade80','#60a5fa','#f97316','#c084fc','#f472b6', '#facc15','#34d399','#818cf8','#fb7185','#a78bfa', '#22d3ee','#e879f9','#84cc16','#f59e0b','#6ee7b7', ]; let zoomIndex = 0; let viewFrom, viewTo; let zoomStack = []; let entryMode = false; 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; restoreFromHash(); await loadView(); } function saveToHash() { const state = { f: viewFrom.toISOString(), t: viewTo.toISOString(), z: zoomIndex, e: entryMode ? 1 : 0, }; location.hash = btoa(JSON.stringify(state)); } function restoreFromHash() { if (!location.hash || location.hash.length < 2) return; try { const state = JSON.parse(atob(location.hash.slice(1))); viewFrom = new Date(state.f); viewTo = new Date(state.t); zoomIndex = state.z ?? 0; entryMode = !!state.e; } catch {} } // --- Main load --- async function loadView() { const from = viewFrom.toISOString(); const to = viewTo.toISOString(); $('range-label').textContent = `${fmtDateTime(viewFrom)} - ${fmtDateTime(viewTo)} (${entryMode ? 'Einträge' : ZOOM_LEVELS[zoomIndex].name})`; $('btn-zoom-out').disabled = zoomStack.length === 0 && zoomIndex === 0 && !entryMode; const [summary, chains] = await Promise.all([ api(`/api/cost-summary?from=${enc(from)}&to=${enc(to)}`), api(`/api/cost-chains?from=${enc(from)}&to=${enc(to)}`), ]); if (entryMode) { const entries = await api(`/api/entry-chains?from=${enc(from)}&to=${enc(to)}`); renderSummary(summary); renderEntryChart(entries); } else { const totalMinutes = (viewTo - viewFrom) / 60000; const bucketMinutes = Math.max(1, Math.round(totalMinutes / BAR_COUNT)); const buckets = await api(`/api/cost-buckets?from=${enc(from)}&to=${enc(to)}&bucketMinutes=${bucketMinutes}`); renderSummary(summary); renderBucketChart(buckets, bucketMinutes); } renderChainBlocks(chains); saveToHash(); } function renderSummary(s) { $('summary').innerHTML = ` Auslastung: ${pctScaled(s.totalCost)} 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 barPct = (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 = `
${pct(cost)} / ${count}
`; if (isMaxZoom) { 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 (grouped by chain from server) --- function renderEntryChart(entries) { const chart = $('chart'); const labels = $('chart-labels'); chart.innerHTML = ''; labels.innerHTML = ''; if (!entries.length) return; // Group by chainId from server const chainMap = new Map(); entries.forEach(e => { const cid = e.chainId ?? 0; if (!chainMap.has(cid)) chainMap.set(cid, []); chainMap.get(cid).push(e); }); const chains = [...chainMap.entries()] .sort((a, b) => a[0] - b[0]) .map(([, v]) => v); const maxCost = Math.max(...entries.map(e => Number(e.cost)), 0.001); window._entryChainColors = new Map(); chains.forEach((chain, ci) => { const color = CHAIN_COLORS[ci % CHAIN_COLORS.length]; if (ci > 0) { const spacer = document.createElement('div'); spacer.className = 'bar-spacer'; chart.appendChild(spacer); labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' })); } const chainTotal = chain.reduce((s, e) => s + Number(e.cost), 0); chain.forEach((e, ei) => { const cost = Number(e.cost); const barPct = (cost / maxCost) * 100; window._entryChainColors.set(e.id, color); const wrapper = document.createElement('div'); wrapper.className = 'bar-wrapper bar-in-chain'; wrapper.innerHTML = `
${pct(cost)} / ${e.model || '?'}
Kette: ${pct(chainTotal)}
`; wrapper.addEventListener('click', () => openModal(e.data, e.id)); chart.appendChild(wrapper); // Chain total label above first bar if (ei === 0) { const totalLbl = document.createElement('div'); totalLbl.className = 'chain-cost-label'; totalLbl.style.color = color; totalLbl.textContent = pct(chainTotal); wrapper.prepend(totalLbl); } const lbl = document.createElement('div'); lbl.className = 'chart-label'; if (ei === 0) lbl.textContent = fmtTime(chain[0].timestamp); labels.appendChild(lbl); }); }); } // --- Chain blocks (grouped by conversation chain, sorted by cost) --- function renderChainBlocks(chains) { const container = $('blocks'); container.innerHTML = ''; if (!chains.length) { container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; return; } chains.forEach((c, ci) => { const block = document.createElement('div'); block.className = 'block'; const first = new Date(c.first); const last = new Date(c.last); const durSec = Math.round((last - first) / 1000); block.innerHTML = `
${fmtDateTime(first)} ${durSec}s ${escHtml(c.userMessage || '-')} ${c.count} ${pct(c.totalCost)}
`; block.querySelector('.block-header').addEventListener('click', () => { openModal(null, null, c.chainId); }); container.appendChild(block); }); } // --- Entry list (flat, sorted by cost) --- function renderEntryList(entries) { const container = $('blocks'); container.innerHTML = ''; if (!entries.length) { container.innerHTML = '

Keine Einträge mit Kosten in diesem Zeitraum.

'; return; } const sorted = [...entries].sort((a, b) => Number(b.cost) - Number(a.cost)); sorted.forEach(e => { const color = window._entryChainColors?.get(e.id) || 'var(--green)'; const row = document.createElement('div'); row.className = 'entry entry-flat'; row.style.borderLeftColor = color; row.innerHTML = ` ${fmtTime(e.timestamp)} ${e.model || '-'} ${shortNum(e.inputTokens)}/${shortNum(e.outputTokens)} ${pct(Number(e.cost))} ${e.type} `; row.addEventListener('click', () => openModal(e.data, e.id)); container.appendChild(row); }); } // --- Toggle chain block entries --- async function toggleChainBlock(idx, chainId) { const el = $(`chain-block-${idx}`); if (el.classList.contains('open')) { el.classList.remove('open'); return; } if (!el.dataset.loaded) { // Load the full chain via entry-chain endpoint (using chainId = user message id) const chain = await api(`/api/entry-chain/${encodeURIComponent(chainId)}`); el.innerHTML = chain.map(entry => { const d = JSON.parse(entry.data); const msg = d.message; if (!msg) return ''; const cost = msg.usage?.cost?.total; return `
${fmtTime(entry.timestamp)} ${msg.model || '-'} ${shortNum(msg.usage?.input)}/${shortNum(msg.usage?.output)} ${cost ? pct(cost) : '-'} ${msg.role || entry.type}
`; }).filter(Boolean).join(''); el.querySelectorAll('.entry').forEach(row => { const eid = JSON.parse(row.dataset.json).id; row.addEventListener('click', () => openModal(row.dataset.json, eid)); }); 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) { const prev = zoomStack.pop(); viewFrom = prev.from; viewTo = prev.to; zoomIndex = prev.zoom; 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; } 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 --- async function openModal(dataStr, clickedId, chainId) { const chainEl = $('modal-chain'); const rawEl = $('modal-content'); if (dataStr) { try { rawEl.textContent = JSON.stringify(JSON.parse(dataStr), null, 2); } catch { rawEl.textContent = dataStr; } } else { rawEl.textContent = ''; } chainEl.innerHTML = '

Lade Kontext...

'; $('modal').classList.remove('hidden'); try { const entryId = chainId || JSON.parse(dataStr).id; const chain = await api(`/api/entry-chain/${encodeURIComponent(entryId)}`); // Calculate chain total const chainCosts = chain.map(entry => { try { const d = JSON.parse(entry.data); return d.message?.usage?.cost?.total || 0; } catch { return 0; } }); const chainTotal = chainCosts.reduce((a, b) => a + b, 0); const chainCount = chainCosts.filter(c => c > 0).length; // Fill raw JSON if not set (e.g. opened from chain block) if (!dataStr) { rawEl.textContent = JSON.stringify(chain.map(e => JSON.parse(e.data)), null, 2); } let html = `
Kette: ${pct(chainTotal)} ${chainCount} Einträge mit Kosten / ${chain.length} gesamt
`; html += chain.map(entry => { const d = JSON.parse(entry.data); const msg = d.message; if (!msg) return ''; const role = msg.role || '?'; const isUser = role === 'user'; const isHighlighted = d.id === clickedId; const content = extractContent(msg); const cost = msg.usage?.cost?.total; const model = msg.model; const time = fmtTime(entry.timestamp); return `
${role} ${time} ${model ? `${model}` : ''} ${cost ? `${pct(cost)}` : ''}
${escHtml(content)}
`; }).join(''); chainEl.innerHTML = html; // Scroll to highlighted entry const active = $('chain-active'); if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) { chainEl.innerHTML = `

Kontext konnte nicht geladen werden

`; } } function extractContent(msg) { if (typeof msg.content === 'string') return msg.content.slice(0, 2000); if (Array.isArray(msg.content)) { return msg.content.filter(c => c.type === 'text').map(c => c.text).join('\n').slice(0, 2000); } return JSON.stringify(msg.content).slice(0, 500); } 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 fmtDateTime(d) { return d.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit', second: '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 escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function escAttr(s) { return String(s).replace(/'/g, ''').replace(/"/g, '"'); } function pct(cost) { return (cost / WEEKLY_BUDGET * 100).toFixed(3) + '%'; } function pctScaled(cost) { const days = (viewTo - viewFrom) / 86400000; const budget = WEEKLY_BUDGET / 7 * days; return (cost / budget * 100).toFixed(3) + '%'; }