diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index 9bee358..65245a7 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -219,6 +219,8 @@ export interface UsageQueryResult { }; totals: UsageTotals; byDay: UsageMetricBucket[]; + /** Local-time hourly buckets keyed `YYYY-MM-DD HH`; absent from older daemons. */ + byHour?: UsageMetricBucket[]; byRepository: UsageMetricBucket[]; byModel: UsageMetricBucket[]; byAgent: UsageMetricBucket[]; diff --git a/src/store/sessions.ts b/src/store/sessions.ts index c65ebaa..83ec2ec 100644 --- a/src/store/sessions.ts +++ b/src/store/sessions.ts @@ -388,6 +388,7 @@ export class SessionStore { }; const byDayMap = new Map(); + const byHourMap = new Map(); const byRepoMap = new Map(); const byModelMap = new Map(); const byAgentMap = new Map(); @@ -480,6 +481,7 @@ export class SessionStore { const d = new Date(row.created_at); const dayKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; accumulate(byDayMap, dayKey, inputTokens, outputTokens, cachedTokens, totalTokens, cost); + accumulate(byHourMap, `${dayKey} ${String(d.getHours()).padStart(2, "0")}`, inputTokens, outputTokens, cachedTokens, totalTokens, cost); // Repo (normalized project name across worktrees) const repoKey = normalizeProjectName(row); @@ -546,6 +548,7 @@ export class SessionStore { const d = new Date(row.ended_at); const dayKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; accumulate(byDayMap, dayKey, inputTokens, outputTokens, cachedTokens, totalTokens, cost); + accumulate(byHourMap, `${dayKey} ${String(d.getHours()).padStart(2, "0")}`, inputTokens, outputTokens, cachedTokens, totalTokens, cost); accumulate( byRepoMap, normalizeProjectName({ repository: row.repository, cwd: row.cwd }), @@ -566,6 +569,7 @@ export class SessionStore { }; const byDay = [...byDayMap.values()].sort((a, b) => a.key.localeCompare(b.key)); + const byHour = [...byHourMap.values()].sort((a, b) => a.key.localeCompare(b.key)); const byRepository = [...byRepoMap.values()].sort(sortDescending); const byModel = [...byModelMap.values()].sort(sortDescending); const byAgent = [...byAgentMap.values()].sort(sortDescending); @@ -580,6 +584,7 @@ export class SessionStore { }, totals, byDay, + byHour, byRepository, byModel, byAgent, diff --git a/src/web/usage-page.ts b/src/web/usage-page.ts index 75cfd6d..5535755 100644 --- a/src/web/usage-page.ts +++ b/src/web/usage-page.ts @@ -195,19 +195,48 @@ export function renderLineChartSvg(rows: UsageMetricBucket[], metric: "costUsd" const left = Math.max(width < 500 ? 52 : 66, Math.ceil(longestTick * 6.7 + 8)); const x = (index: number) => left + index * (width - left - right) / Math.max(values.length - 1, 1); const y = (value: number) => top + (height - top - bottom) * (1 - value / max); - const path = values.map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join(" "); - const area = `${path} L ${x(values.length - 1)} ${height - bottom} L ${left} ${height - bottom} Z`; + const hourly = rows[0].key.length > 10; + const partial = rows[rows.length - 1].label?.endsWith("partial") ? 1 : 0; + const path = values.slice(0, values.length - partial).map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join(" "); + const partialPath = partial ? `` : ""; + const area = `${values.map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join(" ")} L ${x(values.length - 1)} ${height - bottom} L ${left} ${height - bottom} Z`; const grid = [0, .25, .5, .75, 1].map((part) => { const yy = y(max * part); return `${formatAxisTick(max * part, metric === "costUsd")}`; }).join(""); - const points = rows.map((row, index) => `${escapeHtml(row.label ?? row.key)}: ${values[index]}`).join(""); - const labels = rows.filter((_, index) => index % Math.max(1, Math.ceil(rows.length / 7)) === 0 || index === rows.length - 1).map((row) => { + const points = rows.map((row, index) => `${escapeHtml(row.label ?? row.key)}: ${values[index]}`).join(""); + const labels = hourly ? rows.map((row, index) => { + const hour = Number(row.key.slice(11)); + if (hour === 0) return `${escapeHtml(row.key.slice(5, 10))}`; + return hour % 6 === 0 && rows.length <= 72 ? `${hour}h` : ""; + }).join("") : rows.filter((_, index) => index % Math.max(1, Math.ceil(rows.length / 7)) === 0 || index === rows.length - 1).map((row) => { const index = rows.indexOf(row); const anchor = index === 0 && index !== rows.length - 1 ? "start" : index === rows.length - 1 && index !== 0 ? "end" : "middle"; return `${escapeHtml(row.key.slice(5))}`; }).join(""); - return `${grid}${labels}${points}`; + return `${grid}${labels}${partialPath}${points}`; +} + +/** Hourly rows (gaps zero-filled, current hour flagged partial) for ranges up to 7 days, daily rows otherwise. */ +export function chartRows(data: Pick, now = new Date()): UsageMetricBucket[] { + if (!data.byHour?.length || !data.byDay.length || data.byDay.length > 7) return data.byDay; + const pad = (value: number) => String(value).padStart(2, "0"); + const dayKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + const byKey = new Map(data.byHour.map((row) => [row.key, row])); + const [year, month, day] = data.byDay[0].key.split("-").map(Number); + const cursor = new Date(year, month - 1, day); + const lastDay = data.byDay[data.byDay.length - 1].key; + const currentKey = `${dayKey(now)} ${pad(now.getHours())}`; + const rows: UsageMetricBucket[] = []; + while (dayKey(cursor) <= lastDay) { + const key = `${dayKey(cursor)} ${pad(cursor.getHours())}`; + const partial = key === currentKey; + const label = `${key.slice(5, 10)} ${key.slice(11)}:00${partial ? " · partial" : ""}`; + rows.push({ ...(byKey.get(key) ?? { key, sessionCount: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, costUsd: 0, costComplete: true }), key, label }); + if (partial) break; + cursor.setHours(cursor.getHours() + 1); + } + return rows; } export function createUsageChartView(initialSeries: "costUsd" | "totalTokens" | "sessionCount" = "costUsd") { @@ -244,6 +273,7 @@ export function attachChartInteractions(wrapper: UsagePageElement, rows: UsageMe const row = rows[index]; if (!row) return; const formatted = formatChartTooltip(row, series); + points.forEach((point) => point.setAttribute("r", point === nearest ? "4" : point.getAttribute("data-r") ?? "4")); crosshair.setAttribute("x1", String(pointX)); crosshair.setAttribute("x2", String(pointX)); crosshair.setAttribute("visibility", "visible"); @@ -255,12 +285,12 @@ export function attachChartInteractions(wrapper: UsagePageElement, rows: UsageMe }; wrapper.addEventListener("pointermove", show); wrapper.addEventListener("pointerdown", show); - wrapper.addEventListener("pointerleave", () => { crosshair.setAttribute("visibility", "hidden"); tooltip.hidden = true; }); + wrapper.addEventListener("pointerleave", () => { points.forEach((point) => point.setAttribute("r", point.getAttribute("data-r") ?? "4")); crosshair.setAttribute("visibility", "hidden"); tooltip.hidden = true; }); } export function toUsagePageData(result: UsageQueryResult): UsagePageData { return { - range: { ...result.range }, totals: { ...result.totals }, byDay: [...result.byDay], + range: { ...result.range }, totals: { ...result.totals }, byDay: [...result.byDay], byHour: [...(result.byHour ?? [])], byRepository: [...result.byRepository], byModel: [...result.byModel], byAgent: [...result.byAgent], byRun: [...result.byRun], byOrigin: [...(result.byOrigin ?? [])], }; @@ -510,7 +540,7 @@ function renderUsageDashboard( for (const button of root.querySelectorAll("[data-breakdown]")) button.setAttribute("aria-pressed", String(button.dataset.breakdown === state.selectedBreakdown)); if (!state.result) return; const data = state.result; - const rows = data.byDay; + const rows = chartRows(data); const totals = data.totals; const chips = root.querySelector("[data-chips]"); if (chips) { @@ -579,8 +609,9 @@ function renderActiveUsageChart(root: UsagePageElement, state: UsagePageState, c const target = wrapper?.querySelector("[data-chart-svg]"); if (!wrapper || !target || !state.result) return; const width = wrapper.clientWidth || 880; - target.innerHTML = chartView.render(state.result.byDay, width); - attachChartInteractions(wrapper, state.result.byDay, chartView.getSeries()); + const rows = chartRows(state.result); + target.innerHTML = chartView.render(rows, width); + attachChartInteractions(wrapper, rows, chartView.getSeries()); } function selectedRows(data: UsagePageData, by: UsageBreakdown): UsageMetricBucket[] { @@ -592,7 +623,7 @@ function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (c export function renderUsagePage(options: UsagePageOptions = {}): string { const pageOptions = { by: options.by ?? "day", interval: options.interval ?? 2, filters: options.filters ?? {} }; const serializedOptions = JSON.stringify(pageOptions).replaceAll("<", "\\u003c"); - const behaviorSource = [normalizeUsageInterval, buildUsageApiUrl, buildUsagePageSearch, formatCompact, formatUsd, formatAxisTick, formatShare, formatCost, previousWindow, computeDelta, donutSlices, donutCenterFontSize, renderModelLegend, renderDonutSvg, renderSparklineSvg, renderLineChartSvg, createUsageChartView, formatChartTooltip, attachChartInteractions, toUsagePageData, createUsagePageController, startUsagePage, filterAndSortUsageRows, captureUsageSearchFocus, restoreUsageSearchFocus, csvQuote, renderUsageFilterOptions, renderUsageTableRows, renderUsageDashboard, renderActiveUsageChart, selectedRows, escapeHtml] + const behaviorSource = [normalizeUsageInterval, buildUsageApiUrl, buildUsagePageSearch, formatCompact, formatUsd, formatAxisTick, formatShare, formatCost, previousWindow, computeDelta, donutSlices, donutCenterFontSize, renderModelLegend, renderDonutSvg, renderSparklineSvg, renderLineChartSvg, chartRows, createUsageChartView, formatChartTooltip, attachChartInteractions, toUsagePageData, createUsagePageController, startUsagePage, filterAndSortUsageRows, captureUsageSearchFocus, restoreUsageSearchFocus, csvQuote, renderUsageFilterOptions, renderUsageTableRows, renderUsageDashboard, renderActiveUsageChart, selectedRows, escapeHtml] .map((behavior) => Function.prototype.toString.call(behavior)).join("\n\n"); const topbar = renderTopBar({ pages: options.pages ?? [{ label: "Home", path: "/" }, { label: "Review", path: "/review" }, { label: "Setup", path: "/setup" }, { label: "Usage", path: "/usage" }], activePath: "/usage", title: "Usage" }); return `CodeDeck usage