Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions tests/unit/session-usage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,20 @@ test('buildUsageChartGroups aggregates codex and claude sessions into day bucket
assert.strictEqual(result.summary.totalMessages, 15);
assert.strictEqual(result.summary.codexTotal, 2);
assert.strictEqual(result.summary.claudeTotal, 1);
assert.strictEqual(result.summary.avgMessagesPerSession, 5);
assert.strictEqual(result.summary.busiestDay.label, '04-06');
assert.strictEqual(result.summary.busiestDay.totalSessions, 2);
assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').percent, 67);
assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').messageTotal, 8);
assert.strictEqual(result.sourceShare.find(item => item.key === 'claude').avgMessages, 7);
assert.strictEqual(result.topPaths[0].path, '/a');
assert.strictEqual(result.topPaths[0].count, 2);
assert.strictEqual(result.topPaths[0].messageTotal, 12);
assert.strictEqual(result.recentSessions[0].title, '未命名会话');
assert.strictEqual(result.recentSessions[0].sourceLabel, 'Claude Code');
assert.strictEqual(result.topSessionsByMessages[0].messageCount, 7);
assert.strictEqual(result.hourActivity.find(item => item.key === '09').count, 2);
assert.strictEqual(result.weekdayActivity.find(item => item.label === '周一').count, 2);
const lastBucket = result.buckets[result.buckets.length - 1];
assert.strictEqual(lastBucket.codex, 1);
assert.strictEqual(lastBucket.claude, 1);
Expand All @@ -40,4 +51,22 @@ test('buildUsageChartGroups ignores invalid sessions and keeps empty buckets sta
assert.strictEqual(result.summary.totalMessages, 0);
assert.strictEqual(result.buckets.length, 7);
assert.ok(result.buckets.every((item) => item.totalSessions === 0));
assert.ok(result.hourActivity.every((item) => item.count === 0));
assert.ok(result.weekdayActivity.every((item) => item.count === 0));
assert.deepStrictEqual(result.recentSessions, []);
});

test('buildUsageChartGroups produces stable unique keys for sessions without ids', () => {
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
const result = buildUsageChartGroups([
{ source: 'codex', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 4 },
{ source: 'codex', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 4 },
{ source: 'claude', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 4 }
], { range: '7d', now });

const recentKeys = result.recentSessions.map((item) => item.key);
const topKeys = result.topSessionsByMessages.map((item) => item.key);

assert.strictEqual(new Set(recentKeys).size, recentKeys.length);
assert.strictEqual(new Set(topKeys).size, topKeys.length);
});
123 changes: 116 additions & 7 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,22 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
let claudeTotal = 0;
let messageTotal = 0;
const pathMap = new Map();
const sourceMessageTotals = { codex: 0, claude: 0 };
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
key: String(hour).padStart(2, '0'),
label: String(hour).padStart(2, '0'),
count: 0
}));
const weekdayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const weekdayCounts = Array.from({ length: 7 }, (_, index) => ({
key: String(index),
label: weekdayLabels[index],
count: 0
}));
const recentSessions = [];
const topSessionsByMessages = [];

for (const session of list) {
for (const [sessionIndex, session] of list.entries()) {
if (!session || typeof session !== 'object') continue;
const source = normalizeSessionSource(session.source, '');
if (source !== 'codex' && source !== 'claude') continue;
Expand All @@ -169,10 +183,51 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
claudeTotal += 1;
}
messageTotal += messageCount;
sourceMessageTotals[source] += messageCount;

const utcHour = stamp.getUTCHours();
if (hourCounts[utcHour]) {
hourCounts[utcHour].count += 1;
}
const dayIndex = (stamp.getUTCDay() + 6) % 7;
if (weekdayCounts[dayIndex]) {
weekdayCounts[dayIndex].count += 1;
}

const cwd = normalizeSessionPathFilter(session.cwd);
if (cwd) {
pathMap.set(cwd, (Number(pathMap.get(cwd)) || 0) + 1);
const prev = pathMap.get(cwd) || { count: 0, messageTotal: 0, updatedAtMs: 0 };
pathMap.set(cwd, {
count: prev.count + 1,
messageTotal: prev.messageTotal + messageCount,
updatedAtMs: Math.max(prev.updatedAtMs, updatedAtMs)
});
}

const normalizedTitle = typeof session.title === 'string' && session.title.trim()
? session.title.trim()
: (typeof session.sessionId === 'string' && session.sessionId.trim() ? session.sessionId.trim() : '未命名会话');
const sessionEntry = {
key: [
source,
session.sessionId || '',
session.filePath || normalizedTitle,
String(updatedAtMs),
String(messageCount),
String(sessionIndex)
].join(':'),
title: normalizedTitle,
source,
sourceLabel: source === 'codex' ? 'Codex' : 'Claude Code',
cwd,
messageCount,
updatedAt: session.updatedAt || '',
updatedAtMs,
updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || ''),
hasExactMessageCount: session.__messageCountExact === true
};
recentSessions.push(sessionEntry);
topSessionsByMessages.push({ ...sessionEntry });
}

