const $ = id => document.getElementById(id); const ZOOM_LEVELS = [ { name: '1 Monat', weeks: 4 }, { name: '2 Wochen', weeks: 2 }, { name: '1 Woche', weeks: 1 }, { name: '1 Tag', days: 1 } ]; const WEEKLY_BUDGET = 500; // $ pro Woche // Anchor config - loaded from /api/settings, defaults: Thursday 23:00 let ANCHOR_DAY = 0; // 0=Sun, 1=Mon, ..., 4=Thu let ANCHOR_HOUR = 0; const BAR_COUNT = 50; 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; // 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() { $('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(); }); // Anchor dropdowns const daySelect = $('anchor-day'); const hourSelect = $('anchor-hour'); for (let h = 0; h < 24; h++) { const opt = document.createElement('option'); opt.value = h; opt.textContent = h.toString().padStart(2, '0') + ':00'; hourSelect.appendChild(opt); } // Load from localStorage const savedDay = localStorage.getItem('anchorDay'); const savedHour = localStorage.getItem('anchorHour'); if (savedDay != null) ANCHOR_DAY = parseInt(savedDay); if (savedHour != null) ANCHOR_HOUR = parseInt(savedHour); daySelect.value = ANCHOR_DAY; hourSelect.value = ANCHOR_HOUR; daySelect.addEventListener('change', () => { ANCHOR_DAY = parseInt(daySelect.value); localStorage.setItem('anchorDay', ANCHOR_DAY); resetToStart(); }); hourSelect.addEventListener('change', () => { ANCHOR_HOUR = parseInt(hourSelect.value); localStorage.setItem('anchorHour', ANCHOR_HOUR); resetToStart(); }); zoomIndex = 0; const range = getAnchoredRange(zoomIndex); viewFrom = range.from; viewTo = range.to; 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').textContent = zoomStack.length === 0 ? '← Startseite' : '← Raus-Zoomen'; 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; // Determine week boundaries for grouping let lastWeekId = null; let lastDayId = null; 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); // Check if this bucket crosses into a new week or day const weekAnchor = getLastAnchor(bucketStart); const weekId = weekAnchor.getTime(); // Use local time for day boundary at anchor hour const localDayStart = new Date(bucketStart); localDayStart.setHours(localDayStart.getHours() - ANCHOR_HOUR); const dayId = localDayStart.toLocaleDateString(); const isNewWeek = lastWeekId !== null && weekId !== lastWeekId; const showDaySep = ZOOM_LEVELS[zoomIndex].weeks === 1; const isNewDay = showDaySep && lastDayId !== null && dayId !== lastDayId && !isNewWeek; if (isNewWeek || isNewDay) { const spacer = document.createElement('div'); spacer.className = isNewWeek ? 'bar-spacer' : 'bar-spacer day-spacer'; chart.appendChild(spacer); labels.appendChild(Object.assign(document.createElement('div'), { className: 'chart-label bar-spacer' })); } lastWeekId = weekId; lastDayId = dayId; 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 chain-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', () => { if (entryMode) { openModal(null, null, c.chainId); } else if (zoomIndex >= ZOOM_LEVELS.length - 1) { // Already at max zoom (1 Tag) - enter entry mode for the bucket containing this chain const chainMid = new Date((new Date(c.first).getTime() + new Date(c.last).getTime()) / 2); const totalMinutes = (viewTo - viewFrom) / 60000; const bucketMinutes = Math.max(1, Math.round(totalMinutes / BAR_COUNT)); const bucketMs = bucketMinutes * 60000; const elapsed = chainMid - viewFrom; const bucketIndex = Math.floor(elapsed / bucketMs); const bucketStart = new Date(viewFrom.getTime() + bucketIndex * bucketMs); const bucketEnd = new Date(bucketStart.getTime() + bucketMs); zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); viewFrom = bucketStart; viewTo = bucketEnd; entryMode = true; loadView(); } else { const chainMid = new Date((new Date(c.first).getTime() + new Date(c.last).getTime()) / 2); zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); const nextZoom = Math.min(zoomIndex + 1, ZOOM_LEVELS.length - 1); const nextLevel = ZOOM_LEVELS[nextZoom]; if (nextLevel.weeks) { const anchor = getLastAnchor(chainMid); viewFrom = anchor; viewTo = new Date(anchor.getTime() + nextLevel.weeks * 7 * 86400000); } else { // Day slots within current week const weekStart = getLastAnchor(chainMid); const slotMs = nextLevel.days * 86400000; const elapsed = chainMid - weekStart; const slotIndex = Math.floor(elapsed / slotMs); viewFrom = new Date(weekStart.getTime() + slotIndex * slotMs); viewTo = new Date(viewFrom.getTime() + slotMs); } zoomIndex = nextZoom; entryMode = false; loadView(); } }); 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); 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(); } 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 resetToStart() { location.hash = ''; zoomIndex = 0; entryMode = false; zoomStack = []; const range = getAnchoredRange(zoomIndex); viewFrom = range.from; viewTo = range.to; loadView(); } function zoomOut() { if (zoomStack.length) { const prev = zoomStack.pop(); viewFrom = prev.from; viewTo = prev.to; zoomIndex = prev.zoom; entryMode = prev.entry; loadView(); } else { // Back to start location.hash = ''; zoomIndex = 0; entryMode = false; zoomStack = []; const range = getAnchoredRange(zoomIndex); viewFrom = range.from; viewTo = range.to; 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) + '%'; }