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:
1 parent
bd112d54ee
commit
41452fc50a
6 files changed
+96
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user