diff --git a/README.md b/README.md index cb843bcc..b3faeb1d 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ codeburn web --port 8080 # pick a port (falls back to a free one if taken codeburn web --no-open # start the server without opening a browser ``` -A local web dashboard with the same task, model, tool, and project breakdowns as the TUI, rendered with charts. Everything is read from disk on your machine and the server binds to localhost; nothing is uploaded. +A local web dashboard with the same task, model, tool, and project breakdowns as the TUI, rendered with charts. The usage graph follows the selected period with 15-minute, hourly, or daily buckets and can switch between per-session and per-model lines. Everything is read from disk on your machine and the server binds to localhost; nothing is uploaded. ### Combine usage across your devices diff --git a/dash/src/App.tsx b/dash/src/App.tsx index be8f46e6..92fe1aeb 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { MetricCard } from '@/components/MetricCard' import { BarList, type BarItem } from '@/components/BarList' import { DataTable } from '@/components/DataTable' -import { UsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart' +import { GranularUsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart' import { DeviceSearchModal } from '@/components/DeviceSearchModal' import { ContextExplorer } from '@/components/ContextExplorer' @@ -104,7 +104,9 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
- {!payload ? : } + {!payload ? : ( + + )}
diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 8408b5ee..905173f7 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -1,8 +1,8 @@ -import { useMemo } from 'react' -import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' -import type { DailyEntry, DeviceUsage } from '@/lib/api' -import { CHART_COLORS, compactUsd, fmtTokens, label, usd } from '@/lib/utils' +import type { DailyEntry, DeviceUsage, GranularHistory } from '@/lib/api' +import { CHART_COLORS, cn, compactUsd, fmtTokens, label, usd } from '@/lib/utils' export type Unit = 'cost' | 'tokens' @@ -16,7 +16,7 @@ const TOP_N = 6 type Series = { key: string; label: string; color: string } -function makeTooltip(labels: Record, fmt: (n: number) => string) { +function makeTooltip(labels: Record, fmt: (n: number) => string, formatPeriod = fmtDay) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function ChartTooltip({ active, payload, label: lbl }: any) { if (!active || !payload?.length) return null @@ -27,7 +27,7 @@ function makeTooltip(labels: Record, fmt: (n: number) => string) const total = items.reduce((s: number, p: any) => s + p.value, 0) return (
-
{fmtDay(String(lbl))}
+
{formatPeriod(String(lbl))}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {items.slice(0, 6).map((p: any) => ( @@ -47,6 +47,166 @@ function makeTooltip(labels: Record, fmt: (n: number) => string) } } +type Breakdown = 'sessions' | 'models' + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} + +function fmtTimelineTick(value: string, bucketMinutes: number): string { + const d = new Date(value) + if (!Number.isFinite(d.getTime())) return value + if (bucketMinutes >= 1440) return `${d.getDate()} ${MONTHS[d.getMonth() + 1]}` + if (bucketMinutes >= 60) return `${d.getDate()} ${MONTHS[d.getMonth() + 1]} ${pad2(d.getHours())}:00` + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function fmtTimelineTooltip(value: string, bucketMinutes: number): string { + const d = new Date(value) + if (!Number.isFinite(d.getTime())) return value + const day = `${d.getDate()} ${MONTHS[d.getMonth() + 1]} ${d.getFullYear()}` + if (bucketMinutes >= 1440) return day + return `${day}, ${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function bucketLabel(bucketMinutes: number): string { + if (bucketMinutes >= 1440) return 'Daily buckets' + if (bucketMinutes >= 60) return 'Hourly buckets' + return `${bucketMinutes}-minute buckets` +} + +function fmtTimelineUsd(value: number | string): string { + const number = Number(value) + if (!Number.isFinite(number)) return '$0' + const sign = number < 0 ? '-' : '' + const amount = Math.abs(number) + if (amount >= 100) return compactUsd(number) + if (amount >= 10) return `${sign}$${amount.toFixed(0)}` + if (amount >= 1) return `${sign}$${amount.toFixed(1)}` + if (amount >= 0.01) return `${sign}$${amount.toFixed(2)}` + if (amount > 0) return `${sign}$${amount.toFixed(3)}` + return '$0' +} + +function GranularLines({ + timeline, + breakdown, + unit, +}: { + timeline: GranularHistory + breakdown: Breakdown + unit: Unit +}) { + const { rows, series, labels } = useMemo(() => { + const metadata = breakdown === 'sessions' ? timeline.sessionSeries : timeline.modelSeries + const totals = new Map() + for (const point of timeline.points) { + const values = breakdown === 'sessions' ? point.sessions : point.models + for (const value of values) { + const amount = unit === 'tokens' ? value.tokens : value.cost + totals.set(value.seriesId, (totals.get(value.seriesId) ?? 0) + amount) + } + } + + // The backend already folds its beyond-cap remainder into a "*_other" + // series; never give it a top slot or it renders as a second "Other" + // line next to our own display_other fold. + const isBackendOther = (id: string) => id === 'session_other' || id === 'model_other' + const top = [...totals.entries()] + .filter(([id, total]) => total > 0 && !isBackendOther(id)) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_N) + .map(([id]) => id) + const topSet = new Set(top) + const hasOther = [...totals.entries()].some(([id, total]) => total > 0 && !topSet.has(id)) + const keys = hasOther ? [...top, 'display_other'] : top + const metadataById = new Map(metadata.map(item => [item.id, item.label])) + const rowData = timeline.points.map((point) => { + const row: Record = { period: point.timestamp } + for (const key of keys) row[key] = 0 + const values = breakdown === 'sessions' ? point.sessions : point.models + for (const value of values) { + const key = topSet.has(value.seriesId) ? value.seriesId : 'display_other' + if (!(key in row)) continue + row[key] = (row[key] as number) + (unit === 'tokens' ? value.tokens : value.cost) + } + return row + }) + const chartSeries: Series[] = keys.map((key, index) => ({ + key, + label: key === 'display_other' + ? 'Other' + : breakdown === 'models' + ? label(metadataById.get(key) ?? key) + : metadataById.get(key) ?? key, + color: CHART_COLORS[index % CHART_COLORS.length]!, + })) + return { + rows: rowData, + series: chartSeries, + labels: Object.fromEntries(chartSeries.map(item => [item.key, item.label])), + } + }, [timeline, breakdown, unit]) + + if (series.length === 0) { + return
No timestamped usage in this period.
+ } + + const fmt = unit === 'tokens' ? fmtTokens : usd + const axisFmt = (value: number | string) => (unit === 'tokens' ? fmtTokens(Number(value)) : fmtTimelineUsd(value)) + const Tip = makeTooltip(labels, fmt, value => fmtTimelineTooltip(value, timeline.bucketMinutes)) + + return ( +
+
+ {series.map(item => ( + + + {item.label} + + ))} +
+
+ + + + fmtTimelineTick(String(value), timeline.bucketMinutes)} + /> + + } /> + {series.map(item => ( + + ))} + + +
+
+ ) +} + function StackedBars({ rows, series, @@ -101,6 +261,10 @@ function StackedBars({ // Spend (or tokens) per day, stacked by model (single device). export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) { + return +} + +function LegacyUsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) { const { rows, series, labels } = useMemo(() => { const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) => unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost @@ -127,6 +291,54 @@ export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit return } +export function GranularUsageChart({ + daily, + timeline, + unit = 'cost', +}: { + daily: DailyEntry[] + timeline?: GranularHistory + unit?: Unit +}) { + const [selectedBreakdown, setSelectedBreakdown] = useState('sessions') + if (!timeline) return + + const hasSessions = timeline.sessionSeries.length > 0 + const hasModels = timeline.modelSeries.length > 0 + const breakdown = selectedBreakdown === 'sessions' && !hasSessions && hasModels ? 'models' : selectedBreakdown + + return ( +
+
+ + {bucketLabel(timeline.bucketMinutes)} + +
+ {(['sessions', 'models'] as Breakdown[]).map(option => { + const available = option === 'sessions' ? hasSessions : hasModels + return ( + + ) + })} +
+
+ +
+ ) +} + // Spend (or tokens) per day, stacked by device (one color per device) for the All view. export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) { const { rows, series, labels } = useMemo(() => { diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index 7e40ea30..ffc66246 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -19,6 +19,22 @@ export type DailyEntry = { topModels: ModelDay[] } +export type GranularSeries = { id: string; label: string } +export type GranularValue = { seriesId: string; cost: number; tokens: number } +export type GranularPoint = { + timestamp: string + cost: number + tokens: number + models: GranularValue[] + sessions: GranularValue[] +} +export type GranularHistory = { + bucketMinutes: number + modelSeries: GranularSeries[] + sessionSeries: GranularSeries[] + points: GranularPoint[] +} + export type Current = { label: string cost: number @@ -48,7 +64,7 @@ export type Current = { export type Payload = { generated: string current: Current - history: { daily: DailyEntry[] } + history: { daily: DailyEntry[]; timeline?: GranularHistory } } export async function fetchUsage(period: Period, provider: string): Promise { @@ -78,6 +94,27 @@ declare global { function normalizePayload(p?: Payload): Payload | undefined { if (!p) return p const c = (p.current ?? {}) as Partial + const rawTimeline = p.history?.timeline + const timeline = rawTimeline ? { + bucketMinutes: rawTimeline.bucketMinutes ?? 1440, + modelSeries: rawTimeline.modelSeries ?? [], + sessionSeries: rawTimeline.sessionSeries ?? [], + points: (rawTimeline.points ?? []).map((point) => ({ + timestamp: point.timestamp, + cost: point.cost ?? 0, + tokens: point.tokens ?? 0, + models: (point.models ?? []).map((value) => ({ + seriesId: value.seriesId, + cost: value.cost ?? 0, + tokens: value.tokens ?? 0, + })), + sessions: (point.sessions ?? []).map((value) => ({ + seriesId: value.seriesId, + cost: value.cost ?? 0, + tokens: value.tokens ?? 0, + })), + })), + } : undefined return { generated: p.generated, current: { @@ -122,6 +159,7 @@ function normalizePayload(p?: Payload): Payload | undefined { outputTokens: m.outputTokens ?? 0, })), })), + ...(timeline ? { timeline } : {}), }, } } diff --git a/src/granular-history.ts b/src/granular-history.ts new file mode 100644 index 00000000..aeecd565 --- /dev/null +++ b/src/granular-history.ts @@ -0,0 +1,242 @@ +import type { DateRange, ProjectSummary } from './types.js' + +const FIFTEEN_MINUTES = 15 +const ONE_HOUR = 60 +const ONE_DAY = 24 * 60 +const MINUTE_MS = 60 * 1000 +const MAX_SERIES_PER_METRIC = 6 + +export type GranularSeries = { + id: string + label: string +} + +export type GranularValue = { + seriesId: string + cost: number + tokens: number +} + +export type GranularPoint = { + timestamp: string + cost: number + tokens: number + models: GranularValue[] + sessions: GranularValue[] +} + +export type GranularHistory = { + bucketMinutes: number + modelSeries: GranularSeries[] + sessionSeries: GranularSeries[] + points: GranularPoint[] +} + +type Totals = { cost: number; tokens: number } +type RawBucket = { + timestamp: string + cost: number + tokens: number + models: Map + sessions: Map +} + +function nonNegative(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 0 +} + +export function granularBucketMinutes(range: DateRange): number { + const durationMs = Math.max(0, range.end.getTime() - range.start.getTime()) + if (durationMs <= 48 * ONE_HOUR * MINUTE_MS) return FIFTEEN_MINUTES + // Hourly beyond ~8 days means 200+ points of overlapping spikes; daily + // buckets keep month-scale charts readable. + if (durationMs <= 8 * ONE_DAY * MINUTE_MS) return ONE_HOUR + return ONE_DAY +} + +function bucketStart(date: Date, bucketMinutes: number): Date { + if (bucketMinutes === ONE_DAY) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) + } + + // Floor against local wall-clock time so :00 means the user's hour even in + // half-hour timezones. Applying that timestamp's own offset also keeps the + // two repeated hours distinct across a daylight-saving fallback. + const intervalMs = bucketMinutes * MINUTE_MS + const offsetMs = date.getTimezoneOffset() * MINUTE_MS + const localEpoch = date.getTime() - offsetMs + return new Date(Math.floor(localEpoch / intervalMs) * intervalMs + offsetMs) +} + +function nextBucket(date: Date, bucketMinutes: number): Date { + if (bucketMinutes === ONE_DAY) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1) + } + return new Date(date.getTime() + bucketMinutes * MINUTE_MS) +} + +function add(map: Map, key: string, cost: number, tokens: number): void { + const total = map.get(key) ?? { cost: 0, tokens: 0 } + total.cost += cost + total.tokens += tokens + map.set(key, total) +} + +function topSeriesKeys(totals: Map): Set { + const selected = new Set() + const rows = [...totals.entries()] + for (const [key] of [...rows].sort((a, b) => b[1].cost - a[1].cost).slice(0, MAX_SERIES_PER_METRIC)) { + selected.add(key) + } + for (const [key] of [...rows].sort((a, b) => b[1].tokens - a[1].tokens).slice(0, MAX_SERIES_PER_METRIC)) { + selected.add(key) + } + return selected +} + +function shortSessionId(sessionId: string): string { + const trimmed = sessionId.trim() + return trimmed.length > 12 ? `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}` : trimmed || 'unknown' +} + +// Legend labels: the sanitized project dir ("-Users-name-Projects-app") is +// unreadable, so prefer the real projectPath's last two segments ("app/web"). +// Fall back to the sanitized name when no usable path exists. +function shortProjectLabel(projectPath: string, fallback: string): string { + const segments = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '').split('/').filter(Boolean) + if (segments.length === 0) return fallback + return segments.slice(-2).join('/') +} + +function projectSeries( + rawBuckets: RawBucket[], + kind: 'models' | 'sessions', + totals: Map, + labels: Map, +): { series: GranularSeries[]; values: GranularValue[][] } { + const selected = topSeriesKeys(totals) + const prefix = kind === 'models' ? 'model' : 'session' + const publicIds = new Map() + const series: GranularSeries[] = [] + + let index = 0 + for (const rawKey of selected) { + const id = `${prefix}_${index++}` + publicIds.set(rawKey, id) + series.push({ id, label: labels.get(rawKey) ?? rawKey }) + } + + let hasOther = false + const otherId = `${prefix}_other` + const values = rawBuckets.map(bucket => { + const rows: GranularValue[] = [] + let otherCost = 0 + let otherTokens = 0 + for (const [rawKey, value] of bucket[kind]) { + const id = publicIds.get(rawKey) + if (id) { + rows.push({ seriesId: id, cost: value.cost, tokens: value.tokens }) + } else { + otherCost += value.cost + otherTokens += value.tokens + } + } + if (otherCost > 0 || otherTokens > 0) { + hasOther = true + rows.push({ seriesId: otherId, cost: otherCost, tokens: otherTokens }) + } + return rows + }) + + if (hasOther) series.push({ id: otherId, label: 'Other' }) + return { series, values } +} + +/** + * Build a selected-period timeline from real call timestamps. The result is + * bounded to the top six cost series plus the top six token series for each + * breakdown; everything else is retained in an aggregate Other series. + */ +export function buildGranularHistory( + projects: ProjectSummary[], + range: DateRange, + now = new Date(), +): GranularHistory { + const bucketMinutes = granularBucketMinutes(range) + const effectiveEnd = range.end.getTime() < now.getTime() ? range.end : now + if (range.start.getTime() > effectiveEnd.getTime()) { + return { bucketMinutes, modelSeries: [], sessionSeries: [], points: [] } + } + + const rawBuckets: RawBucket[] = [] + const byTimestamp = new Map() + for ( + let cursor = bucketStart(range.start, bucketMinutes); + cursor.getTime() <= effectiveEnd.getTime(); + cursor = nextBucket(cursor, bucketMinutes) + ) { + const timestamp = cursor.toISOString() + const bucket: RawBucket = { timestamp, cost: 0, tokens: 0, models: new Map(), sessions: new Map() } + rawBuckets.push(bucket) + byTimestamp.set(timestamp, bucket) + } + + const modelTotals = new Map() + const sessionTotals = new Map() + const modelLabels = new Map() + const sessionLabels = new Map() + let callCount = 0 + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + const timestamp = Date.parse(call.timestamp) + if (!Number.isFinite(timestamp) || timestamp < range.start.getTime() || timestamp > effectiveEnd.getTime()) continue + const bucket = byTimestamp.get(bucketStart(new Date(timestamp), bucketMinutes).toISOString()) + if (!bucket) continue + + const cost = nonNegative(call.costUSD) + // Match the browser's existing Tokens view: fresh input + output. + // Cache and reasoning remain available in their dedicated metrics. + const tokens = nonNegative(call.usage.inputTokens) + nonNegative(call.usage.outputTokens) + const modelKey = call.model || 'unknown' + // Session ids are usually globally unique, but a few providers scope + // them to a workspace. Include the project path so two workspaces do + // not collapse into one line when they reuse the same local id. + const sessionKey = `${call.provider}\0${project.projectPath}\0${session.sessionId}` + const projectName = session.project || project.project || 'Unknown project' + + bucket.cost += cost + bucket.tokens += tokens + add(bucket.models, modelKey, cost, tokens) + add(bucket.sessions, sessionKey, cost, tokens) + add(modelTotals, modelKey, cost, tokens) + add(sessionTotals, sessionKey, cost, tokens) + modelLabels.set(modelKey, modelKey === '' ? 'Other model' : modelKey) + sessionLabels.set(sessionKey, `${shortProjectLabel(project.projectPath, projectName)} · ${shortSessionId(session.sessionId)} (${call.provider})`) + callCount++ + } + } + } + } + + if (callCount === 0) { + return { bucketMinutes, modelSeries: [], sessionSeries: [], points: [] } + } + + const modelProjection = projectSeries(rawBuckets, 'models', modelTotals, modelLabels) + const sessionProjection = projectSeries(rawBuckets, 'sessions', sessionTotals, sessionLabels) + return { + bucketMinutes, + modelSeries: modelProjection.series, + sessionSeries: sessionProjection.series, + points: rawBuckets.map((bucket, i) => ({ + timestamp: bucket.timestamp, + cost: bucket.cost, + tokens: bucket.tokens, + models: modelProjection.values[i] ?? [], + sessions: sessionProjection.values[i] ?? [], + })), + } +} diff --git a/src/mcp/redact.ts b/src/mcp/redact.ts index b206c635..bdc54279 100644 --- a/src/mcp/redact.ts +++ b/src/mcp/redact.ts @@ -32,6 +32,7 @@ function redactSessionDetails(details: Array<{ cost: number; savingsUSD: number; export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload { if (includeNames) return payload + const timeline = payload.history?.timeline return { ...payload, current: { @@ -43,5 +44,15 @@ export function redactProjectNames(payload: MenubarPayload, includeNames: boolea })), topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })), }, + history: { + ...payload.history, + ...(timeline ? { + timeline: { + ...timeline, + sessionSeries: [], + points: timeline.points.map(point => ({ ...point, sessions: [] })), + }, + } : {}), + }, } } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index fbd13601..010f24ac 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -35,6 +35,7 @@ export type ProviderCost = { cost: number } import type { OptimizeResult } from './optimize.js' +import type { GranularHistory } from './granular-history.js' const TOP_ACTIVITIES_LIMIT = 20 const TOP_MODELS_LIMIT = 20 @@ -234,6 +235,9 @@ export type MenubarPayload = { } history: { daily: DailyHistoryEntry[] + /// Selected-period timeline for the local browser dashboard. Optional for + /// compatibility with older peers and non-dashboard payload producers. + timeline?: GranularHistory } combined?: CombinedUsage claudeConfigs?: ClaudeConfigSelector @@ -305,11 +309,11 @@ function buildProviders(providers: ProviderCost[]): Record { return map } -function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] { - if (!daily || daily.length === 0) return { daily: [] } +function buildHistory(daily: DailyHistoryEntry[] | undefined, timeline?: GranularHistory): MenubarPayload['history'] { + if (!daily || daily.length === 0) return { daily: [], ...(timeline ? { timeline } : {}) } const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date)) const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT) - return { daily: trimmed } + return { daily: trimmed, ...(timeline ? { timeline } : {}) } } function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] { @@ -372,6 +376,7 @@ export function buildMenubarPayload( routingWaste?: MenubarPayload['current']['routingWaste'], breakdowns?: BreakdownArrays, claudeConfigs?: ClaudeConfigSelector, + granularHistory?: GranularHistory, ): MenubarPayload { const payload: MenubarPayload = { generated: new Date().toISOString(), @@ -403,7 +408,7 @@ export function buildMenubarPayload( mcpServers: breakdowns?.mcpServers ?? [], }, optimize: buildOptimize(optimize), - history: buildHistory(dailyHistory), + history: buildHistory(dailyHistory, granularHistory), } if (claudeConfigs && claudeConfigs.options.length > 1) { payload.claudeConfigs = claudeConfigs diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts index a09f5118..98690b89 100644 --- a/src/sharing/sanitize.ts +++ b/src/sharing/sanitize.ts @@ -7,6 +7,9 @@ import type { MenubarPayload } from '../menubar-json.js' // those per device. If a user names a subagent/skill after a client, that name // would travel; revisit if that becomes a concern. export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { + // Older peers may predate the history field even though current producers + // always include it, so keep the boundary tolerant while sanitizing. + const timeline = payload.history?.timeline return { ...payload, current: { @@ -14,5 +17,15 @@ export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload { topProjects: [], topSessions: [], }, + history: { + ...payload.history, + ...(timeline ? { + timeline: { + ...timeline, + sessionSeries: [], + points: timeline.points.map(point => ({ ...point, sessions: [] })), + }, + } : {}), + }, } } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index b70b401d..4fef79ab 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -11,6 +11,7 @@ import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModels } from './models-report.js' import { scanAndDetect } from './optimize.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' +import { buildGranularHistory } from './granular-history.js' export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) @@ -518,5 +519,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: })() const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) - return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs) + const granularRange = opts.daysSelection?.range ?? scanRange + const granularHistory = buildGranularHistory(scanProjects, granularRange) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory) } diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts new file mode 100644 index 00000000..9eec5339 --- /dev/null +++ b/tests/granular-history.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest' + +import { buildGranularHistory, granularBucketMinutes, type GranularHistory } from '../src/granular-history.js' +import type { ParsedApiCall, ProjectSummary, TokenUsage } from '../src/types.js' + +const ZERO_USAGE: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, +} + +function apiCall(options: { + timestamp: string + model?: string + provider?: string + cost?: number + input?: number + output?: number + cacheRead?: number +}): ParsedApiCall { + return { + provider: options.provider ?? 'claude', + model: options.model ?? 'claude-sonnet-4-6', + usage: { + ...ZERO_USAGE, + inputTokens: options.input ?? 0, + outputTokens: options.output ?? 0, + cacheReadInputTokens: options.cacheRead ?? 0, + }, + costUSD: options.cost ?? 0, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: options.timestamp, + bashCommands: [], + deduplicationKey: `${options.timestamp}:${options.model ?? 'model'}`, + } +} + +function project(sessions: Array<{ id: string; project?: string; calls: ParsedApiCall[] }>): ProjectSummary { + return { + project: 'demo', + projectPath: '/repos/demo', + totalCostUSD: 0, + totalSavingsUSD: 0, + totalApiCalls: sessions.reduce((sum, session) => sum + session.calls.length, 0), + totalProxiedCostUSD: 0, + sessions: sessions.map(session => ({ + sessionId: session.id, + project: session.project ?? 'demo', + firstTimestamp: session.calls[0]?.timestamp ?? '', + lastTimestamp: session.calls.at(-1)?.timestamp ?? '', + totalCostUSD: session.calls.reduce((sum, call) => sum + call.costUSD, 0), + totalSavingsUSD: 0, + totalInputTokens: session.calls.reduce((sum, call) => sum + call.usage.inputTokens, 0), + totalOutputTokens: session.calls.reduce((sum, call) => sum + call.usage.outputTokens, 0), + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: session.calls.length, + turns: session.calls.map((call, index) => ({ + userMessage: '', + assistantCalls: [call], + timestamp: call.timestamp, + sessionId: session.id, + category: 'coding' as const, + retries: 0, + hasEdits: false, + turnId: String(index), + })), + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as never, + skillBreakdown: {}, + subagentBreakdown: {}, + })), + } +} + +function sumSeries(history: GranularHistory, kind: 'models' | 'sessions', seriesId: string, field: 'cost' | 'tokens'): number { + return history.points.reduce((sum, point) => { + const row = point[kind].find(value => value.seriesId === seriesId) + return sum + (row?.[field] ?? 0) + }, 0) +} + +describe('granular history', () => { + it('selects 15-minute, hourly, and daily buckets from the requested duration', () => { + const start = new Date('2026-07-01T00:00:00.000Z') + const range = (hours: number) => ({ start, end: new Date(start.getTime() + hours * 60 * 60 * 1000) }) + + expect(granularBucketMinutes(range(24))).toBe(15) + expect(granularBucketMinutes(range(48))).toBe(15) + expect(granularBucketMinutes(range(48.01))).toBe(60) + expect(granularBucketMinutes(range(24 * 8))).toBe(60) + expect(granularBucketMinutes(range(24 * 8 + 1))).toBe(1440) + expect(granularBucketMinutes(range(24 * 30))).toBe(1440) + }) + + it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => { + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([ + project([ + { + id: 'session-alpha-123456', + project: 'alpha', + calls: [ + apiCall({ timestamp: '2026-07-15T01:07:00.000Z', model: 'claude-opus-4-6', cost: 1, input: 100, output: 50, cacheRead: 9_999 }), + apiCall({ timestamp: '2026-07-15T01:14:00.000Z', model: 'claude-opus-4-6', cost: 0.5, input: 25, output: 25 }), + apiCall({ timestamp: '2026-07-15T01:16:00.000Z', model: 'claude-sonnet-4-6', cost: 0.25, input: 10, output: 5 }), + ], + }, + { + id: 'session-beta-654321', + project: 'beta', + calls: [apiCall({ timestamp: '2026-07-15T13:01:00.000Z', model: 'gpt-5.4', provider: 'codex', cost: 2, input: 200, output: 100 })], + }, + ]), + ], { start, end }, end) + + expect(history.bucketMinutes).toBe(15) + expect(history.points).toHaveLength(96) + expect(history.modelSeries).toHaveLength(3) + expect(history.sessionSeries).toHaveLength(2) + + const firstActive = history.points.find(point => point.timestamp === '2026-07-15T01:00:00.000Z')! + const secondActive = history.points.find(point => point.timestamp === '2026-07-15T01:15:00.000Z')! + const idle = history.points.find(point => point.timestamp === '2026-07-15T01:30:00.000Z')! + expect(firstActive).toMatchObject({ cost: 1.5, tokens: 200 }) + expect(secondActive).toMatchObject({ cost: 0.25, tokens: 15 }) + expect(idle).toMatchObject({ cost: 0, tokens: 0, models: [], sessions: [] }) + + // Labels use the real projectPath's last two segments, not the sanitized + // project name. + const alpha = history.sessionSeries.find(series => series.label === 'repos/demo · sessio…3456 (claude)')! + const beta = history.sessionSeries.find(series => series.label === 'repos/demo · sessio…4321 (codex)')! + expect(sumSeries(history, 'sessions', alpha.id, 'cost')).toBe(1.75) + expect(sumSeries(history, 'sessions', beta.id, 'tokens')).toBe(300) + // Cache reads are intentionally not folded into the browser's Tokens line. + expect(history.points.reduce((sum, point) => sum + point.tokens, 0)).toBe(515) + }) + + it('caps detail series, retains both cost-heavy and token-heavy leaders, and preserves totals in Other', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const sessions = Array.from({ length: 13 }, (_, index) => { + const rank = index + 1 + return { + id: `sensitive-session-${String(rank).padStart(2, '0')}`, + project: `project-${rank}`, + calls: [apiCall({ + timestamp, + model: `model-${rank}`, + cost: rank, + input: 14 - rank, + })], + } + }) + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + + const history = buildGranularHistory([project(sessions)], { start, end }, end) + const point = history.points.find(row => row.cost > 0)! + + // Six top-by-cost + six disjoint top-by-token + one Other line. + expect(history.modelSeries).toHaveLength(13) + expect(history.sessionSeries).toHaveLength(13) + expect(history.modelSeries.at(-1)).toEqual({ id: 'model_other', label: 'Other' }) + expect(history.sessionSeries.at(-1)).toEqual({ id: 'session_other', label: 'Other' }) + expect(point.models.reduce((sum, value) => sum + value.cost, 0)).toBe(point.cost) + expect(point.models.reduce((sum, value) => sum + value.tokens, 0)).toBe(point.tokens) + expect(point.sessions.reduce((sum, value) => sum + value.cost, 0)).toBe(point.cost) + expect(point.sessions.reduce((sum, value) => sum + value.tokens, 0)).toBe(point.tokens) + expect(new Set(history.sessionSeries.map(series => series.label)).size).toBe(history.sessionSeries.length) + expect(history.sessionSeries.every(series => !series.label.includes('sensitive-session-'))).toBe(true) + }) + + it('does not draw future buckets or accept calls outside the selected range', () => { + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const now = new Date('2026-07-15T02:20:00.000Z') + const history = buildGranularHistory([project([{ + id: 'active', + calls: [ + apiCall({ timestamp: '2026-07-15T02:19:00.000Z', cost: 1, input: 10 }), + apiCall({ timestamp: '2026-07-15T03:00:00.000Z', cost: 100, input: 1_000 }), + apiCall({ timestamp: 'not-a-date', cost: 100, input: 1_000 }), + ], + }])], { start, end }, now) + + expect(history.points.at(-1)!.timestamp).toBe('2026-07-15T02:15:00.000Z') + expect(history.points.reduce((sum, point) => sum + point.cost, 0)).toBe(1) + expect(history.points.reduce((sum, point) => sum + point.tokens, 0)).toBe(10) + }) + + it('keeps workspace-scoped session ids separate across projects', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const alpha = project([{ id: 'shared-local-id', project: 'alpha', calls: [apiCall({ timestamp, cost: 1 })] }]) + const beta = project([{ id: 'shared-local-id', project: 'beta', calls: [apiCall({ timestamp, cost: 2 })] }]) + alpha.projectPath = '/repos/alpha' + beta.projectPath = '/repos/beta' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + + const history = buildGranularHistory([alpha, beta], { start, end }, end) + + expect(history.sessionSeries).toHaveLength(2) + expect(history.sessionSeries.map(series => series.label)).toEqual(expect.arrayContaining([ + expect.stringContaining('alpha ·'), + expect.stringContaining('beta ·'), + ])) + }) + + it('aligns quarter-hour buckets to local wall time in a fractional-offset timezone', () => { + const previousTz = process.env['TZ'] + process.env['TZ'] = 'Asia/Kathmandu' + try { + const start = new Date(2026, 6, 15, 0, 7) + const end = new Date(2026, 6, 15, 1, 0) + const callTime = new Date(2026, 6, 15, 0, 17) + const history = buildGranularHistory([ + project([{ id: 'fractional-offset', calls: [apiCall({ timestamp: callTime.toISOString(), cost: 1 })] }]), + ], { start, end }, end) + const active = history.points.find(point => point.cost > 0)! + + expect(active.timestamp).toBe(new Date(2026, 6, 15, 0, 15).toISOString()) + } finally { + if (previousTz === undefined) delete process.env['TZ'] + else process.env['TZ'] = previousTz + } + }) + + it('keeps the two repeated hours distinct across daylight-saving fallback', () => { + const previousTz = process.env['TZ'] + process.env['TZ'] = 'America/New_York' + try { + const start = new Date(2026, 9, 31, 0, 0) + const end = new Date(2026, 10, 2, 23, 59, 59, 999) + const history = buildGranularHistory([ + project([{ + id: 'dst-fallback', + calls: [ + apiCall({ timestamp: '2026-11-01T05:30:00.000Z', cost: 1 }), + apiCall({ timestamp: '2026-11-01T06:30:00.000Z', cost: 2 }), + ], + }]), + ], { start, end }, end) + + expect(history.bucketMinutes).toBe(60) + expect(history.points.find(point => point.timestamp === '2026-11-01T05:00:00.000Z')?.cost).toBe(1) + expect(history.points.find(point => point.timestamp === '2026-11-01T06:00:00.000Z')?.cost).toBe(2) + } finally { + if (previousTz === undefined) delete process.env['TZ'] + else process.env['TZ'] = previousTz + } + }) +}) diff --git a/tests/mcp-redact.test.ts b/tests/mcp-redact.test.ts index 23157083..6f986cb6 100644 --- a/tests/mcp-redact.test.ts +++ b/tests/mcp-redact.test.ts @@ -8,7 +8,20 @@ function payload(): MenubarPayload { sessionDetails: [{ cost: 3, calls: 5, inputTokens: 100, outputTokens: 50, date: '2026-06-01', models: [{ name: 'Opus', cost: 3 }] }], } return { - generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] }, + generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, + history: { + daily: [], + timeline: { + bucketMinutes: 15, + modelSeries: [{ id: 'model_0', label: 'Opus' }], + sessionSeries: [{ id: 'session_0', label: 'secret-client-repo · abc123 (claude)' }], + points: [{ + timestamp: '2026-06-01T10:00:00.000Z', cost: 5, tokens: 150, + models: [{ seriesId: 'model_0', cost: 5, tokens: 150 }], + sessions: [{ seriesId: 'session_0', cost: 5, tokens: 150 }], + }], + }, + }, current: { label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0, cacheHitPercent: 0, topActivities: [], topModels: [], providers: {}, @@ -45,9 +58,18 @@ describe('redact', () => { const out = redactProjectNames(payload(), false) expect(out.current.topProjects[0]!.name).toBe(out.current.topSessions[0]!.project) }) + it('removes session timeline detail by default but keeps model aggregates', () => { + const out = redactProjectNames(payload(), false) + expect(out.history.timeline?.sessionSeries).toEqual([]) + expect(out.history.timeline?.points[0]!.sessions).toEqual([]) + expect(out.history.timeline?.modelSeries).toHaveLength(1) + expect(out.history.timeline?.points[0]!.models).toHaveLength(1) + expect(JSON.stringify(out)).not.toContain('secret-client-repo ·') + }) it('keeps real names and session details when include=true', () => { const out = redactProjectNames(payload(), true) expect(out.current.topProjects[0]!.name).toBe('secret-client-repo') expect(out.current.topProjects[0]!.sessionDetails![0]!.date).toBe('2026-06-01') + expect(out.history.timeline?.sessionSeries[0]!.label).toContain('secret-client-repo') }) }) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index e685b274..461d4021 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -252,6 +252,28 @@ describe('buildMenubarPayload', () => { expect(payload.history.daily).toEqual([]) }) + it('preserves the optional selected-period timeline alongside daily history', () => { + const timeline = { + bucketMinutes: 15, + modelSeries: [{ id: 'model_0', label: 'claude-opus-4-6' }], + sessionSeries: [{ id: 'session_0', label: 'codeburn · abc123 (claude)' }], + points: [{ + timestamp: '2026-07-15T10:00:00.000Z', + cost: 1.5, + tokens: 200, + models: [{ seriesId: 'model_0', cost: 1.5, tokens: 200 }], + sessions: [{ seriesId: 'session_0', cost: 1.5, tokens: 200 }], + }], + } + const payload = buildMenubarPayload( + emptyPeriod('Today'), [], null, + undefined, undefined, undefined, undefined, undefined, + timeline, + ) + + expect(payload.history).toEqual({ daily: [], timeline }) + }) + it('drops providers with negative cost defensively', () => { const providers: ProviderCost[] = [ { name: 'Claude', cost: 76.45 }, diff --git a/tests/sharing/sanitize.test.ts b/tests/sharing/sanitize.test.ts index 6ad7458b..20407065 100644 --- a/tests/sharing/sanitize.test.ts +++ b/tests/sharing/sanitize.test.ts @@ -25,7 +25,21 @@ function fixture(): MenubarPayload { tools: [{ name: 'Bash', calls: 9 }], topSessions: [{ project: 'secret-project', cost: 100, savingsUSD: 0, calls: 5, date: '2026-06-01' }], }, - history: { daily: [] }, + history: { + daily: [], + timeline: { + bucketMinutes: 15, + modelSeries: [{ id: 'model_0', label: 'claude-opus-4-6' }], + sessionSeries: [{ id: 'session_0', label: 'secret-project · abc123…cdef (claude)' }], + points: [{ + timestamp: '2026-06-01T10:00:00.000Z', + cost: 5, + tokens: 150, + models: [{ seriesId: 'model_0', cost: 5, tokens: 150 }], + sessions: [{ seriesId: 'session_0', cost: 5, tokens: 150 }], + }], + }, + }, } as unknown as MenubarPayload } @@ -37,6 +51,10 @@ describe('sanitizeForSharing', () => { expect(clean.current.cost).toBe(100) expect(clean.current.topModels[0]!.name).toBe('Opus') expect(clean.current.providers).toEqual({ claude: 100 }) + expect(clean.history.timeline?.sessionSeries).toEqual([]) + expect(clean.history.timeline?.points[0]!.sessions).toEqual([]) + expect(clean.history.timeline?.modelSeries).toHaveLength(1) + expect(clean.history.timeline?.points[0]!.models).toHaveLength(1) }) it('leaks no project name anywhere in the shared payload', () => { diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index 3a47c7fb..1d7083a3 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -15,6 +15,8 @@ describe('buildMenubarPayloadForRange', () => { expect(Array.isArray(payload.current.topProjects)).toBe(true) expect(Array.isArray(payload.current.topModels)).toBe(true) expect(Array.isArray(payload.history.daily)).toBe(true) + expect(payload.history.timeline?.bucketMinutes).toBe(15) + expect(Array.isArray(payload.history.timeline?.points)).toBe(true) expect(payload.current.retryTax.totalUSD).toBeGreaterThanOrEqual(0) // Codex credits are always present in the payload (display gates them); 0 with no data. expect(typeof payload.current.codexCredits).toBe('number') diff --git a/tests/web-dashboard.test.ts b/tests/web-dashboard.test.ts index f8858ca6..ba1ca58a 100644 --- a/tests/web-dashboard.test.ts +++ b/tests/web-dashboard.test.ts @@ -49,6 +49,9 @@ describe('web dashboard server: invalid query returns 400 without exiting', () = // A successful follow-up request proves the server survived the bad one. const ok = await fetch(`${base}/api/usage?period=today`) expect(ok.status).toBe(200) + const payload = await ok.json() as { history: { timeline?: { bucketMinutes: number; points: unknown[] } } } + expect(payload.history.timeline?.bucketMinutes).toBe(15) + expect(Array.isArray(payload.history.timeline?.points)).toBe(true) }) it('answers 400 for an invalid /api/devices period', async () => {