Skip to content

Commit d18b384

Browse files
committed
feat(usage): add local insights dashboard
1 parent 0f6ba66 commit d18b384

5 files changed

Lines changed: 335 additions & 16 deletions

File tree

tests/unit/session-usage.test.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,20 @@ test('buildUsageChartGroups aggregates codex and claude sessions into day bucket
1919
assert.strictEqual(result.summary.totalMessages, 15);
2020
assert.strictEqual(result.summary.codexTotal, 2);
2121
assert.strictEqual(result.summary.claudeTotal, 1);
22+
assert.strictEqual(result.summary.avgMessagesPerSession, 5);
23+
assert.strictEqual(result.summary.busiestDay.label, '04-06');
24+
assert.strictEqual(result.summary.busiestDay.totalSessions, 2);
2225
assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').percent, 67);
26+
assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').messageTotal, 8);
27+
assert.strictEqual(result.sourceShare.find(item => item.key === 'claude').avgMessages, 7);
2328
assert.strictEqual(result.topPaths[0].path, '/a');
2429
assert.strictEqual(result.topPaths[0].count, 2);
30+
assert.strictEqual(result.topPaths[0].messageTotal, 12);
31+
assert.strictEqual(result.recentSessions[0].title, '未命名会话');
32+
assert.strictEqual(result.recentSessions[0].sourceLabel, 'Claude Code');
33+
assert.strictEqual(result.topSessionsByMessages[0].messageCount, 7);
34+
assert.strictEqual(result.hourActivity.find(item => item.key === '09').count, 2);
35+
assert.strictEqual(result.weekdayActivity.find(item => item.label === '周一').count, 2);
2536
const lastBucket = result.buckets[result.buckets.length - 1];
2637
assert.strictEqual(lastBucket.codex, 1);
2738
assert.strictEqual(lastBucket.claude, 1);
@@ -40,4 +51,7 @@ test('buildUsageChartGroups ignores invalid sessions and keeps empty buckets sta
4051
assert.strictEqual(result.summary.totalMessages, 0);
4152
assert.strictEqual(result.buckets.length, 7);
4253
assert.ok(result.buckets.every((item) => item.totalSessions === 0));
54+
assert.ok(result.hourActivity.every((item) => item.count === 0));
55+
assert.ok(result.weekdayActivity.every((item) => item.count === 0));
56+
assert.deepStrictEqual(result.recentSessions, []);
4357
});

web-ui/logic.sessions.mjs

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
145145
let claudeTotal = 0;
146146
let messageTotal = 0;
147147
const pathMap = new Map();
148+
const sourceMessageTotals = { codex: 0, claude: 0 };
149+
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
150+
key: String(hour).padStart(2, '0'),
151+
label: String(hour).padStart(2, '0'),
152+
count: 0
153+
}));
154+
const weekdayLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
155+
const weekdayCounts = Array.from({ length: 7 }, (_, index) => ({
156+
key: String(index),
157+
label: weekdayLabels[index],
158+
count: 0
159+
}));
160+
const recentSessions = [];
161+
const topSessionsByMessages = [];
148162

149163
for (const session of list) {
150164
if (!session || typeof session !== 'object') continue;
@@ -169,10 +183,44 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
169183
claudeTotal += 1;
170184
}
171185
messageTotal += messageCount;
186+
sourceMessageTotals[source] += messageCount;
187+
188+
const utcHour = stamp.getUTCHours();
189+
if (hourCounts[utcHour]) {
190+
hourCounts[utcHour].count += 1;
191+
}
192+
const dayIndex = (stamp.getUTCDay() + 6) % 7;
193+
if (weekdayCounts[dayIndex]) {
194+
weekdayCounts[dayIndex].count += 1;
195+
}
196+
172197
const cwd = normalizeSessionPathFilter(session.cwd);
173198
if (cwd) {
174-
pathMap.set(cwd, (Number(pathMap.get(cwd)) || 0) + 1);
199+
const prev = pathMap.get(cwd) || { count: 0, messageTotal: 0, updatedAtMs: 0 };
200+
pathMap.set(cwd, {
201+
count: prev.count + 1,
202+
messageTotal: prev.messageTotal + messageCount,
203+
updatedAtMs: Math.max(prev.updatedAtMs, updatedAtMs)
204+
});
175205
}
206+
207+
const normalizedTitle = typeof session.title === 'string' && session.title.trim()
208+
? session.title.trim()
209+
: (typeof session.sessionId === 'string' && session.sessionId.trim() ? session.sessionId.trim() : '未命名会话');
210+
const sessionEntry = {
211+
key: `${source}:${session.sessionId || ''}:${session.filePath || normalizedTitle}`,
212+
title: normalizedTitle,
213+
source,
214+
sourceLabel: source === 'codex' ? 'Codex' : 'Claude Code',
215+
cwd,
216+
messageCount,
217+
updatedAt: session.updatedAt || '',
218+
updatedAtMs,
219+
updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || ''),
220+
hasExactMessageCount: session.__messageCountExact === true
221+
};
222+
recentSessions.push(sessionEntry);
223+
topSessionsByMessages.push(sessionEntry);
176224
}
177225

