feat(dashboard): add granular session and model timelines (#673)

* feat(dashboard): add granular usage timelines

* fix(dashboard): readable timeline legend and month-scale buckets

Session legend labels now use the real projectPath's last two segments
(app/web) instead of the sanitized project dir, which rendered as an
unreadable -Users-name-... dump and truncated in the legend.

Hourly buckets now cap at 8 days; longer ranges bucket daily. A 30-day
window rendered 720 overlapping hourly spikes, unreadable as a chart.

The browser no longer gives the backend session_other/model_other
aggregate a top-N slot, which rendered two identical Other legend
entries whenever the backend fold out-ranked a real series.

---------

Co-authored-by: AgentSeal <hello@agentseal.org>
This commit is contained in:
ozymandiashh 2026-07-16 13:04:54 +03:00 committed by GitHub
parent 1e54c693b6
commit 5d366fb03b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 877 additions and 17 deletions

View file

@ -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

View file

@ -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:
</div>
</div>
<div className="mt-3 h-64 px-2 pb-2">
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : <UsageChart daily={payload.history.daily} unit={unit} />}
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : (
<GranularUsageChart daily={payload.history.daily} timeline={payload.history.timeline} unit={unit} />
)}
</div>
</Card>

View file

@ -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<string, string>, fmt: (n: number) => string) {
function makeTooltip(labels: Record<string, string>, 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<string, string>, fmt: (n: number) => string)
const total = items.reduce((s: number, p: any) => s + p.value, 0)
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-black/5">
<div className="mb-1.5 font-medium text-foreground">{fmtDay(String(lbl))}</div>
<div className="mb-1.5 font-medium text-foreground">{formatPeriod(String(lbl))}</div>
<div className="flex flex-col gap-1">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{items.slice(0, 6).map((p: any) => (
@ -47,6 +47,166 @@ function makeTooltip(labels: Record<string, string>, 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<string, number>()
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<string, number | string> = { 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 <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-tertiary-foreground">No timestamped usage in this period.</div>
}
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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 px-2 pb-1 text-[10px] text-tertiary-foreground">
{series.map(item => (
<span key={item.key} className="flex min-w-0 items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: item.color }} />
<span className="max-w-40 truncate">{item.label}</span>
</span>
))}
</div>
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -6 }}>
<CartesianGrid vertical={false} strokeDasharray="2 2" stroke="var(--color-chart-grid-stroke)" />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="equidistantPreserveStart"
minTickGap={36}
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={(value) => fmtTimelineTick(String(value), timeline.bucketMinutes)}
/>
<YAxis
tickLine={false}
axisLine={false}
width={50}
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={axisFmt}
/>
<Tooltip cursor={{ stroke: 'var(--color-chart-grid-stroke)' }} content={<Tip />} />
{series.map(item => (
<Line
key={item.key}
type="linear"
dataKey={item.key}
stroke={item.color}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
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 <LegacyUsageChart daily={daily} unit={unit} />
}
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 <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
}
export function GranularUsageChart({
daily,
timeline,
unit = 'cost',
}: {
daily: DailyEntry[]
timeline?: GranularHistory
unit?: Unit
}) {
const [selectedBreakdown, setSelectedBreakdown] = useState<Breakdown>('sessions')
if (!timeline) return <LegacyUsageChart daily={daily} unit={unit} />
const hasSessions = timeline.sessionSeries.length > 0
const hasModels = timeline.modelSeries.length > 0
const breakdown = selectedBreakdown === 'sessions' && !hasSessions && hasModels ? 'models' : selectedBreakdown
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between gap-3 px-2 pb-1">
<span className="text-[10px] font-medium uppercase tracking-[0.1em] text-tertiary-foreground">
{bucketLabel(timeline.bucketMinutes)}
</span>
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
{(['sessions', 'models'] as Breakdown[]).map(option => {
const available = option === 'sessions' ? hasSessions : hasModels
return (
<button
key={option}
type="button"
disabled={!available}
onClick={() => setSelectedBreakdown(option)}
className={cn(
'rounded-[4px] px-2 py-0.5 text-[10px] font-medium capitalize transition-colors',
breakdown === option ? 'bg-card text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
!available && 'cursor-not-allowed opacity-40',
)}
>
{option}
</button>
)
})}
</div>
</div>
<GranularLines timeline={timeline} breakdown={breakdown} unit={unit} />
</div>
)
}
// 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(() => {

View file

@ -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<Payload> {
@ -78,6 +94,27 @@ declare global {
function normalizePayload(p?: Payload): Payload | undefined {
if (!p) return p
const c = (p.current ?? {}) as Partial<Current>
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 } : {}),
},
}
}

242
src/granular-history.ts Normal file
View file

@ -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<string, Totals>
sessions: Map<string, Totals>
}
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<string, Totals>, 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<string, Totals>): Set<string> {
const selected = new Set<string>()
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<string, Totals>,
labels: Map<string, string>,
): { series: GranularSeries[]; values: GranularValue[][] } {
const selected = topSeriesKeys(totals)
const prefix = kind === 'models' ? 'model' : 'session'
const publicIds = new Map<string, string>()
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<string, RawBucket>()
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<string, Totals>()
const sessionTotals = new Map<string, Totals>()
const modelLabels = new Map<string, string>()
const sessionLabels = new Map<string, string>()
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 === '<synthetic>' ? '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] ?? [],
})),
}
}

View file

@ -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: [] })),
},
} : {}),
},
}
}

View file

@ -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<string, number> {
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

View file

@ -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: [] })),
},
} : {}),
},
}
}

View file

@ -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)
}

View file

@ -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
}
})
})

View file

@ -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')
})
})

View file

@ -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 },

View file

@ -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', () => {

View file

@ -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')

View file

@ -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 () => {