diff --git a/DEVY.md b/DEVY.md index 1522bb1..52168ae 100644 --- a/DEVY.md +++ b/DEVY.md @@ -2,10 +2,6 @@ Cost-Analytics Dashboard für OpenClaw API-Nutzung. Importiert Session-JSONL-Fil **Stack:** .NET 10 Minimal API + Vanilla JS SPA + SQLite | **Port:** 18684 -## Pfad - -`/home/node/.openclaw/workspace/projects/token-analytics` - ## Deployment ```bash @@ -14,8 +10,6 @@ bash deploy.sh ## Notizen -- Siehe ARCHITECTURE.md für technische Details -- Siehe IDEAS.md für offene Ideen - Config wird server-seitig in `dashboard-config.json` gespeichert (nicht in Git) - JSONL-Import ist Full-Scan (robust gegen OpenClaw Session-Kompaktierung) - Balkenanzahl dynamisch: Vielfache von 28, basierend auf Viewport und `minBarWidth` \ No newline at end of file diff --git a/wwwroot/app.js b/wwwroot/app.js index cbd9d57..10b7e7e 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -216,6 +216,7 @@ function renderBucketChart(buckets, bucketMinutes) { // Determine week boundaries for grouping let lastWeekId = null; let lastDayId = null; + let lastSixHourId = null; for (let i = 0; i < getBarCount(); i++) { const b = bucketMap[i]; @@ -236,12 +237,18 @@ function renderBucketChart(buckets, bucketMinutes) { const isNewWeek = lastWeekId !== null && weekId !== lastWeekId; const showDaySep = ZOOM_LEVELS[zoomIndex].weeks === 1; const isNewDay = showDaySep && lastDayId !== null && dayId !== lastDayId && !isNewWeek; - if (isNewWeek || isNewDay) { + // 3h quarter separators for 1-day view (absolute, anchored to anchorHour) + const isOneDayView = ZOOM_LEVELS[zoomIndex].days === 1; + const absHour = new Date(bucketStart); + const sixHourId = isOneDayView ? Math.floor((absHour.getHours() - CFG.anchorHour + 24) % 24 / 3) : null; + const isNewQuarter = isOneDayView && lastSixHourId !== null && sixHourId !== lastSixHourId && !isNewWeek && !isNewDay; + if (isNewWeek || isNewDay || isNewQuarter) { 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' })); } + if (isOneDayView) lastSixHourId = sixHourId; lastWeekId = weekId; lastDayId = dayId; @@ -365,20 +372,9 @@ function renderChainBlocks(chains) { 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 + // Already at max zoom (1 Tag) - enter entry mode for 3h window 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 / getBarCount())); - 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(); + enterEntryMode(chainMid, chainMid); } 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 }); @@ -497,8 +493,16 @@ function zoomInto(clickStart, clickEnd) { function enterEntryMode(bucketStart, bucketEnd) { zoomStack.push({ from: new Date(viewFrom), to: new Date(viewTo), zoom: zoomIndex, entry: false }); - viewFrom = bucketStart; - viewTo = bucketEnd; + // Snap to 3-hour window containing the clicked bucket (use bucketStart to avoid boundary drift) + const THREE_H = 3 * 3600000; + // Align to 3h slots starting at anchorHour + const dayStart = new Date(bucketStart); + dayStart.setHours(CFG.anchorHour, 0, 0, 0); + if (dayStart > bucketStart) dayStart.setDate(dayStart.getDate() - 1); + const elapsed = bucketStart.getTime() - dayStart.getTime(); + const slotIdx = Math.floor(elapsed / THREE_H); + viewFrom = new Date(dayStart.getTime() + slotIdx * THREE_H); + viewTo = new Date(viewFrom.getTime() + THREE_H); entryMode = true; loadView(); }