feat(overview): add a 7-day usage chart and split the first row 1:1

The dashboard's first row becomes a 1:1 column pair: the KPI band's six
figures (three per row) beside a new last-7-days usage chart whose card
stretches to the same height. Each day pairs a billed-tokens bar (the
heatmap's blue) with its priced fee (green), folded off the merged daily
ledger and priced by the same book and estimator as the KPI cost cell —
per-turn attribution, never the session's last-active day. The plot is
framed like a chart: hairline gridlines at quarters of each series' own
peak, a solid baseline, tick rails on both sides (tokens left, cost
right, dashes while nothing prices), and a day strip mirroring the
columns' flex geometry. The pair folds to one column at 932px of
container and the KPI grid to 2x3 at 640px; the bars' box keeps a
min-height floor so percentage heights survive the folded single-column
row. The cost legend qualifies its scope (price-mapped models only)
while the tooltip line stays bare.
This commit is contained in:
bowenliang123 committed 2026-09-28 13:45:08 +08:00
1 parent 1935492d93
commit 8263f2eadb
9 files changed
+497 -87

No files matched your search

+46 -36
View File
@@ -6,9 +6,12 @@
* projection values), so the panel draws every session's insight without
* opening one log.
*
* The panel's first row is the KPI metrics band: the range's six figures
* (sessions, billed tokens, cost, cache hit, tool calls, active time) in one
* full-width line. Below it, the aggregate stats row folds the same range's
* The panel's first row is a 1:1 column pair: the KPI metrics band (the
* range's six figures — sessions, billed tokens, cost, cache hit, tool
* calls, active time — three per row) beside the last-7-days usage chart
* (each day's billed tokens and estimated cost as a bar pair, folded off
* the merged daily ledger). Below it, the aggregate stats row folds the
* same range's
* sessions into the two donut cards the per-session Context tab opens with —
* Token Stats (the composition-split billed volume) and Timing Stats (the
* summed totals). The body is then a 3:7 column pair: the insight column (the
@@ -40,6 +43,7 @@ import { useEscapeClose } from './escapeClose'
import { makeHeatmap, todayKey, type HeatMetric } from './heatmap'
import { makeDonut } from './donut'
import { makeOverviewTokens } from './overviewTokens'
import { makeOverviewUsage } from './overviewUsage'
import { makeStatsTiming } from './statsTiming'
import { ContextIcon } from '../icon'
import { makeOverviewCard } from './overviewCard'
@@ -68,6 +72,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const Donut = makeDonut(kit)
const OverviewTokens = makeOverviewTokens(kit, Donut)
const StatsTiming = makeStatsTiming(kit, Donut)
const OverviewUsage = makeOverviewUsage(kit)
const ErrorBoundary = makeErrorBoundary(t)
/** The display currency follows the active locale (zh → CNY), read per render — the slot outlet re-renders on a locale switch. */
@@ -132,7 +137,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
)
const paged = pageOf(visible, page)
const kpi = kpisOf(ranged, allRows.length, book, currency)
const days = aggregateDays(allRows)
const days = aggregateDays(allRows, book, currency)
const openOne = (id: string): void => {
openSessionVia(ctx, id)
overviewStore.set(false)
@@ -164,39 +169,44 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
<div className="lc-empty">{t('ov.unavailable')}</div>
) : (
<>
{/* The panel's first row: the KPI metrics band — the range's six
figures in one full-width line. */}
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
<span className="lc-stat-value">{kpi.sessions}</span>
<span className="lc-stat-sub">{t('ov.kpi.ofTotal', { n: kpi.listed })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.tokens')}</span>
<span className="lc-stat-value">{fmt(kpi.tokens)}</span>
<span className="lc-stat-sub">{t('stats.turns')} {fmt(kpi.turns)}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cost')}</span>
<span className="lc-stat-value">{kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}</span>
<span className="lc-stat-sub">{t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cacheHit')}</span>
<span className="lc-stat-value">{kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.toolCalls')}</span>
<span className="lc-stat-value">{fmt(kpi.toolCalls)}</span>
<span className="lc-stat-sub">{t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('timing.total')}</span>
<span className="lc-stat-value">{fmtDuration(kpi.wallMs)}</span>
<span className="lc-stat-sub">{t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}</span>
{/* The panel's first row: a 1:1 column pair — the KPI metrics
band (the range's six figures, three per row) beside the
last-7-days usage chart (per-day tokens + cost bars off the
merged ledger; the range selector does not scope it). */}
<div className="lc-ov-first">
<div className="lc-ov-kpis">
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.sessions')}</span>
<span className="lc-stat-value">{kpi.sessions}</span>
<span className="lc-stat-sub">{t('ov.kpi.ofTotal', { n: kpi.listed })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('ov.kpi.tokens')}</span>
<span className="lc-stat-value">{fmt(kpi.tokens)}</span>
<span className="lc-stat-sub">{t('stats.turns')} {fmt(kpi.turns)}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cost')}</span>
<span className="lc-stat-value">{kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}</span>
<span className="lc-stat-sub">{t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.cacheHit')}</span>
<span className="lc-stat-value">{kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}</span>
<span className="lc-stat-sub">{t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('stats.toolCalls')}</span>
<span className="lc-stat-value">{fmt(kpi.toolCalls)}</span>
<span className="lc-stat-sub">{t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}</span>
</div>
<div className="lc-stat lc-ov-kpi">
<span className="lc-stat-label">{t('timing.total')}</span>
<span className="lc-stat-value">{fmtDuration(kpi.wallMs)}</span>
<span className="lc-stat-sub">{t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}</span>
</div>
</div>
<OverviewUsage days={days} currency={currency} today={todayKey()} />
</div>
{/* The aggregate stats row: the range's Token Stats and Timing
Stats, folded over the KPI band's own scope (the composition
+144
View File
@@ -0,0 +1,144 @@
/**
* The Context Insights panel's first-row usage chart — the RIGHT half of the
* KPI row. Seven day columns ending today, each pairing two bars priced off
* the merged daily ledger (overview.ts aggregateDays): billed TOKENS (the
* heatmap's own blue) and the day's estimated COST (green). The fee rides
* the same per-day pricing records the host fold books at each turn's
* initiation, priced by the same book and estimator as the KPI band's cost
* cell — never a whole session's spend dumped on the day it was last
* active. The two series scale on their OWN window maxima (a $2 week must
* not flatten under a 169M-token week), so each bar reads as a share of its
* series' peak; the tooltip carries the exact figures. The window is fixed
* to the last 7 days — the range selector above scopes the KPI figures, not
* this calendar.
*
* The plot is framed like a chart: hairline gridlines at 0/25/50/75/100% of
* each series' own peak, a solid baseline at 0, and a tick rail on each
* side — TOKENS on the left, COST on the right, computed from the same
* maxima the bars scale to. The cost rail reads '—' until some day actually
* prices.
*/
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ReactElement } from 'react'
import { formatCost, type CostCurrency } from '../cost'
import { type DayTotals } from '../overview'
import { shiftDayKey } from '../../shared/days'
import type { ViewKit } from '../viewkit'
export interface OverviewUsageProps {
/** The merged daily ledger (day key → that day's figures). */
days: Record<string, DayTotals>
/** The display currency the day fees were priced in. */
currency: CostCurrency
/** The local today key (injected so specs pin the calendar). */
today: string
}
/** The chart's window: today and the six days before it, oldest first. */
export function usageWindowOf(today: string): string[] {
const columns: string[] = []
for (let d = -6; d <= 0; d++) {
const key = shiftDayKey(today, d)
if (key === null) return []
columns.push(key)
}
return columns
}
/** The shared gridline/tick positions, in percent of each series' peak. */
const TICKS: readonly number[] = [0, 25, 50, 75, 100]
export function makeOverviewUsage(kit: ViewKit): (props: OverviewUsageProps) => ReactElement {
const { t, fmt } = kit
return function OverviewUsage(props: OverviewUsageProps): ReactElement {
const window = usageWindowOf(props.today)
// Widened honestly: a Record index read can miss at runtime.
const byKey: Record<string, DayTotals | undefined> = props.days
const columns = window.map(key => ({ key, entry: byKey[key] }))
const empty = columns.every(c => c.entry === undefined || (c.entry.tokens <= 0 && c.entry.cost === null))
let maxTokens = 0
let maxCost = 0
for (const c of columns) {
if (c.entry === undefined) continue
if (c.entry.tokens > maxTokens) maxTokens = c.entry.tokens
if (c.entry.cost !== null && c.entry.cost > maxCost) maxCost = c.entry.cost
}
const heightOf = (value: number, max: number): string => (max <= 0 || value <= 0 ? '0%' : `${Math.max(4, Math.round((value / max) * 100))}%`)
const tickOf = (percent: number): string => fmt(Math.round((maxTokens * percent) / 100))
// '—' while nothing prices (an all-zero rail would read as free); the 0
// tick reads bare — formatCost's toPrecision renders the floor as the
// odd "¥0.0".
const costTickOf = (percent: number): string => {
if (maxCost <= 0) return '—'
return percent === 0 ? '0' : formatCost((maxCost * percent) / 100, props.currency)
}
return (
<div className="lc-card lc-ov-usage">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('ov.usage.title')}</span>
</div>
{empty || window.length === 0
? <div className="lc-empty">{t('ov.usage.empty')}</div>
: (
<>
<div className="lc-ov-usage-plot" role="group" aria-label={t('ov.usage.title')}>
<div className="lc-ov-usage-rail lc-ov-usage-rail-l" aria-hidden="true">
{TICKS.map(p => <span key={p} className="lc-ov-usage-tick" style={{ top: `${100 - p}%` }}>{tickOf(p)}</span>)}
</div>
<div className="lc-ov-usage-grid">
{TICKS.map(p => (
<span
key={p}
className={'lc-ov-usage-line' + (p === 0 ? ' lc-ov-usage-line-0' : '')}
style={{ bottom: `${p}%` }}
aria-hidden="true"
/>
))}
<div className="lc-ov-usage-cols">
{columns.map((c) => {
const tokens = c.entry?.tokens ?? 0
const cost = c.entry?.cost ?? null
// The tooltip line reads the BARE label — the legend's
// qualifier ("仅含支持计价映射的模型") stays off the bubble.
const label = `${c.key}\n${t('ov.usage.tokens')} ${fmt(tokens)}\n${t('ov.usage.costTip')} ${cost === null ? '—' : formatCost(cost, props.currency)}`
return (
<Tooltip key={c.key} label={label} side="top">
<div className="lc-ov-usage-col">
<div className="lc-ov-usage-bars">
<span
className="lc-ov-usage-bar lc-ov-usage-tokens"
style={{ height: heightOf(tokens, maxTokens) }}
/>
<span
className="lc-ov-usage-bar lc-ov-usage-cost"
style={{ height: cost === null ? '0%' : heightOf(cost, maxCost) }}
/>
</div>
</div>
</Tooltip>
)
})}
</div>
</div>
<div className="lc-ov-usage-rail lc-ov-usage-rail-r" aria-hidden="true">
{TICKS.map(p => <span key={p} className="lc-ov-usage-tick" style={{ top: `${100 - p}%` }}>{costTickOf(p)}</span>)}
</div>
</div>
{/* The day strip mirrors the columns' flex geometry (same flex
basis, same gap), so every label sits under its column. */}
<div className="lc-ov-usage-days" aria-hidden="true">
{columns.map(c => <span key={c.key} className="lc-ov-usage-day">{c.key.slice(5)}</span>)}
</div>
</>
)}
<div className="lc-ov-usage-key" aria-hidden="true">
<span className="lc-ov-usage-swatch lc-ov-usage-tokens" />
<span className="lc-ov-usage-key-label">{t('ov.usage.tokens')}</span>
<span className="lc-ov-usage-swatch lc-ov-usage-cost" />
<span className="lc-ov-usage-key-label">{t('ov.usage.cost')}</span>
</div>
</div>
)
}
}
+10
View File
@@ -259,6 +259,11 @@ export const DICT_ZH: Record<string, string> = {
'ov.kpi.toolSub': '工具执行 {dur}',
'ov.kpi.wallSub': '模型调用 {n}',
'ov.stats.empty': '统计范围内暂无 Token 用量',
'ov.usage.title': '近 7 天用量',
'ov.usage.tokens': 'Token',
'ov.usage.cost': '费用(仅含支持计价映射的模型)',
'ov.usage.costTip': '费用',
'ov.usage.empty': '近 7 天暂无用量 — 会话产生模型请求后,这里会显示每日 Token 与费用',
'balance.title': 'DeepSeek 余额',
'balance.tip.granted': '赠送余额',
'balance.tip.toppedUp': '充值余额',
@@ -551,6 +556,11 @@ export const DICT_EN: Record<string, string> = {
'ov.kpi.toolSub': 'tool runs {dur}',
'ov.kpi.wallSub': '{n} model calls',
'ov.stats.empty': 'No billed tokens in this range',
'ov.usage.title': 'Last 7 Days',
'ov.usage.tokens': 'Tokens',
'ov.usage.cost': 'Cost (price-mapped models only)',
'ov.usage.costTip': 'Cost',
'ov.usage.empty': 'No usage in the last 7 days — daily tokens and cost appear once sessions make model requests',
'balance.title': 'DeepSeek balance',
'balance.tip.granted': 'Granted balance',
'balance.tip.toppedUp': 'Topped-up balance',
+42 -12
View File
@@ -549,38 +549,68 @@ export function kpisOf(
}
}
/** One merged day of the daily ledgers: billed tokens, model requests, and the sessions active that day. */
/** One merged day of the daily ledgers: billed tokens, model requests, the sessions active that day, and the day's priced fee. */
export interface DayTotals {
tokens: number
requests: number
sessions: number
/**
* The day's estimated spend in the display currency (null: nothing priced —
* no pricing record folded, no book yet, or no model the book prices).
*/
cost: number | null
}
/**
* Merge every row's daily ledger into one — the heatmap's data. A session
* counts toward a day only when its own entry carries activity, mirroring
* the day filter's predicate, so the cell's tooltip previews the click; a
* zeroed entry is skipped whole. The merged record stays small even over
* long histories.
* Merge every row's daily ledger into one — the heatmap's and the usage
* chart's data. A session counts toward a day only when its own entry
* carries activity, mirroring the day filter's predicate, so the cell's
* tooltip previews the click; a zeroed entry is skipped whole. Each day's
* pricing records merge into one SessionCostUsage and price off the SAME
* book/estimator the KPI band's cost cell rides (null fee on anything
* unpriced). The merged record stays small even over long histories.
*/
export function aggregateDays(rows: readonly OverviewRow[]): Record<string, DayTotals> {
const days: Record<string, DayTotals> = {}
// Widened honestly: a Record index read can miss at runtime.
const byKey: Record<string, DayTotals | undefined> = days
export function aggregateDays(
rows: readonly OverviewRow[],
book: ModelBook | null | undefined,
currency: CostCurrency,
): Record<string, DayTotals> {
// The walk merges into a richer record (each day's fee raw material rides
// along), then every merged day prices once — no second lookup pass.
const merged: Record<string, { tokens: number; requests: number; sessions: number; fees: SessionCostUsage[] }> = {}
const byKey: Record<string, { tokens: number; requests: number; sessions: number; fees: SessionCostUsage[] } | undefined> = merged
for (const row of rows) {
if (row.activity === null) continue
for (const key of Object.keys(row.activity.days)) {
const entry = row.activity.days[key]
if (entry.tokens <= 0 && entry.requests <= 0) continue
const prev = byKey[key]
if (prev === undefined) days[key] = { tokens: entry.tokens, requests: entry.requests, sessions: 1 }
else {
if (prev === undefined) {
merged[key] = {
tokens: entry.tokens,
requests: entry.requests,
sessions: 1,
fees: entry.cost !== undefined ? [entry.cost] : [],
}
} else {
prev.tokens += entry.tokens
prev.requests += entry.requests
prev.sessions++
if (entry.cost !== undefined) prev.fees.push(entry.cost)
}
}
}
const days: Record<string, DayTotals> = {}
for (const [key, day] of Object.entries(merged)) {
// No fee records merge to null (nothing priced); records that merge but
// match no book price null the same way.
days[key] = {
tokens: day.tokens,
requests: day.requests,
sessions: day.sessions,
cost: estimateSessionCost(mergeCostUsage(...day.fees), book, currency),
}
}
return days
}
+61 -13
View File
@@ -1,12 +1,13 @@
/*
* The Context Insights panel: the sidebar-foot entry button (stacked above
* Settings by the harness's own foot layout) and the frame-wide overlay
* panel it opens — the KPI metrics band (the range's six figures in one
* full-width line) over the aggregate stats row (Token Stats | Timing
* Stats, the range's sessions folded into the per-session tab's two donut
* cards) over a 3:7 body of insight column (the activity heatmap, then the
* preferences entry row) beside the session column (search, chips, card
* grid).
* panel it opens — the first row (a 1:1 pair: the KPI metrics band, the
* range's six figures three per row, beside the last-7-days usage chart's
* token/cost bar columns) over the aggregate stats row (Token Stats |
* Timing Stats, the range's sessions folded into the per-session tab's two
* donut cards) over a 3:7 body of insight column (the activity heatmap,
* then the preferences entry row) beside the session column (search, chips,
* card grid).
* The backdrop is the `lc-ov` query container (it spans the viewport, and
* the card sizes off the viewport too), so the column fold keys off the
* viewport width; on the folded width the card itself scrolls in normal
@@ -52,20 +53,67 @@
.lc-ov-balance-value { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
.lc-ov-head .lc-modal-close { margin-left: 0; flex: none; }
/* ---- the KPI metrics band: the panel's first row — the range's six figures
in one full-width line. Under width pressure it folds 6 → 3×2 (a half-card
still reads every English sub-line) → 2×3 (phone), so no cell ever
/* ---- the panel's first row: a 1:1 column pair — the KPI metrics band
(the range's six figures, three per row — the row pair is the tallened
first row) beside the last-7-days usage chart, whose card
stretches to the same height. The pair folds to one full-width column at
932px of container (the body's own fold point, below which a half-width
chart's seven day groups would crush); the KPI grid then spreads the
full width, and drops to 2×3 at 640px (phone), so no cell ever
ellipsizes its figure. ---- */
.lc-ov-kpis { flex: none; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.lc-ov-kpi { align-items: center; padding: 10px 8px; }
.lc-ov-first { flex: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
.lc-ov-kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.lc-ov-kpi { align-items: center; padding: 8px; }
.lc-ov-kpi .lc-stat-value { font-size: 20px; }
@container lc-ov (max-width: 1000px) {
.lc-ov-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@container lc-ov (max-width: 932px) {
.lc-ov-first { grid-template-columns: minmax(0, 1fr); }
}
@container lc-ov (max-width: 640px) {
.lc-ov-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ---- the first row's usage chart: seven day columns, each pairing a
billed-tokens bar (the heatmap's blue) over a priced-cost bar (green),
both folded off the merged daily ledger. The bars scale on their OWN
window maxima — a $2 week must not flatten under a 169M-token week — and
the plot is the card's flex remainder, so the card fills its half of the
first row whatever the KPI grid's wrapped height. A 4% floor keeps a
tiny day visible; a 0/unpriced figure draws no bar at all. Hairline
gridlines run at 0/25/50/75/100% of the peak, the 0 line solid as the
baseline, and each side carries a tick rail computed off the same maxima
(tokens left, cost right — the cost rail reads '—' while nothing
prices). ---- */
.lc-ov-usage { flex: 1 1 auto; display: flex; flex-direction: column; margin-bottom: 0; min-width: 0; box-sizing: border-box; }
.lc-ov-usage .lc-card-title { margin-bottom: 4px; }
.lc-ov-usage-plot { flex: 1 1 auto; display: flex; align-items: stretch; gap: 6px; min-height: 0; }
/* The rails hold absolutely-positioned ticks (top = 100% peak, bottom = 0);
a fixed width keeps both sides symmetric and the columns' geometry stable
across tick widths ("169.2M" vs "$0.0075"). */
.lc-ov-usage-rail { position: relative; flex: none; width: 42px; font-size: 9px; line-height: 1.2; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-ov-usage-rail-l .lc-ov-usage-tick { right: 0; }
.lc-ov-usage-rail-r .lc-ov-usage-tick { left: 0; }
.lc-ov-usage-tick { position: absolute; transform: translateY(-50%); white-space: nowrap; }
/* The grid spans the bars' region only — the day strip renders as its own
row below, so the gridlines never cross the labels. The min-height floor
keeps percentage-height bars alive on the folded single-column row, where
the card sizes to its own content. */
.lc-ov-usage-grid { position: relative; flex: 1 1 auto; min-width: 0; min-height: 56px; display: flex; }
.lc-ov-usage-line { position: absolute; left: 0; right: 0; height: 1px; background: color-mix(in srgb, var(--dsw-alias-border-l2) 70%, transparent); }
.lc-ov-usage-line-0 { background: var(--dsw-alias-border-l2); }
.lc-ov-usage-cols { position: relative; flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 6px; }
.lc-ov-usage-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.lc-ov-usage-bars { flex: 1 1 auto; width: 100%; display: flex; align-items: flex-end; justify-content: center; gap: 3px; }
.lc-ov-usage-bar { flex: none; width: 10px; border-radius: 3px 3px 0 0; }
.lc-ov-usage-tokens { background: color-mix(in srgb, var(--color-blue-500) 72%, transparent); }
.lc-ov-usage-cost { background: color-mix(in srgb, var(--color-green-500) 72%, transparent); }
/* Same flex geometry as .lc-ov-usage-cols, so every label centers under its
own column. */
.lc-ov-usage-days { flex: none; display: flex; gap: 6px; padding-top: 3px; }
.lc-ov-usage-day { flex: 1 1 0; min-width: 0; text-align: center; font-size: 9px; line-height: 1.2; color: var(--dsw-alias-label-secondary); white-space: nowrap; font-variant-numeric: tabular-nums; }
.lc-ov-usage-key { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 5px; padding-top: 5px; }
.lc-ov-usage-swatch { flex: none; width: 10px; height: 8px; border-radius: 2px; }
.lc-ov-usage-key-label { font-size: 9px; color: var(--dsw-alias-label-secondary); }
/* ---- the aggregate stats row: Token Stats beside Timing Stats between the
KPI band and the 3:7 body. Two half-width cards; the pair folds to one
column at 700px of container — below the body's own 932px fold, where
+17 -17
View File
@@ -47,20 +47,20 @@ describe('Heatmap', () => {
})
test('a corrupt today key degrades to the empty note', async () => {
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 5, requests: 1, sessions: 1 } }, today: 'junk' }))
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 5, requests: 1, sessions: 1, cost: null } }, today: 'junk' }))
assert.ok(text(m.container).includes('No activity yet'))
await m.unmount()
})
test('cells draw with depth levels; data days are buttons with labels', async () => {
const days = {
'2026-09-16': { tokens: 100, requests: 4, sessions: 2 },
'2026-09-15': { tokens: 50, requests: 2, sessions: 1 },
'2026-09-14': { tokens: 25, requests: 1, sessions: 1 },
'2026-09-10': { tokens: 30, requests: 0, sessions: 1 },
'2026-09-09': { tokens: 75, requests: 3, sessions: 3 },
'2026-09-08': { tokens: 1, requests: 1, sessions: 1 },
'2026-09-07': { tokens: 0, requests: 2, sessions: 1 },
'2026-09-16': { tokens: 100, requests: 4, sessions: 2, cost: null },
'2026-09-15': { tokens: 50, requests: 2, sessions: 1, cost: null },
'2026-09-14': { tokens: 25, requests: 1, sessions: 1, cost: null },
'2026-09-10': { tokens: 30, requests: 0, sessions: 1, cost: null },
'2026-09-09': { tokens: 75, requests: 3, sessions: 3, cost: null },
'2026-09-08': { tokens: 1, requests: 1, sessions: 1, cost: null },
'2026-09-07': { tokens: 0, requests: 2, sessions: 1, cost: null },
}
const m = await mount(h(Heatmap, { days, today: TODAY, weeks: 2 }))
const buttons = queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
@@ -90,7 +90,7 @@ describe('Heatmap', () => {
})
test('month labels mark the columns where a month begins', async () => {
const days = { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } }
const days = { '2026-09-16': { tokens: 10, requests: 1, sessions: 1, cost: null } }
// The default 8-week window runs Sunday 2026-07-26 → 2026-09-13: July
// began outside it, August begins in the Jul 26 column (Sat Aug 1) and
// September in the Aug 30 column (Tue Sep 1).
@@ -104,13 +104,13 @@ describe('Heatmap', () => {
await m2.unmount()
// A later column that opens on the 1st carries the label itself (its
// whole week stays in the new month; 2026-02-01 was a Sunday).
const m3 = await mount(h(Heatmap, { days: { '2026-02-03': { tokens: 1, requests: 1, sessions: 1 } }, today: '2026-02-03', weeks: 2 }))
const m3 = await mount(h(Heatmap, { days: { '2026-02-03': { tokens: 1, requests: 1, sessions: 1, cost: null } }, today: '2026-02-03', weeks: 2 }))
assert.deepEqual(queryAll(m3.container, '.lc-heat-mon').map(s => s.textContent), ['Jan', 'Feb'])
await m3.unmount()
})
test('a Less→More key lays out the five depth steps under the grid', async () => {
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } }, today: TODAY, weeks: 2 }))
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1, cost: null } }, today: TODAY, weeks: 2 }))
const legend = query(m.container, '.lc-heat-legend')
assert.deepEqual(queryAll(legend, '.lc-heat-cell').map(s => s.className), [
'lc-heat-cell lc-heat-0',
@@ -125,8 +125,8 @@ describe('Heatmap', () => {
test('the sessions metric depths on each day’s distinct sessions, independently of steps', async () => {
const days = {
'2026-09-16': { tokens: 100, requests: 4, sessions: 2 },
'2026-09-09': { tokens: 75, requests: 1, sessions: 3 },
'2026-09-16': { tokens: 100, requests: 4, sessions: 2, cost: null },
'2026-09-09': { tokens: 75, requests: 1, sessions: 3, cost: null },
}
const m = await mount(h(Heatmap, { days, today: TODAY, weeks: 2, metric: 'sessions' }))
const byKey = new Map(queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell').map(b => [b.getAttribute('aria-label')?.split('\n')[0], b]))
@@ -137,7 +137,7 @@ describe('Heatmap', () => {
test('cells tip through the harness Tooltip: the bubble mounts on hover and drops on leave', async () => {
const m = await mount(h(Heatmap, {
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 3 } },
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 3, cost: null } },
today: TODAY,
weeks: 2,
}))
@@ -156,7 +156,7 @@ describe('Heatmap', () => {
const picked: (string | null)[] = []
const record = (day: string | null): void => { picked.push(day) }
const m = await mount(h(Heatmap, {
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } },
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1, cost: null } },
today: TODAY,
weeks: 2,
selected: null,
@@ -166,7 +166,7 @@ describe('Heatmap', () => {
await click(cell)
assert.deepEqual(picked, ['2026-09-16'])
await m.update(h(Heatmap, {
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } },
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1, cost: null } },
today: TODAY,
weeks: 2,
selected: '2026-09-16',
@@ -180,7 +180,7 @@ describe('Heatmap', () => {
})
test('without an onSelect relay the cells stay inert', async () => {
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } }, today: TODAY, weeks: 2 }))
const m = await mount(h(Heatmap, { days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 1, cost: null } }, today: TODAY, weeks: 2 }))
const cell = query<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
await click(cell)
assert.ok(!cell.className.includes('lc-heat-on'))
@@ -139,10 +139,14 @@ describe('OverviewPanel', () => {
assert.equal(pulls, 1, 'the baseline re-pull fires on open')
assert.deepEqual(backfillPosts, ['/api/dsh-context/backfill'], 'the warm-up trigger POST fires on open')
assert.ok(text(m.container).includes('Context Insights'))
// The metrics band is the panel's FIRST row: the six KPI cells span the
// card above the aggregate stats pair, out of the insight column.
// The panel's first row is the 1:1 pair: the KPI band (the six cells,
// three per row) beside the last-7-days usage chart, above the aggregate
// stats pair and out of the insight column.
const cardRows = [...query(m.container, '.lc-ov-card').children].map(el => el.className.split(' ')[0])
assert.deepEqual(cardRows, ['lc-ov-head', 'lc-ov-kpis', 'lc-ov-stats', 'lc-ov-body'])
assert.deepEqual(cardRows, ['lc-ov-head', 'lc-ov-first', 'lc-ov-stats', 'lc-ov-body'])
// The pair's halves: the KPI band's six cells, then the usage chart.
assert.equal(queryAll(m.container, '.lc-ov-first > .lc-ov-kpis .lc-stat').length, 6)
assert.ok(query(m.container, '.lc-ov-first > .lc-card.lc-ov-usage') !== null, 'the usage chart rides the row')
// KPI band: 2 sessions in the 30d range, 1750 tokens billed, priced cost, cache hit.
const labels = queryAll(m.container, '.lc-stat-label').map(el => el.textContent)
assert.deepEqual(labels, ['Active Sessions', 'Tokens Used', 'Cost', 'Cache Hit', 'Tool Calls', 'Active Time'])
@@ -0,0 +1,139 @@
// The first row's usage chart (src/client/components/overviewUsage.tsx):
// the fixed last-7-days window off the injected today key, the two series'
// independent maxima, bar heights, the tick rails and gridlines framed off
// the same maxima, unpriced/zero days, and the empty and degraded paths —
// rendered with real React.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeOverviewUsage, usageWindowOf } from '../../../src/client/components/overviewUsage'
import type { DayTotals } from '../../../src/client/overview'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
const OverviewUsage = makeOverviewUsage(kit)
// 2026-09-16 was a Wednesday; the window is its seven-day tail.
const TODAY = '2026-09-16'
function day(over: Partial<DayTotals> = {}): DayTotals {
return { tokens: 0, requests: 0, sessions: 0, cost: null, ...over }
}
describe('usageWindowOf', () => {
test('walks today and the six days before it, oldest first', () => {
assert.deepEqual(usageWindowOf(TODAY), [
'2026-09-10', '2026-09-11', '2026-09-12', '2026-09-13', '2026-09-14', '2026-09-15', '2026-09-16',
])
})
test('a corrupt today key reads as an empty window (the card degrades to its note)', () => {
assert.deepEqual(usageWindowOf('junk'), [])
})
})
describe('OverviewUsage', () => {
test('an all-empty window renders the empty note, no bars', async () => {
const m = await mount(h(OverviewUsage, { days: {}, currency: 'usd', today: TODAY }))
assert.ok(text(m.container).includes('No usage in the last 7 days'))
assert.equal(queryAll(m.container, '.lc-ov-usage-bar').length, 0)
await m.unmount()
})
test('a corrupt today key degrades to the empty note', async () => {
const m = await mount(h(OverviewUsage, { days: { '2026-09-16': day({ tokens: 5 }) }, currency: 'usd', today: 'junk' }))
assert.ok(text(m.container).includes('No usage in the last 7 days'))
await m.unmount()
})
test('seven columns draw, out-of-window days are ignored, and the key names both series', async () => {
const m = await mount(h(OverviewUsage, {
days: {
'2026-09-16': day({ tokens: 1200, cost: 0.5 }),
'2026-09-10': day({ tokens: 300 }),
'2026-09-09': day({ tokens: 999_999 }),
'2026-09-01': day({ tokens: 50_000_000 }),
},
currency: 'usd',
today: TODAY,
}))
assert.equal(queryAll(m.container, '.lc-ov-usage-col').length, 7)
const days = queryAll(m.container, '.lc-ov-usage-day').map(el => el.textContent)
assert.deepEqual(days, ['09-10', '09-11', '09-12', '09-13', '09-14', '09-15', '09-16'])
assert.ok(text(m.container).includes('Tokens'))
assert.ok(text(m.container).includes('Cost'))
await m.unmount()
})
test('bar heights scale on each series’ OWN window maximum, with a floor for tiny days', async () => {
const m = await mount(h(OverviewUsage, {
days: {
'2026-09-16': day({ tokens: 1000, cost: 1 }),
'2026-09-15': day({ tokens: 100, cost: 0.5 }),
'2026-09-14': day({ tokens: 1, cost: 0.01 }),
},
currency: 'usd',
today: TODAY,
}))
const heightOf = (col: number, cls: string): string =>
(queryAll(m.container, '.lc-ov-usage-col')[col].querySelector('.' + cls) as HTMLElement).style.height
// Columns run oldest first: 09-16 (col 6) holds both series' maxima,
// 09-15 (col 5) half-ish, 09-14 (col 4) the slivers under the 4% floor.
assert.equal(heightOf(6, 'lc-ov-usage-tokens'), '100%')
assert.equal(heightOf(5, 'lc-ov-usage-tokens'), '10%')
assert.equal(heightOf(4, 'lc-ov-usage-tokens'), '4%')
// Cost scales independently: 1 → 100%, 0.5 → 50%, 0.01 → 4%.
assert.equal(heightOf(6, 'lc-ov-usage-cost'), '100%')
assert.equal(heightOf(5, 'lc-ov-usage-cost'), '50%')
assert.equal(heightOf(4, 'lc-ov-usage-cost'), '4%')
await m.unmount()
})
test('the frame: five gridlines with a solid baseline, and tick rails off the same maxima', async () => {
const m = await mount(h(OverviewUsage, {
days: {
'2026-09-16': day({ tokens: 1000, cost: 1 }),
'2026-09-15': day({ tokens: 250, cost: 0.25 }),
},
currency: 'usd',
today: TODAY,
}))
// Five hairlines, the 0 one solid (the baseline).
assert.equal(queryAll(m.container, '.lc-ov-usage-line').length, 5)
assert.ok(query(m.container, '.lc-ov-usage-line-0') !== null)
const baseline = query(m.container, '.lc-ov-usage-line-0') as HTMLElement
assert.equal(baseline.style.bottom, '0%')
// Left rail: the tokens peak's quarters; right rail: the cost peak's.
const left = queryAll(m.container, '.lc-ov-usage-rail-l .lc-ov-usage-tick').map(el => el.textContent)
const right = queryAll(m.container, '.lc-ov-usage-rail-r .lc-ov-usage-tick').map(el => el.textContent)
assert.deepEqual(left, ['0', '250', '500', '750', '1.0k'])
assert.deepEqual(right, ['0', '$0.25', '$0.50', '$0.75', '$1.00'])
await m.unmount()
})
test('an unpriced window reads the cost rail as dashes; the bars stay', async () => {
const m = await mount(h(OverviewUsage, {
days: { '2026-09-16': day({ tokens: 500, cost: null }) },
currency: 'usd',
today: TODAY,
}))
const right = queryAll(m.container, '.lc-ov-usage-rail-r .lc-ov-usage-tick').map(el => el.textContent)
assert.deepEqual(right, ['—', '—', '—', '—', '—'])
const col = queryAll(m.container, '.lc-ov-usage-col')[6]
assert.equal((col.querySelector('.lc-ov-usage-tokens') as HTMLElement).style.height, '100%')
assert.equal((col.querySelector('.lc-ov-usage-cost') as HTMLElement).style.height, '0%')
await m.unmount()
})
test('a priced-but-free day draws no bars yet keeps the column (not the empty note)', async () => {
const m = await mount(h(OverviewUsage, {
days: { '2026-09-16': day({ tokens: 0, requests: 2, cost: 0 }) },
currency: 'usd',
today: TODAY,
}))
assert.equal(queryAll(m.container, '.lc-ov-usage-col').length, 7)
assert.equal(text(m.container).includes('No usage in the last 7 days'), false)
await m.unmount()
})
})
+31 -6
View File
@@ -512,15 +512,18 @@ describe('kpisOf', () => {
})
describe('aggregateDays', () => {
const book = { prices: { deepseek: { 'deepseek-v4': { hit: 0.1, miss: 1, write: 1, out: 2 } } }, index: priceIndexOf({ deepseek: { 'deepseek-v4': { hit: 0.1, miss: 1, write: 1, out: 2 } } }, {}) }
const FEE: SessionCostUsage = { deepseek: { 'deepseek-v4': { peak: { uncached: 100, cacheRead: 50, cacheWrite: 0, output: 40 } } } }
test('merges every row’s ledger, skipping rows without one', () => {
const rows = [
rowOf({ activity: { days: { '2026-09-16': { tokens: 5, requests: 1 }, '2026-09-15': { tokens: 2, requests: 2 } } } }),
rowOf({ activity: { days: { '2026-09-16': { tokens: 7, requests: 3 } } } }),
rowOf(),
]
assert.deepEqual(aggregateDays(rows), {
'2026-09-16': { tokens: 12, requests: 4, sessions: 2 },
'2026-09-15': { tokens: 2, requests: 2, sessions: 1 },
assert.deepEqual(aggregateDays(rows, book, 'usd'), {
'2026-09-16': { tokens: 12, requests: 4, sessions: 2, cost: null },
'2026-09-15': { tokens: 2, requests: 2, sessions: 1, cost: null },
})
})
@@ -529,11 +532,33 @@ describe('aggregateDays', () => {
rowOf({ activity: { days: { '2026-09-16': { tokens: 0, requests: 0 }, '2026-09-15': { tokens: 3, requests: 1 } } } }),
rowOf({ activity: { days: { '2026-09-16': { tokens: 1, requests: 1 } } } }),
]
assert.deepEqual(aggregateDays(rows), {
'2026-09-16': { tokens: 1, requests: 1, sessions: 1 },
'2026-09-15': { tokens: 3, requests: 1, sessions: 1 },
assert.deepEqual(aggregateDays(rows, book, 'usd'), {
'2026-09-16': { tokens: 1, requests: 1, sessions: 1, cost: null },
'2026-09-15': { tokens: 3, requests: 1, sessions: 1, cost: null },
})
})
test('each day’s pricing records merge and price off the book; unpriced days stay null', () => {
const rows = [
rowOf({ activity: { days: {
'2026-09-16': { tokens: 5, requests: 1, cost: FEE },
'2026-09-15': { tokens: 2, requests: 2 },
} } }),
rowOf({ activity: { days: { '2026-09-16': { tokens: 7, requests: 3, cost: { deepseek: { 'deepseek-v4': { peak: { uncached: 1, cacheRead: 0, cacheWrite: 0, output: 1 } } } } } } } }),
rowOf({ activity: { days: { '2026-09-14': { tokens: 9, requests: 1, cost: { openai: { 'gpt-5': { peak: { uncached: 10, cacheRead: 0, cacheWrite: 0, output: 1 } } } } } } } }),
]
const days = aggregateDays(rows, book, 'usd')
// The DeepSeek peak buckets double the list price: (100·1 + 50·0.1 + 40·2)·2/1e6 plus the
// second session's (1·1 + 1·2)·2/1e6.
assert.ok(Math.abs((days['2026-09-16'].cost ?? 0) - 376e-6) < 1e-12)
assert.equal(days['2026-09-15'].cost, null, 'a day without pricing records prices to nothing')
assert.equal(days['2026-09-14'].cost, null, 'a model the book cannot price prices to nothing')
})
test('a day with no book prices to null', () => {
const rows = [rowOf({ activity: { days: { '2026-09-16': { tokens: 5, requests: 1, cost: FEE } } } })]
assert.equal(aggregateDays(rows, null, 'cny')['2026-09-16'].cost, null)
})
})
describe('relativeTime', () => {