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 => ( +
+ +
+ {/* The day strip mirrors the columns' flex geometry (same flex + basis, same gap), so every label sits under its column. */} + + + )} + +
+ ) + } +} 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', () => {