178226
const totalSessions = codexTotal + claudeTotal;
@@ -181,16 +229,41 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
181229
{ key: 'claude', label: 'Claude', value: claudeTotal }
182230
].map((item) => ({
183231
...item,
184-
percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0
232+
percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0,
233+
messageTotal: sourceMessageTotals[item.key] || 0,
234+
messagePercent: messageTotal > 0 ? Math.round(((sourceMessageTotals[item.key] || 0) / messageTotal) * 100) : 0,
235+
avgMessages: item.value > 0 ? Math.round(((sourceMessageTotals[item.key] || 0) / item.value) * 10) / 10 : 0
185236
}));
186237

187238
const topPaths = [...pathMap.entries()]
188-
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-Hans-CN'))
239+
.sort((a, b) => b[1].count - a[1].count || b[1].messageTotal - a[1].messageTotal || a[0].localeCompare(b[0], 'zh-Hans-CN'))
189240
.slice(0, 5)
190-
.map(([pathValue, count]) => ({ path: pathValue, count }));
241+
.map(([pathValue, meta]) => ({
242+
path: pathValue,
243+
count: meta.count,
244+
messageTotal: meta.messageTotal,
245+
updatedAtLabel: meta.updatedAtMs ? formatSessionTimelineTimestamp(new Date(meta.updatedAtMs).toISOString()) : ''
246+
}));
247+
248+
const sortedRecentSessions = recentSessions
249+
.sort((a, b) => b.updatedAtMs - a.updatedAtMs || b.messageCount - a.messageCount || a.title.localeCompare(b.title, 'zh-Hans-CN'))
250+
.slice(0, 6);
251+
252+
const sortedTopSessionsByMessages = topSessionsByMessages
253+
.sort((a, b) => b.messageCount - a.messageCount || b.updatedAtMs - a.updatedAtMs || a.title.localeCompare(b.title, 'zh-Hans-CN'))
254+
.slice(0, 6);
191255

192256
const maxSessionBucket = buckets.reduce((max, item) => Math.max(max, item.totalSessions), 0);
193257
const maxMessageBucket = buckets.reduce((max, item) => Math.max(max, item.totalMessages), 0);
258+
const maxHourCount = hourCounts.reduce((max, item) => Math.max(max, item.count), 0);
259+
const maxWeekdayCount = weekdayCounts.reduce((max, item) => Math.max(max, item.count), 0);
260+
const busiestDay = [...buckets]
261+
.sort((a, b) => b.totalSessions - a.totalSessions || b.totalMessages - a.totalMessages || a.key.localeCompare(b.key, 'zh-Hans-CN'))[0] || null;
262+
const busiestHour = [...hourCounts]
263+
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key, 'zh-Hans-CN'))[0] || null;
264+
const activeDays = buckets.filter((item) => item.totalSessions > 0).length;
265+
const avgMessagesPerSession = totalSessions > 0 ? Math.round((messageTotal / totalSessions) * 10) / 10 : 0;
266+
const avgSessionsPerActiveDay = activeDays > 0 ? Math.round((totalSessions / activeDays) * 10) / 10 : 0;
194267

