diff --git a/src/client/components/heatmap.tsx b/src/client/components/heatmap.tsx index 639cc5e..a455f15 100644 --- a/src/client/components/heatmap.tsx +++ b/src/client/components/heatmap.tsx @@ -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 /** 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
{t('ov.heat.empty')}
// 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 = 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
{t('ov.heat.empty')}
+ 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 = { 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 && } {column.map((cell) => { if (cell.future) return - + diff --git a/src/client/i18n.ts b/src/client/i18n.ts index c1eef68..59515b1 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -265,6 +265,8 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/styles/base.css b/src/client/styles/base.css index 837c87c..8bc0758 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -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; } diff --git a/tests/client/components/heatmap.spec.ts b/tests/client/components/heatmap.spec.ts index 1f83775..e96c012 100644 --- a/tests/client/components/heatmap.spec.ts +++ b/tests/client/components/heatmap.spec.ts @@ -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(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(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(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. diff --git a/tests/client/components/overviewPanel.spec.ts b/tests/client/components/overviewPanel.spec.ts index 98ef699..416210e 100644 --- a/tests/client/components/overviewPanel.spec.ts +++ b/tests/client/components/overviewPanel.spec.ts @@ -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(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(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(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, {