const totalSessions = codexTotal + claudeTotal;
Expand All @@ -181,16 +236,41 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
{ key: 'claude', label: 'Claude', value: claudeTotal }
].map((item) => ({
...item,
percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0
percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0,
messageTotal: sourceMessageTotals[item.key] || 0,
messagePercent: messageTotal > 0 ? Math.round(((sourceMessageTotals[item.key] || 0) / messageTotal) * 100) : 0,
avgMessages: item.value > 0 ? Math.round(((sourceMessageTotals[item.key] || 0) / item.value) * 10) / 10 : 0
}));

const topPaths = [...pathMap.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-Hans-CN'))
.sort((a, b) => b[1].count - a[1].count || b[1].messageTotal - a[1].messageTotal || a[0].localeCompare(b[0], 'zh-Hans-CN'))
.slice(0, 5)
.map(([pathValue, count]) => ({ path: pathValue, count }));
.map(([pathValue, meta]) => ({
path: pathValue,
count: meta.count,
messageTotal: meta.messageTotal,
updatedAtLabel: meta.updatedAtMs ? formatSessionTimelineTimestamp(new Date(meta.updatedAtMs).toISOString()) : ''
}));

const sortedRecentSessions = recentSessions
.sort((a, b) => b.updatedAtMs - a.updatedAtMs || b.messageCount - a.messageCount || a.title.localeCompare(b.title, 'zh-Hans-CN'))
.slice(0, 6);

const sortedTopSessionsByMessages = topSessionsByMessages
.sort((a, b) => b.messageCount - a.messageCount || b.updatedAtMs - a.updatedAtMs || a.title.localeCompare(b.title, 'zh-Hans-CN'))
.slice(0, 6);

const maxSessionBucket = buckets.reduce((max, item) => Math.max(max, item.totalSessions), 0);
const maxMessageBucket = buckets.reduce((max, item) => Math.max(max, item.totalMessages), 0);
const maxHourCount = hourCounts.reduce((max, item) => Math.max(max, item.count), 0);
const maxWeekdayCount = weekdayCounts.reduce((max, item) => Math.max(max, item.count), 0);
const busiestDay = [...buckets]
.sort((a, b) => b.totalSessions - a.totalSessions || b.totalMessages - a.totalMessages || a.key.localeCompare(b.key, 'zh-Hans-CN'))[0] || null;
const busiestHour = [...hourCounts]
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key, 'zh-Hans-CN'))[0] || null;
const activeDays = buckets.filter((item) => item.totalSessions > 0).length;
const avgMessagesPerSession = totalSessions > 0 ? Math.round((messageTotal / totalSessions) * 10) / 10 : 0;
const avgSessionsPerActiveDay = activeDays > 0 ? Math.round((totalSessions / activeDays) * 10) / 10 : 0;

return {
range,
Expand All @@ -200,12 +280,41 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
totalMessages: messageTotal,
codexTotal,
claudeTotal,
activeDays: buckets.filter((item) => item.totalSessions > 0).length
activeDays,
avgMessagesPerSession,
avgSessionsPerActiveDay,
busiestDay: busiestDay
? {
key: busiestDay.key,
label: busiestDay.label,
totalSessions: busiestDay.totalSessions,
totalMessages: busiestDay.totalMessages
}
: null,
busiestHour: busiestHour
? {
key: busiestHour.key,
label: `${busiestHour.label}:00`,
count: busiestHour.count
}
: null
},
sourceShare,
topPaths,
recentSessions: sortedRecentSessions,
topSessionsByMessages: sortedTopSessionsByMessages,
hourActivity: hourCounts.map((item) => ({
...item,
percent: maxHourCount > 0 ? Math.round((item.count / maxHourCount) * 100) : 0
})),
weekdayActivity: weekdayCounts.map((item) => ({
...item,
percent: maxWeekdayCount > 0 ? Math.round((item.count / maxWeekdayCount) * 100) : 0
})),
maxSessionBucket,
maxMessageBucket
maxMessageBucket,
maxHourCount,
maxWeekdayCount
};
}

