Skip to content

Commit 95b8e96

Browse files
SurviveMclaude
andcommitted
feat(web-ui): add 7x24 hourly heatmap to usage page
Add weekday-by-hour heatmap grid (7 rows x 24 columns) to the usage page, showing session distribution across weekdays and hours with color-coded intensity levels. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0552a27 commit 95b8e96

7 files changed

Lines changed: 285 additions & 1 deletion

File tree

tests/unit/session-usage.test.mjs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
55
const __filename = fileURLToPath(import.meta.url);
66
const __dirname = path.dirname(__filename);
77
const logic = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs')));
8-
const { buildUsageChartGroups, buildUsageHeatmap } = logic;
8+
const { buildUsageChartGroups, buildUsageHeatmap, buildUsageHourlyHeatmap } = logic;
99

1010
test('buildUsageChartGroups aggregates codex and claude sessions into day buckets', () => {
1111
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
@@ -107,3 +107,47 @@ test('buildUsageHeatmap aligns to monday and aggregates sessions per day', () =>
107107
const hasDay = result.weeks.some((week) => Array.isArray(week.days) && week.days.some((cell) => cell && cell.dateKey === '2026-04-06' && cell.sessionCount === 2));
108108
assert.ok(hasDay);
109109
});
110+
111+
test('buildUsageHourlyHeatmap produces 7x24 grid with correct aggregation', () => {
112+
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
113+
const result = buildUsageHourlyHeatmap([
114+
{ source: 'codex', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 5, totalTokens: 120 },
115+
{ source: 'claude', updatedAt: '2026-04-06T08:30:00.000Z', messageCount: 7, totalTokens: 230 },
116+
{ source: 'codex', updatedAt: '2026-04-05T14:00:00.000Z', messageCount: 3, totalTokens: 90 }
117+
], { range: '7d', now });
118+
119+
assert.strictEqual(result.range, '7d');
120+
assert.strictEqual(result.grid.length, 7);
121+
assert.strictEqual(result.grid[0].length, 24);
122+
assert.strictEqual(result.weekdayLabels.length, 7);
123+
assert.strictEqual(result.hourLabels.length, 24);
124+
125+
const april6Dow = (new Date(Date.UTC(2026, 3, 6)).getUTCDay() + 6) % 7;
126+
assert.strictEqual(result.grid[april6Dow][8].sessionCount, 2);
127+
assert.strictEqual(result.grid[april6Dow][8].messageCount, 12);
128+
assert.strictEqual(result.grid[april6Dow][8].tokenTotal, 350);
129+
assert.ok(result.maxSessionCount >= 2);
130+
});
131+
132+
test('buildUsageHourlyHeatmap ignores sessions outside range', () => {
133+
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
134+
const result = buildUsageHourlyHeatmap([
135+
{ source: 'codex', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 5, totalTokens: 120 },
136+
{ source: 'codex', updatedAt: '2026-03-01T08:00:00.000Z', messageCount: 99, totalTokens: 999 }
137+
], { range: '7d', now });
138+
139+
let totalSessions = 0;
140+
for (const row of result.grid) {
141+
for (const cell of row) {
142+
totalSessions += cell.sessionCount;
143+
}
144+
}
145+
assert.strictEqual(totalSessions, 1);
146+
});
147+
148+
test('buildUsageHourlyHeatmap returns empty grid for no sessions', () => {
149+
const result = buildUsageHourlyHeatmap([], { range: '7d' });
150+
assert.strictEqual(result.grid.length, 7);
151+
assert.strictEqual(result.grid[0].length, 24);
152+
assert.strictEqual(result.maxSessionCount, 1);
153+
});

tests/unit/web-ui-behavior-parity.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
615615
'promptComposerMissingVars',
616616
'sessionUsageDaily',
617617
'sessionUsageHeatmap',
618+
'sessionUsageHourlyHeatmap',
618619
'sessionUsageDailyTableRows',
619620
'sessionsUsageSelectedDaySummary',
620621
'usageCurrentSessionStats',

web-ui/logic.sessions.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,62 @@ export function buildUsageHeatmap(sessions = [], options = {}) {
301301
};
302302
}
303303

