feat(client): price the activity heatmap by sessions or steps

The card's depth always rode billed tokens; now a Sessions/Steps toggle
in the card's top-right corner (default steps) picks the metric, and the
day tooltip gains a steps line beside the sessions count. Sessions and
steps shape the window's days on different spans - one long session
drops a step a day, another day stacks a run - so the join tracks an
independent window maximum per metric and never scales one off the
other's peak. A billed day with no settled step (a legal wire shape)
stays gray under steps yet still tips and pins. The toggle rides the
shared lc-gran idiom inside a lc-heat-ctl wrapper - one auto margin for
the hint text plus the group - and specs cover both scales plus the
panel-level switch.
This commit is contained in:
bowenliang123 committed 2026-09-24 16:37:43 +08:00
1 parent bd112d54ee
commit 41452fc50a
6 files changed
+96 -19

No files matched your search

+25 -8
View File
@@ -3,12 +3,16 @@
* (weeks as columns, Sunday-first weekdays as rows, a month label over each
* column where a month begins — the window's opening column always labeled —
* and a Less→More key for the depth steps) over the merged daily
* ledger (overview.ts). Cell depth is the day's billed-token share of the
* window's maximum, in four steps; a day with data is a button whose click
* ledger (overview.ts). Cell depth is the selected metric's share of the
* window's maximum — distinct active sessions or settlement steps; a session
* can spread its steps thinly across many days while another day stacks a
* run, so each metric scales on its OWN maximum — in four steps; a day with
* data is a button whose click
* pins the session list to that day (click again to release). Cells tip
* through the harness's own Tooltip primitive (instant on hover; native
* `title` lags a second behind), the bubble carrying the date and the day's
* active sessions. The grid is computed from the injected `today` key, so
* `title` lags a second behind), the bubble carrying the date, the day's
* active sessions, and its steps. The grid is computed from the injected
* `today` key, so
* the layout is deterministic in tests and follows the browser's local
* calendar at runtime.
*/
@@ -19,11 +23,16 @@ import { dayKeyOf, shiftDayKey, sundayOfWeek } from '../../shared/days'
import { type DayTotals } from '../overview'
import type { ViewKit } from '../viewkit'
/** The heatmap's depth metric: the day's distinct active sessions or its settlement steps. */
export type HeatMetric = 'sessions' | 'steps'
export interface HeatmapProps {
/** Merged ledger (day key → that day's figures). */
days: Record<string, DayTotals>
/** Columns to draw (default 8 — two months). */
weeks?: number
/** The metric pricing the cells' depth (default steps). */
metric?: HeatMetric
/** The pinned day key, when the list is filtered to a day. */
selected?: string | null
/** Day-pin relay; a data-less day is inert. */
@@ -82,12 +91,14 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
const { t } = kit
return function Heatmap(props: HeatmapProps): ReactElement {
const weeks = props.weeks ?? 8
const metric = props.metric ?? 'steps'
const columns = gridOf(props.today, weeks)
if (columns === null) return <div className="lc-empty">{t('ov.heat.empty')}</div>
// Join the ledger onto the grid and price the depth scale. The record is
// widened honestly: a day-key read can miss at runtime.
const byKey: Record<string, DayTotals | undefined> = props.days
let max = 0
let maxSessions = 0
let maxSteps = 0
let any = false
for (const column of columns) {
for (const cell of column) {
@@ -95,10 +106,16 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
if (entry === undefined || (entry.tokens <= 0 && entry.requests <= 0)) continue
cell.entry = entry
any = true
if (entry.tokens > max) max = entry.tokens
// Independent maxima: sessions and steps shape the window's days
// differently (one long session drops a step a day; another day
// stacks a run), so neither metric scales off the other's peak.
if (entry.sessions > maxSessions) maxSessions = entry.sessions
if (entry.requests > maxSteps) maxSteps = entry.requests
}
}
if (!any) return <div className="lc-empty">{t('ov.heat.empty')}</div>
const max = metric === 'sessions' ? maxSessions : maxSteps
const metricValueOf = (entry: DayTotals): number => (metric === 'sessions' ? entry.sessions : entry.requests)
// Keyed BY the Sunday-first row index (0=Sun..6=Sat) — the label sits on
// the row it names, and no row arithmetic can shift it.
const weekdayLabels: Record<number, string> = { 1: t('ov.heat.wd.1'), 3: t('ov.heat.wd.3'), 5: t('ov.heat.wd.5') }
@@ -123,7 +140,7 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
{opens && <span className="lc-heat-mon" aria-hidden="true">{t('ov.heat.mon.' + column[6].key.slice(5, 7))}</span>}
{column.map((cell) => {
if (cell.future) return <span key={cell.key} className="lc-heat-cell lc-heat-future" aria-hidden="true" />
const level = cell.entry === undefined ? 0 : levelOf(cell.entry.tokens, max)
const level = cell.entry === undefined ? 0 : levelOf(metricValueOf(cell.entry), max)
if (cell.entry === undefined) {
return (
<Tooltip key={cell.key} label={cell.key} side="top">
@@ -132,7 +149,7 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
)
}
const picked = props.selected === cell.key
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}`
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}\n${t('ov.heat.steps', { n: cell.entry.requests })}`
return (
<Tooltip key={cell.key} label={label} side="top">
<button
+21 -3
View File
@@ -31,7 +31,7 @@ import type { ViewKit } from '../viewkit'
import { makeBalanceCapsule } from './balanceCapsule'
import { makeErrorBoundary } from './errorBoundary'
import { useEscapeClose } from './escapeClose'
import { makeHeatmap, todayKey } from './heatmap'
import { makeHeatmap, todayKey, type HeatMetric } from './heatmap'
import { ContextIcon } from '../icon'
import { makeOverviewCard } from './overviewCard'
@@ -44,6 +44,8 @@ export interface OverviewPanelProps {
const RANGES: readonly OverviewRange[] = ['24h', '7d', '30d', 'all']
const SORTS: readonly OverviewSort[] = ['recent', 'tokens', 'context']
/** The heatmap's depth metrics, in toggle order (steps is the default). */
const METRICS: readonly HeatMetric[] = ['sessions', 'steps']
export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: OverviewPanelProps) => ReactElement | null {
const { t, fmtDuration } = kit
@@ -70,6 +72,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
const [query, setQuery] = useState('')
const [group, setGroup] = useState<string | null>(null)
const [sort, setSort] = useState<OverviewSort>('recent')
const [metric, setMetric] = useState<HeatMetric>('steps')
const [page, setPage] = useState(0)
const close = (): void => { overviewStore.set(false) }
useEscapeClose(open, close)
@@ -181,9 +184,24 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
<div className="lc-card lc-ov-heat-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('ov.heat.title')}</span>
<span className="lc-card-sub">{t('ov.heat.sub')}</span>
{/* One wrapper so the right side pushes with a single auto
margin (two bare auto-margin siblings would split the
free space and drift apart). */}
<span className="lc-heat-ctl">
<span className="lc-card-sub">{t('ov.heat.sub')}</span>
<div className="lc-gran" role="group" aria-label={t('ov.heat.metric')}>
{METRICS.map(m => (
<button
key={m}
type="button"
className={'lc-gran-btn' + (metric === m ? ' lc-gran-on' : '')}
onClick={() => { setMetric(m) }}
>{t('ov.heat.metric.' + m)}</button>
))}
</div>
</span>
</div>
<Heatmap days={days} selected={day} onSelect={setDay} today={todayKey()} />
<Heatmap days={days} metric={metric} selected={day} onSelect={setDay} today={todayKey()} />
</div>
</div>
+4
View File
@@ -265,6 +265,8 @@ export const DICT_ZH: Record<string, string> = {
'ov.heat.empty': '暂无活跃数据 — 会话产生模型请求后,这里会显示每日用量',
'ov.heat.sessions': '{n} 个活跃会话',
'ov.heat.less': '少', 'ov.heat.more': '多',
'ov.heat.metric': '指标', 'ov.heat.metric.sessions': '会话', 'ov.heat.metric.steps': '步数',
'ov.heat.steps': '{n} 步',
'ov.heat.wd.1': '星期一', 'ov.heat.wd.3': '星期三', 'ov.heat.wd.5': '星期五',
'ov.heat.mon.01': '1月', 'ov.heat.mon.02': '2月', 'ov.heat.mon.03': '3月',
'ov.heat.mon.04': '4月', 'ov.heat.mon.05': '5月', 'ov.heat.mon.06': '6月',
@@ -553,6 +555,8 @@ export const DICT_EN: Record<string, string> = {
'ov.heat.empty': 'No activity yet — daily usage appears once sessions make model requests',
'ov.heat.sessions': '{n} active sessions',
'ov.heat.less': 'Less', 'ov.heat.more': 'More',
'ov.heat.metric': 'Metric', 'ov.heat.metric.sessions': 'Sessions', 'ov.heat.metric.steps': 'Steps',
'ov.heat.steps': '{n} steps',
'ov.heat.wd.1': 'Mon', 'ov.heat.wd.3': 'Wed', 'ov.heat.wd.5': 'Fri',
'ov.heat.mon.01': 'Jan', 'ov.heat.mon.02': 'Feb', 'ov.heat.mon.03': 'Mar',
'ov.heat.mon.04': 'Apr', 'ov.heat.mon.05': 'May', 'ov.heat.mon.06': 'Jun',
+4 -2
View File
@@ -66,8 +66,10 @@
.lc-card-title-text { flex: none; white-space: nowrap; }
.lc-gran, .lc-kinds { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; padding: 1px; }
/* A title carrying TWO toggle groups wraps them so the auto-margin push happens once — two bare .lc-gran siblings would split the free space and drift apart. */
.lc-trend-ctl { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.lc-trend-ctl .lc-gran { margin-left: 0; }
.lc-trend-ctl, .lc-heat-ctl { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.lc-trend-ctl .lc-gran, .lc-heat-ctl .lc-gran { margin-left: 0; }
/* The heat title's hint text loses its own auto margin inside the wrapper. */
.lc-heat-ctl .lc-card-sub { margin-left: 0; }
/* The trend card's adaptive switch rides the title text's right — a single-button group whose auto margin is
neutralized so it stays adjacent (the browser card's DNA toggle idiom). */
.lc-trend-adaptive { margin-left: 0; flex: none; }
+22 -6
View File
@@ -57,23 +57,27 @@ describe('Heatmap', () => {
'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 },
}
const m = await mount(h(Heatmap, { days, today: TODAY, weeks: 2 }))
const buttons = queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
// The zero-token day counts as activity (requests > 0) and draws level 0.
assert.equal(buttons.length, 6)
assert.equal(buttons.length, 7)
const byKey = new Map(buttons.map(b => [b.getAttribute('aria-label')?.split('\n')[0], b]))
assert.ok(byKey.get('2026-09-16')?.className.includes('lc-heat-4'), 'the maximum day is deepest')
assert.ok(byKey.get('2026-09-15')?.className.includes('lc-heat-2'))
assert.ok(byKey.get('2026-09-14')?.className.includes('lc-heat-1'))
assert.ok(byKey.get('2026-09-10')?.className.includes('lc-heat-0'), 'a day billed without a settled step stays gray')
assert.ok(byKey.get('2026-09-09')?.className.includes('lc-heat-3'))
assert.ok(byKey.get('2026-09-08')?.className.includes('lc-heat-1'), 'a crumb is never invisible')
assert.ok(byKey.get('2026-09-07')?.className.includes('lc-heat-0'))
// The tooltip is two lines: the date, then the day's active sessions.
assert.equal(byKey.get('2026-09-16')?.getAttribute('aria-label'), '2026-09-16\n2 active sessions')
// The zero-token day counts as activity (requests > 0); depths ride the
// steps metric, so its pair of steps draws half the peak.
assert.ok(byKey.get('2026-09-07')?.className.includes('lc-heat-2'))
// The tooltip is three lines: the date, the day's active sessions, its steps.
assert.equal(byKey.get('2026-09-16')?.getAttribute('aria-label'), '2026-09-16\n2 active sessions\n4 steps')
assert.equal(byKey.get('2026-09-10')?.getAttribute('aria-label'), '2026-09-10\n1 active sessions\n0 steps')
// Inert cells: data-less days and future days draw as plain spans.
const spans = queryAll(m.container, 'span.lc-heat-cell')
assert.ok(spans.length > 0)
@@ -119,6 +123,18 @@ describe('Heatmap', () => {
await m.unmount()
})
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 },
}
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]))
assert.ok(byKey.get('2026-09-16')?.className.includes('lc-heat-3'), 'two of the three-session peak draws level 3')
assert.ok(byKey.get('2026-09-09')?.className.includes('lc-heat-4'), 'the sessions peak depths level 4 that its thin steps never would')
await m.unmount()
})
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 } },
@@ -127,7 +143,7 @@ describe('Heatmap', () => {
}))
const cell = query<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
await hover(cell)
assert.equal(query(m.container, '[role="tooltip"]').textContent, '2026-09-16\n3 active sessions')
assert.equal(query(m.container, '[role="tooltip"]').textContent, '2026-09-16\n3 active sessions\n1 steps')
await unhover(cell)
assert.equal(queryAll(m.container, '[role="tooltip"]').length, 0, 'the bubble drops when the pointer leaves')
// An empty day tips too — the bare date, one line.
@@ -218,6 +218,26 @@ describe('OverviewPanel', () => {
await m.unmount()
})
test('the metric toggle re-prices the heatmap between steps and sessions', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const todayCellClass = (): string | undefined =>
queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
.find(c => c.getAttribute('aria-label')?.startsWith(TODAY))?.className
// Steps (the default): today's pair of steps IS the window's steps peak.
assert.ok(todayCellClass()?.includes('lc-heat-4'), 'steps mode (default): today holds the steps peak')
const metricButtons = queryAll<HTMLButtonElement>(m.container, '.lc-heat-ctl .lc-gran-btn')
assert.deepEqual(metricButtons.map(b => b.textContent), ['Sessions', 'Steps'])
assert.ok(metricButtons[1].className.includes('lc-gran-on'), 'steps starts selected')
await click(metricButtons[0])
const after = queryAll<HTMLButtonElement>(m.container, '.lc-heat-ctl .lc-gran-btn')
assert.ok(after[0].className.includes('lc-gran-on'), 'the click selects sessions')
assert.ok(!after[1].className.includes('lc-gran-on'))
// Sessions: today had one of the window's two active sessions.
assert.ok(todayCellClass()?.includes('lc-heat-2'), 'sessions mode re-prices today down its own scale')
await m.unmount()
})
test('search and sort steer the grid', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx, {