Expand Down
19 changes: 17 additions & 2 deletions web-ui/modules/app.computed.session.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,26 @@ export function createSessionComputed() {
sessionUsageSummaryCards() {
const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
? this.sessionUsageCharts.summary
: { totalSessions: 0, totalMessages: 0, activeDays: 0 };
: { totalSessions: 0, totalMessages: 0, activeDays: 0, avgMessagesPerSession: 0, busiestDay: null, busiestHour: null };
return [
{ key: 'sessions', label: '总会话数', value: summary.totalSessions || 0 },
{ key: 'messages', label: '总消息数', value: summary.totalMessages || 0 },
{ key: 'days', label: '活跃天数', value: summary.activeDays || 0 }
{ key: 'days', label: '活跃天数', value: summary.activeDays || 0 },
{ key: 'avg-messages', label: '平均每会话消息', value: summary.avgMessagesPerSession || 0 },
{
key: 'busiest-day',
label: '最忙日',
value: summary.busiestDay && summary.busiestDay.totalSessions > 0
? `${summary.busiestDay.label} · ${summary.busiestDay.totalSessions}`
: '暂无'
},
{
key: 'busiest-hour',
label: '高峰时段',
value: summary.busiestHour && summary.busiestHour.count > 0
? `${summary.busiestHour.label} · ${summary.busiestHour.count}`
: '暂无'
}
];
},
visibleSessionTrashItems() {
Expand Down
77 changes: 69 additions & 8 deletions web-ui/partials/index/panel-usage.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,48 @@
</section>

<section class="usage-card">
<div class="usage-card-title">来源占比</div>
<div class="usage-card-title">消息趋势</div>
<div class="usage-bars">
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key + '-messages'" class="usage-bar-group">
<div class="usage-bar-stack">
<div class="usage-bar codex" style="width:100%" :style="{ height: ((bucket.totalMessages / Math.max(sessionUsageCharts.maxMessageBucket, 1)) * 100) + '%' }" :title="`${bucket.totalMessages} messages`"></div>
</div>
<div class="usage-bar-label">{{ bucket.label }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">活跃时段</div>
<div class="usage-mini-bars">
<div v-for="item in sessionUsageCharts.hourActivity" :key="item.key" class="usage-mini-bar-group">
<div class="usage-mini-bar-track" :title="`${item.label}:00 · ${item.count} 次会话`">
<div class="usage-mini-bar-fill" :style="{ height: item.percent + '%' }"></div>
</div>
<div class="usage-mini-bar-label">{{ item.label }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">来源洞察</div>
<div class="usage-list">
<div v-for="item in sessionUsageCharts.sourceShare" :key="item.key" class="usage-list-row">
<div class="usage-list-label">{{ item.label }}</div>
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: item.percent + '%' }"></div></div>
<div class="usage-list-value">{{ item.percent }}%</div>
<div class="usage-list-subvalue">{{ item.value }} 会话 · {{ item.messageTotal }} 消息 · 均值 {{ item.avgMessages }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">消息趋势</div>
<div class="usage-bars">
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key + '-messages'" class="usage-bar-group">
<div class="usage-bar-stack">
<div class="usage-bar codex" style="width:100%" :style="{ height: ((bucket.totalMessages / Math.max(sessionUsageCharts.maxMessageBucket, 1)) * 100) + '%' }" :title="`${bucket.totalMessages} messages`"></div>
</div>
<div class="usage-bar-label">{{ bucket.label }}</div>
<div class="usage-card-title">工作日分布</div>
<div class="usage-list">
<div v-for="item in sessionUsageCharts.weekdayActivity" :key="item.key" class="usage-list-row usage-list-row-compact">
<div class="usage-list-label">{{ item.label }}</div>
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: item.percent + '%' }"></div></div>
<div class="usage-list-value">{{ item.count }}</div>
</div>
</div>
</section>
Expand All @@ -76,6 +100,43 @@
<div class="usage-list-label">{{ item.count }} 次</div>
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: ((item.count / Math.max((sessionUsageCharts.topPaths.length ? sessionUsageCharts.topPaths[0].count : 1), 1)) * 100) + '%' }"></div></div>
<div class="usage-list-value" :title="item.path">{{ item.path }}</div>
<div class="usage-list-subvalue">{{ item.messageTotal }} 消息<span v-if="item.updatedAtLabel"> · 最近 {{ item.updatedAtLabel }}</span></div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">近期活跃会话</div>
<div v-if="!sessionUsageCharts.recentSessions.length" class="usage-list-value">暂无会话数据</div>
<div v-else class="usage-session-list">
<div v-for="item in sessionUsageCharts.recentSessions" :key="item.key" class="usage-session-item">
<div class="usage-session-row">
<div class="usage-session-title" :title="item.title">{{ item.title }}</div>
<span :class="['pill', item.source === 'codex' ? 'configured' : 'empty']">{{ item.sourceLabel }}</span>
</div>
<div class="usage-session-meta">
<span>{{ item.messageCount }} 消息</span>
<span v-if="item.updatedAtLabel">{{ item.updatedAtLabel }}</span>
</div>
<div v-if="item.cwd" class="usage-session-path" :title="item.cwd">{{ item.cwd }}</div>
</div>
</div>
</section>

<section class="usage-card">
<div class="usage-card-title">消息密度最高</div>
<div v-if="!sessionUsageCharts.topSessionsByMessages.length" class="usage-list-value">暂无会话数据</div>
<div v-else class="usage-session-list">
<div v-for="item in sessionUsageCharts.topSessionsByMessages" :key="item.key + '-messages'" class="usage-session-item">
<div class="usage-session-row">
<div class="usage-session-title" :title="item.title">{{ item.title }}</div>
<div class="usage-inline-stat">{{ item.messageCount }} 消息</div>
</div>
<div class="usage-session-meta">
<span>{{ item.sourceLabel }}</span>
<span v-if="item.updatedAtLabel">{{ item.updatedAtLabel }}</span>
</div>
<div v-if="item.cwd" class="usage-session-path" :title="item.cwd">{{ item.cwd }}</div>
</div>
</div>
</section>
Expand Down
Loading
Loading