diff --git a/src/client/components/overviewPanel.tsx b/src/client/components/overviewPanel.tsx
index 816b201..2d9651f 100644
--- a/src/client/components/overviewPanel.tsx
+++ b/src/client/components/overviewPanel.tsx
@@ -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
{t('ov.unavailable')}
) : (
<>
- {/* The panel's first row: the KPI metrics band — the range's six
- figures in one full-width line. */}
-
-
- {t('ov.kpi.sessions')}
- {kpi.sessions}
- {t('ov.kpi.ofTotal', { n: kpi.listed })}
-
-
- {t('ov.kpi.tokens')}
- {fmt(kpi.tokens)}
- {t('stats.turns')} {fmt(kpi.turns)}
-
-
- {t('stats.cost')}
- {kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}
- {t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}
-
-
- {t('stats.cacheHit')}
- {kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}
- {t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}
-
-
- {t('stats.toolCalls')}
- {fmt(kpi.toolCalls)}
- {t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}
-
-
-
{t('timing.total')}
-
{fmtDuration(kpi.wallMs)}
-
{t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}
+ {/* 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). */}
+
+
+
+ {t('ov.kpi.sessions')}
+ {kpi.sessions}
+ {t('ov.kpi.ofTotal', { n: kpi.listed })}
+
+
+ {t('ov.kpi.tokens')}
+ {fmt(kpi.tokens)}
+ {t('stats.turns')} {fmt(kpi.turns)}
+
+
+ {t('stats.cost')}
+ {kpi.cost === null ? '—' : formatCost(kpi.cost, currency)}
+ {t('ov.kpi.pricedSub', { n: kpi.costSessions, total: kpi.sessions })}
+
+
+ {t('stats.cacheHit')}
+ {kpi.cacheHit === null ? '—' : kpi.cacheHit + '%'}
+ {t('ov.kpi.sessionsSub', { n: kpi.usageSessions })}
+
+
+ {t('stats.toolCalls')}
+ {fmt(kpi.toolCalls)}
+ {t('ov.kpi.toolSub', { dur: fmtDuration(kpi.toolsMs) })}
+
+
+ {t('timing.total')}
+ {fmtDuration(kpi.wallMs)}
+ {t('ov.kpi.wallSub', { n: fmt(kpi.calls) })}
+
+
{/* The aggregate stats row: the range's Token Stats and Timing
Stats, folded over the KPI band's own scope (the composition
diff --git a/src/client/components/overviewUsage.tsx b/src/client/components/overviewUsage.tsx
new file mode 100644
index 0000000..1c1d0c2
--- /dev/null
+++ b/src/client/components/overviewUsage.tsx
@@ -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
+ /** 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 = 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 (
+
+
+ {t('ov.usage.title')}
+
+ {empty || window.length === 0
+ ?
{t('ov.usage.empty')}
+ : (
+ <>
+
+
+ {TICKS.map(p => {tickOf(p)})}
+
+
+ {TICKS.map(p => (
+
+ ))}
+
+ {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 (
+
+
+
+ )
+ })}
+
+
+
+ {TICKS.map(p => {costTickOf(p)})}
+
+
+ {/* The day strip mirrors the columns' flex geometry (same flex
+ basis, same gap), so every label sits under its column. */}
+
+ {columns.map(c => {c.key.slice(5)})}
+
+ >
+ )}
+
+
+ {t('ov.usage.tokens')}
+
+ {t('ov.usage.cost')}
+
+
+ )
+ }
+}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index afcacf8..7e06b13 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -259,6 +259,11 @@ export const DICT_ZH: Record = {
'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 = {
'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',
diff --git a/src/client/overview.ts b/src/client/overview.ts
index bc58b12..8363332 100644
--- a/src/client/overview.ts
+++ b/src/client/overview.ts
@@ -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 {
- const days: Record = {}
- // Widened honestly: a Record index read can miss at runtime.
- const byKey: Record = days
+export function aggregateDays(
+ rows: readonly OverviewRow[],
+ book: ModelBook | null | undefined,
+ currency: CostCurrency,
+): Record {
+ // 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 = {}
+ const byKey: Record = 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 = {}
+ 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
}
diff --git a/src/client/styles/overview.css b/src/client/styles/overview.css
index 57a24fd..f05c35b 100644
--- a/src/client/styles/overview.css
+++ b/src/client/styles/overview.css
@@ -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
diff --git a/tests/client/components/heatmap.spec.ts b/tests/client/components/heatmap.spec.ts
index e96c012..27f455f 100644
--- a/tests/client/components/heatmap.spec.ts
+++ b/tests/client/components/heatmap.spec.ts
@@ -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(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(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(m.container, 'button.lc-heat-cell')
await click(cell)
assert.ok(!cell.className.includes('lc-heat-on'))
diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts
index 8cae9a8..9894c3d 100644
--- a/tests/client/components/overviewPanel.spec.ts
+++ b/tests/client/components/overviewPanel.spec.ts
@@ -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'])
diff --git a/tests/client/components/overviewUsage.spec.ts b/tests/client/components/overviewUsage.spec.ts
new file mode 100644
index 0000000..213c4dd
--- /dev/null
+++ b/tests/client/components/overviewUsage.spec.ts
@@ -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 {
+ 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()
+ })
+})
diff --git a/tests/client/overview.spec.ts b/tests/client/overview.spec.ts
index 8cb1e60..7501509 100644
--- a/tests/client/overview.spec.ts
+++ b/tests/client/overview.spec.ts
@@ -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', () => {