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:
1 parent
c463620b71
commit
dd0ccbf044
4 files changed
+73
-28
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user