195268
return {
196269
range,
@@ -200,12 +273,41 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
200273
totalMessages: messageTotal,
201274
codexTotal,
202275
claudeTotal,
203-
activeDays: buckets.filter((item) => item.totalSessions > 0).length
276+
activeDays,
277+
avgMessagesPerSession,
278+
avgSessionsPerActiveDay,
279+
busiestDay: busiestDay
280+
? {
281+
key: busiestDay.key,
282+
label: busiestDay.label,
283+
totalSessions: busiestDay.totalSessions,
284+
totalMessages: busiestDay.totalMessages
285+
}
286+
: null,
287+
busiestHour: busiestHour
288+
? {
289+
key: busiestHour.key,
290+
label: `${busiestHour.label}:00`,
291+
count: busiestHour.count
292+
}
293+
: null
204294
},
205295
sourceShare,
206296
topPaths,
297+
recentSessions: sortedRecentSessions,
298+
topSessionsByMessages: sortedTopSessionsByMessages,
299+
hourActivity: hourCounts.map((item) => ({
300+
...item,
301+
percent: maxHourCount > 0 ? Math.round((item.count / maxHourCount) * 100) : 0
302+
})),
303+
weekdayActivity: weekdayCounts.map((item) => ({
304+
...item,
305+
percent: maxWeekdayCount > 0 ? Math.round((item.count / maxWeekdayCount) * 100) : 0
306+
})),
207307
maxSessionBucket,
208-
maxMessageBucket
308+
maxMessageBucket,
309+
maxHourCount,
310+
maxWeekdayCount
209311
};
210312
}
211313

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,26 @@ export function createSessionComputed() {
112112
sessionUsageSummaryCards() {
113113
const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
114114
? this.sessionUsageCharts.summary
115-
: { totalSessions: 0, totalMessages: 0, activeDays: 0 };
115+
: { totalSessions: 0, totalMessages: 0, activeDays: 0, avgMessagesPerSession: 0, busiestDay: null, busiestHour: null };
116116
return [
117117
{ key: 'sessions', label: '总会话数', value: summary.totalSessions || 0 },
118118
{ key: 'messages', label: '总消息数', value: summary.totalMessages || 0 },
119-
{ key: 'days', label: '活跃天数', value: summary.activeDays || 0 }
119+
{ key: 'days', label: '活跃天数', value: summary.activeDays || 0 },
120+
{ key: 'avg-messages', label: '平均每会话消息', value: summary.avgMessagesPerSession || 0 },
121+
{
122+
key: 'busiest-day',
123+
label: '最忙日',
124+
value: summary.busiestDay && summary.busiestDay.totalSessions > 0
125+
? `${summary.busiestDay.label} · ${summary.busiestDay.totalSessions}`
126+
: '暂无'
127+
},
128+
{
129+
key: 'busiest-hour',
130+
label: '高峰时段',
131+
value: summary.busiestHour && summary.busiestHour.count > 0
132+
? `${summary.busiestHour.label} · ${summary.busiestHour.count}`
133+
: '暂无'
134+
}
120135
];
121136
},
122137
visibleSessionTrashItems() {

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

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,48 @@
4646
</section>
4747

4848
<section class="usage-card">
49-
<div class="usage-card-title">来源占比</div>
49+
<div class="usage-card-title">消息趋势</div>
50+
<div class="usage-bars">
51+
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key + '-messages'" class="usage-bar-group">
52+
<div class="usage-bar-stack">
53+
<div class="usage-bar codex" style="width:100%" :style="{ height: ((bucket.totalMessages / Math.max(sessionUsageCharts.maxMessageBucket, 1)) * 100) + '%' }" :title="`${bucket.totalMessages} messages`"></div>
54+
</div>
55+
<div class="usage-bar-label">{{ bucket.label }}</div>
56+
</div>
57+
</div>
58+
</section>
59+
60+
<section class="usage-card">
61+
<div class="usage-card-title">活跃时段</div>
62+
<div class="usage-mini-bars">
63+
<div v-for="item in sessionUsageCharts.hourActivity" :key="item.key" class="usage-mini-bar-group">
64+
<div class="usage-mini-bar-track" :title="`${item.label}:00 · ${item.count} 次会话`">
65+
<div class="usage-mini-bar-fill" :style="{ height: item.percent + '%' }"></div>
66+
</div>
67+
<div class="usage-mini-bar-label">{{ item.label }}</div>
68+
</div>
69+
</div>
70+
</section>
71+
72+
<section class="usage-card">
73+
<div class="usage-card-title">来源洞察</div>
5074
<div class="usage-list">
5175
<div v-for="item in sessionUsageCharts.sourceShare" :key="item.key" class="usage-list-row">
5276
<div class="usage-list-label">{{ item.label }}</div>
5377
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: item.percent + '%' }"></div></div>
5478
<div class="usage-list-value">{{ item.percent }}%</div>
79+
<div class="usage-list-subvalue">{{ item.value }} 会话 · {{ item.messageTotal }} 消息 · 均值 {{ item.avgMessages }}</div>
5580
</div>
5681
</div>
5782
</section>
5883

5984
<section class="usage-card">
60-
<div class="usage-card-title">消息趋势</div>
61-
<div class="usage-bars">
62-
<div v-for="bucket in sessionUsageCharts.buckets" :key="bucket.key + '-messages'" class="usage-bar-group">
63-
<div class="usage-bar-stack">
64-
<div class="usage-bar codex" style="width:100%" :style="{ height: ((bucket.totalMessages / Math.max(sessionUsageCharts.maxMessageBucket, 1)) * 100) + '%' }" :title="`${bucket.totalMessages} messages`"></div>
65-
</div>
66-
<div class="usage-bar-label">{{ bucket.label }}</div>
85+
<div class="usage-card-title">工作日分布</div>
86+
<div class="usage-list">
87+
<div v-for="item in sessionUsageCharts.weekdayActivity" :key="item.key" class="usage-list-row usage-list-row-compact">
88+
<div class="usage-list-label">{{ item.label }}</div>
89+
<div class="usage-progress"><div class="usage-progress-fill" :style="{ width: item.percent + '%' }"></div></div>
90+
<div class="usage-list-value">{{ item.count }}</div>
6791
</div>
6892
</div>
6993
</section>
@@ -76,6 +100,43 @@
76100
<div class="usage-list-label">{{ item.count }} 次</div>
77101
<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>
78102
<div class="usage-list-value" :title="item.path">{{ item.path }}</div>
103+
<div class="usage-list-subvalue">{{ item.messageTotal }} 消息<span v-if="item.updatedAtLabel"> · 最近 {{ item.updatedAtLabel }}</span></div>
104+
</div>
105+
</div>
106+
</section>
107+
108+
<section class="usage-card">
109+
<div class="usage-card-title">近期活跃会话</div>
110+
<div v-if="!sessionUsageCharts.recentSessions.length" class="usage-list-value">暂无会话数据</div>
111+
<div v-else class="usage-session-list">
112+
<div v-for="item in sessionUsageCharts.recentSessions" :key="item.key" class="usage-session-item">
113+
<div class="usage-session-row">
114+
<div class="usage-session-title" :title="item.title">{{ item.title }}</div>
115+
<span :class="['pill', item.source === 'codex' ? 'configured' : 'empty']">{{ item.sourceLabel }}</span>
116+
</div>
117+
<div class="usage-session-meta">
118+
<span>{{ item.messageCount }} 消息</span>
119+
<span v-if="item.updatedAtLabel">{{ item.updatedAtLabel }}</span>
120+
</div>
121+
<div v-if="item.cwd" class="usage-session-path" :title="item.cwd">{{ item.cwd }}</div>
122+
</div>
123+
</div>
124+
</section>
125+
126+
<section class="usage-card">
127+
<div class="usage-card-title">消息密度最高</div>
128+
<div v-if="!sessionUsageCharts.topSessionsByMessages.length" class="usage-list-value">暂无会话数据</div>
129+
<div v-else class="usage-session-list">
130+
<div v-for="item in sessionUsageCharts.topSessionsByMessages" :key="item.key + '-messages'" class="usage-session-item">
131+
<div class="usage-session-row">
132+
<div class="usage-session-title" :title="item.title">{{ item.title }}</div>
133+
<div class="usage-inline-stat">{{ item.messageCount }} 消息</div>
134+
</div>
135+
<div class="usage-session-meta">
136+
<span>{{ item.sourceLabel }}</span>
137+
<span v-if="item.updatedAtLabel">{{ item.updatedAtLabel }}</span>
138+
</div>
139+
<div v-if="item.cwd" class="usage-session-path" :title="item.cwd">{{ item.cwd }}</div>
79140
</div>
80141
</div>
81142
</section>

0 commit comments

Comments
 (0)