feat(client): add month labels to the activity heatmap

Each column is labeled when a month begins inside it: the month changes
between its Monday and Sunday, or its Monday is the 1st; the label names
the Sunday's month. Months that began before the window stay unlabeled.
This commit is contained in:
Bowen Liang committed 2026-09-17 00:28:08 +08:00
1 parent c463620b71
commit dd0ccbf044
4 files changed
+73 -28

No files matched your search

+35 -26
View File
@@ -1,6 +1,7 @@
/**
* The Context Dashboard's activity heatmap: a GitHub-style contribution grid
* (weeks as columns, Monday-first weekdays as rows) over the merged daily
* (weeks as columns, Monday-first weekdays as rows, a month label over the
* column where each month begins) 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
* pins the session list to that day (click again to release). Cells tip
@@ -107,34 +108,42 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
))}
</div>
<div className="lc-heat-cols">
{columns.map((column, wi) => (
<div key={wi} className="lc-heat-col">
{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)
if (cell.entry === undefined) {
{columns.map((column, wi) => {
// A column is labeled when a month BEGINS inside it: the month
// changes between its Monday and Sunday, or its Monday IS the
// 1st. The label names the Sunday's month — the new month in
// both cases. Months that began before the window stay unlabeled.
const opens = column[0].key.slice(0, 7) !== column[6].key.slice(0, 7) || column[0].key.slice(8, 10) === '01'
return (
<div key={wi} className="lc-heat-col">
{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)
if (cell.entry === undefined) {
return (
<Tooltip key={cell.key} label={cell.key} side="top">
<span className="lc-heat-cell lc-heat-0" />
</Tooltip>
)
}
const picked = props.selected === cell.key
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}`
return (
<Tooltip key={cell.key} label={cell.key} side="top">
<span className="lc-heat-cell lc-heat-0" />
<Tooltip key={cell.key} label={label} side="top">
<button
type="button"
className={`lc-heat-cell lc-heat-${String(level)}${picked ? ' lc-heat-on' : ''}`}
aria-label={label}
aria-pressed={picked}
onClick={() => { if (props.onSelect !== undefined) props.onSelect(picked ? null : cell.key) }}
/>
</Tooltip>
)
}
const picked = props.selected === cell.key
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}`
return (
<Tooltip key={cell.key} label={label} side="top">
<button
type="button"
className={`lc-heat-cell lc-heat-${String(level)}${picked ? ' lc-heat-on' : ''}`}
aria-label={label}
aria-pressed={picked}
onClick={() => { if (props.onSelect !== undefined) props.onSelect(picked ? null : cell.key) }}
/>
</Tooltip>
)
})}
</div>
))}
})}
</div>
)
})}
</div>
</div>
)
+8
View File
@@ -261,6 +261,10 @@ export const DICT_ZH: Record<string, string> = {
'ov.heat.empty': '暂无活跃数据 — 会话产生模型请求后,这里会显示每日用量',
'ov.heat.sessions': '{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月',
'ov.heat.mon.07': '7月', 'ov.heat.mon.08': '8月', 'ov.heat.mon.09': '9月',
'ov.heat.mon.10': '10月', 'ov.heat.mon.11': '11月', 'ov.heat.mon.12': '12月',
'ov.list.title': '会话',
'ov.list.search': '搜索标题或目录…',
'ov.list.sortLabel': '排序',
@@ -540,6 +544,10 @@ 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.wd.1': 'M', 'ov.heat.wd.3': 'W', 'ov.heat.wd.5': 'F',
'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',
'ov.heat.mon.07': 'Jul', 'ov.heat.mon.08': 'Aug', 'ov.heat.mon.09': 'Sep',
'ov.heat.mon.10': 'Oct', 'ov.heat.mon.11': 'Nov', 'ov.heat.mon.12': 'Dec',
'ov.list.title': 'Sessions',
'ov.list.search': 'Search title or path…',
'ov.list.sortLabel': 'Sort',
+12 -2
View File
@@ -71,13 +71,23 @@
* The activity heatmap: fixed-size square cells (8 week columns ≈ a 200px
* block) centered in the card, so the card's height follows the 7 cell rows
* instead of the cells stretching to the card's width — the compact
* GitHub-widget read. Monday-first, four depth steps.
* GitHub-widget read. Monday-first, four depth steps, a month label over
* the column where each month begins.
*/
.lc-heat { display: flex; gap: 4px; flex: 1; align-items: center; justify-content: center; }
/* Each column and the weekday gutter reserve the same 13px line above their
cells — the month labels' line — so the centered cell rows stay aligned. */
.lc-heat-wds, .lc-heat-col { padding-top: 13px; }
.lc-heat-wds { display: flex; flex-direction: column; gap: 3px; }
.lc-heat-wd { flex: none; width: 10px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 9px; color: var(--dsw-alias-label-secondary); text-align: center; }
.lc-heat-cols { display: flex; gap: 3px; }
.lc-heat-col { display: flex; flex-direction: column; gap: 3px; }
/* relative anchors the label; absolute keeps its width off the 20px column
(a wider in-flow label would widen just the labeled columns and skew the
grid). top:0 rides the column's own 13px padding band, clear of the cells.
Labels only sit at month opens, ≥4 columns apart, so the nowrap overflow
past a 20px column never meets the next label. */
.lc-heat-col { position: relative; display: flex; flex-direction: column; gap: 3px; }
.lc-heat-mon { position: absolute; top: 0; left: 0; font-size: 9px; line-height: 13px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
.lc-heat-cell { width: 20px; height: 20px; box-sizing: border-box; border: 0; border-radius: 20%; padding: 0; background: var(--dsw-alias-bg-layer-2); cursor: default; }
button.lc-heat-cell { cursor: pointer; }
button.lc-heat-cell:hover { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--color-blue-500) 70%, transparent); }
+18
View File
@@ -84,6 +84,24 @@ describe('Heatmap', () => {
await m.unmount()
})
test('month labels mark the columns where a month begins', async () => {
const days = { '2026-09-16': { tokens: 10, requests: 1, sessions: 1 } }
// The default 8-week window runs Monday 2026-07-27 → 2026-09-14: July
// began outside it, August begins in the Jul 27 column (Sat Aug 1) and
// September in the Aug 31 column (Tue Sep 1).
const m = await mount(h(Heatmap, { days, today: TODAY }))
assert.deepEqual(queryAll(m.container, '.lc-heat-mon').map(s => s.textContent), ['Aug', 'Sep'])
await m.unmount()
// A window without any month's first day draws no labels at all.
const m2 = await mount(h(Heatmap, { days, today: TODAY, weeks: 2 }))
assert.deepEqual(queryAll(m2.container, '.lc-heat-mon').map(s => s.textContent), [])
await m2.unmount()
// A column that opens on the 1st carries the label itself.
const m3 = await mount(h(Heatmap, { days: { '2026-06-03': { tokens: 1, requests: 1, sessions: 1 } }, today: '2026-06-03', weeks: 1 }))
assert.deepEqual(queryAll(m3.container, '.lc-heat-mon').map(s => s.textContent), ['Jun'])
await m3.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 } },