304+
export function buildUsageHourlyHeatmap(sessions = [], options = {}) {
305+
const list = Array.isArray(sessions) ? sessions : [];
306+
const range = normalizeUsageRange(options.range);
307+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
308+
const dayMs = 24 * 60 * 60 * 1000;
309+
const todayStart = toUtcDayStartMs(now);
310+
311+
const normalized = [];
312+
for (const session of list) {
313+
if (!session || typeof session !== 'object') continue;
314+
const source = normalizeSessionSource(session.source, '');
315+
if (source !== 'codex' && source !== 'claude') continue;
316+
const updatedAtMs = Date.parse(session.updatedAt || '');
317+
if (!Number.isFinite(updatedAtMs)) continue;
318+
const dayStart = toUtcDayStartMs(updatedAtMs);
319+
if (range !== 'all') {
320+
const rangeDays = range === '30d' ? 30 : 7;
321+
const rangeStart = todayStart - ((rangeDays - 1) * dayMs);
322+
if (dayStart < rangeStart || dayStart > todayStart) continue;
323+
}
324+
const stamp = new Date(updatedAtMs);
325+
const weekday = (stamp.getUTCDay() + 6) % 7;
326+
const hour = stamp.getUTCHours();
327+
const messageCount = Number.isFinite(Number(session.messageCount))
328+
? Math.max(0, Math.floor(Number(session.messageCount)))
329+
: 0;
330+
const tokenTotal = readSessionTotalTokens(session);
331+
normalized.push({ weekday, hour, messageCount, tokenTotal });
332+
}
333+
334+
const grid = Array.from({ length: 7 }, () =>
335+
Array.from({ length: 24 }, () => ({ sessionCount: 0, messageCount: 0, tokenTotal: 0 }))
336+
);
337+
for (const item of normalized) {
338+
const cell = grid[item.weekday][item.hour];
339+
cell.sessionCount += 1;
340+
cell.messageCount += item.messageCount;
341+
cell.tokenTotal += item.tokenTotal;
342+
}
343+
344+
let maxSessionCount = 0;
345+
for (let day = 0; day < 7; day += 1) {
346+
for (let hour = 0; hour < 24; hour += 1) {
347+
maxSessionCount = Math.max(maxSessionCount, grid[day][hour].sessionCount);
348+
}
349+
}
350+
351+
return {
352+
range,
353+
grid,
354+
maxSessionCount: Math.max(1, maxSessionCount),
355+
weekdayLabels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
356+
hourLabels: Array.from({ length: 24 }, (_, index) => String(index).padStart(2, '0'))
357+
};
358+
}
359+
304360
function buildUsageBuckets(normalizedSessions, options = {}) {
305361
const range = normalizeUsageRange(options.range);
306362
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();

web-ui/modules/app.computed.session.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
buildSessionTimelineNodes,
33
buildUsageChartGroups,
44
buildUsageHeatmap,
5+
buildUsageHourlyHeatmap,
56
isSessionQueryEnabled
67
} from '../logic.mjs';
78
import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs';
@@ -580,6 +581,53 @@ export function createSessionComputed() {
580581
weekdayAxis
581582
};
582583
},
584+
sessionUsageHourlyHeatmap() {
585+
const sessions = this.sessionUsageCharts && Array.isArray(this.sessionUsageCharts.filteredSessions)
586+
? this.sessionUsageCharts.filteredSessions
587+
: this.sessionsUsageList;
588+
const result = buildUsageHourlyHeatmap(sessions, { range: this.sessionsUsageTimeRange });
589+
const t = typeof this.t === 'function' ? this.t : null;
590+
const lang = typeof this.lang === 'string' ? this.lang.trim().toLowerCase() : '';
591+
const weekdayLabels = lang === 'en'
592+
? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
593+
: result.weekdayLabels;
594+
const max = result.maxSessionCount;
595+
const grid = Array.isArray(result.grid) ? result.grid : [];
596+
const rows = grid.map((cells, dayIndex) => ({
597+
weekday: weekdayLabels[dayIndex],
598+
cells: cells.map((cell, hourIndex) => {
599+
const ratio = cell.sessionCount > 0 ? (cell.sessionCount / max) : 0;
600+
const level = cell.sessionCount <= 0
601+
? 0
602+
: (ratio <= 0.25 ? 1 : (ratio <= 0.5 ? 2 : (ratio <= 0.75 ? 3 : 4)));
603+
const hourLabel = String(hourIndex).padStart(2, '0');
604+
const tooltipText = t
605+
? t('usage.hourlyHeatmap.tooltip', {
606+
weekday: weekdayLabels[dayIndex],
607+
hour: hourLabel,
608+
sessions: cell.sessionCount,
609+
messages: cell.messageCount,
610+
tokens: (cell.tokenTotal || 0).toLocaleString('en-US')
611+
})
612+
: `${weekdayLabels[dayIndex]} ${hourLabel}:00 · ${cell.sessionCount} sessions · ${cell.messageCount} messages · ${cell.tokenTotal} tokens`;
613+
return {
614+
hour: hourIndex,
615+
hourLabel,
616+
sessionCount: cell.sessionCount,
617+
messageCount: cell.messageCount,
618+
tokenTotal: cell.tokenTotal,
619+
level,
620+
tooltip: tooltipText
621+
};
622+
})
623+
}));
624+
return {
625+
range: result.range,
626+
rows,
627+
hourLabels: result.hourLabels,
628+
maxSessionCount: result.maxSessionCount
629+
};
630+
},
583631
sessionUsageSummaryCards() {
584632
const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
585633
? this.sessionUsageCharts.summary

web-ui/modules/i18n.dict.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,11 @@ const DICT = Object.freeze({
622622
'usage.heatmap.legend.more': '多',
623623
'usage.heatmap.tooltip': '{date} · {sessions} 会话 · {messages} 消息 · {tokens} token',
624624
'usage.heatmap.aria': '{date},{sessions} 会话',
625+
'usage.hourlyHeatmap.title': '7×24 活跃热力图',
626+
'usage.hourlyHeatmap.subtitle': '按星期 × 小时聚合会话分布,深色 = 高活跃。',
627+
'usage.hourlyHeatmap.tooltip': '{weekday} {hour}:00 · {sessions} 会话 · {messages} 消息 · {tokens} token',
628+
'usage.hourlyHeatmap.legend.less': '少',
629+
'usage.hourlyHeatmap.legend.more': '多',
625630
'usage.legend.tokens': 'Token',
626631
'usage.legend.cost': '预估费用',
627632
'usage.table.date': '日期',
@@ -1676,6 +1681,11 @@ const DICT = Object.freeze({
16761681
'usage.heatmap.legend.more': 'More',
16771682
'usage.heatmap.tooltip': '{date} · {sessions} sessions · {messages} messages · {tokens} tokens',
16781683
'usage.heatmap.aria': '{date}, {sessions} sessions',
1684+
'usage.hourlyHeatmap.title': '7×24 Activity Heatmap',
1685+
'usage.hourlyHeatmap.subtitle': 'Session distribution by weekday × hour; darker = more active.',
1686+
'usage.hourlyHeatmap.tooltip': '{weekday} {hour}:00 · {sessions} sessions · {messages} messages · {tokens} tokens',
1687+
'usage.hourlyHeatmap.legend.less': 'Less',
1688+
'usage.hourlyHeatmap.legend.more': 'More',
16791689
'usage.legend.tokens': 'Tokens',
16801690
'usage.legend.cost': 'Estimated cost',
16811691
'usage.table.date': 'Date',

web-ui/partials/index/panel-usage.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,36 @@
288288
</div>
289289
</section>
290290

291+
<section class="usage-card usage-card-hourly-heatmap">
292+
<div class="usage-card-title">{{ t('usage.hourlyHeatmap.title') }}</div>
293+
<div class="usage-card-subtitle">{{ t('usage.hourlyHeatmap.subtitle') }}</div>
294+
<div class="hourly-heatmap-wrapper">
295+
<div class="hourly-heatmap-header">
296+
<div class="hourly-heatmap-corner"></div>
297+
<div v-for="h in sessionUsageHourlyHeatmap.hourLabels" :key="'hh-' + h" class="hourly-heatmap-hour-label">{{ h }}</div>
298+
</div>
299+
<div v-for="(row, dayIndex) in sessionUsageHourlyHeatmap.rows" :key="'hd-' + dayIndex" class="hourly-heatmap-row">
300+
<div class="hourly-heatmap-weekday-label">{{ row.weekday }}</div>
301+
<div
302+
v-for="(cell, hourIndex) in row.cells"
303+
:key="'hc-' + dayIndex + '-' + hourIndex"
304+
:class="['hourly-heatmap-cell', 'level-' + cell.level]"
305+
:title="cell.tooltip"
306+
:aria-label="cell.tooltip">
307+
</div>
308+
</div>
309+
<div class="hourly-heatmap-legend">
310+
<span class="hourly-heatmap-legend-label">{{ t('usage.hourlyHeatmap.legend.less') }}</span>
311+
<span class="hourly-heatmap-cell level-0"></span>
312+
<span class="hourly-heatmap-cell level-1"></span>
313+
<span class="hourly-heatmap-cell level-2"></span>
314+
<span class="hourly-heatmap-cell level-3"></span>
315+
<span class="hourly-heatmap-cell level-4"></span>
316+
<span class="hourly-heatmap-legend-label">{{ t('usage.hourlyHeatmap.legend.more') }}</span>
317+
</div>
318+
</div>
319+
</section>
320+
291321
<section class="usage-card usage-card-top-paths">
292322
<div class="usage-card-title">{{ t('usage.paths.title') }}</div>
293323
<div v-if="!sessionUsageCharts.topPaths.length" class="usage-list-value">{{ t('usage.paths.empty') }}</div>

web-ui/styles/sessions-usage.css

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,3 +943,98 @@
943943
align-items: stretch;
944944
}
945945
}
946+
947+
.usage-card-hourly-heatmap {
948+
overflow-x: auto;
949+
}
950+
951+
.hourly-heatmap-wrapper {
952+
display: flex;
953+
flex-direction: column;
954+
gap: 2px;
955+
margin-top: 8px;
956+
min-width: 580px;
957+
}
958+
959+
.hourly-heatmap-header {
960+
display: flex;
961+
gap: 2px;
962+
align-items: flex-end;
963+
}
964+
965+
.hourly-heatmap-corner {
966+
width: 36px;
967+
flex-shrink: 0;
968+
}
969+
970+
.hourly-heatmap-hour-label {
971+
flex: 1;
972+
min-width: 0;
973+
font-size: 10px;
974+
color: var(--color-text-muted);
975+
text-align: center;
976+
line-height: 14px;
977+
}
978+
979+
.hourly-heatmap-row {
980+
display: flex;
981+
gap: 2px;
982+
align-items: center;
983+
}
984+
985+
.hourly-heatmap-weekday-label {
986+
width: 36px;
987+
flex-shrink: 0;
988+
font-size: 11px;
989+
color: var(--color-text-secondary);
990+
text-align: right;
991+
padding-right: 4px;
992+
}
993+
994+
.hourly-heatmap-cell {
995+
flex: 1;
996+
min-width: 0;
997+
aspect-ratio: 1;
998+
border-radius: 3px;
999+
cursor: default;
1000+
}
1001+
1002+
.hourly-heatmap-cell.level-0 {
1003+
background: var(--color-surface-alt);
1004+
}
1005+
1006+
.hourly-heatmap-cell.level-1 {
1007+
background: #9be9a8;
1008+
}
1009+
1010+
.hourly-heatmap-cell.level-2 {
1011+
background: #40c463;
1012+
}
1013+
1014+
.hourly-heatmap-cell.level-3 {
1015+
background: #30a14e;
1016+
}
1017+
1018+
.hourly-heatmap-cell.level-4 {
1019+
background: #216e39;
1020+
}
1021+
1022+
.hourly-heatmap-legend {
1023+
display: flex;
1024+
align-items: center;
1025+
gap: 3px;
1026+
justify-content: flex-end;
1027+
margin-top: 6px;
1028+
font-size: 10px;
1029+
color: var(--color-text-muted);
1030+
}
1031+
1032+
.hourly-heatmap-legend .hourly-heatmap-cell {
1033+
width: 12px;
1034+
height: 12px;
1035+
aspect-ratio: auto;
1036+
}
1037+
1038+
.hourly-heatmap-legend-label {
1039+
margin: 0 2px;
1040+
}

0 commit comments

Comments
 (0)