mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 08:22:42 +00:00
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:
parent
1e54c693b6
commit
5d366fb03b
15 changed files with 877 additions and 17 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
242
src/granular-history.ts
Normal 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] ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [] })),
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] })),
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
267
tests/granular-history.test.ts
Normal file
267
tests/granular-history.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue