From d18b384b1a6acad401af498952f333c9ebcb1ab3 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 9 Apr 2026 14:34:45 +0000 Subject: [PATCH 1/4] feat(usage): add local insights dashboard --- tests/unit/session-usage.test.mjs | 14 +++ web-ui/logic.sessions.mjs | 114 +++++++++++++++++++-- web-ui/modules/app.computed.session.mjs | 19 +++- web-ui/partials/index/panel-usage.html | 77 ++++++++++++-- web-ui/styles/sessions-usage.css | 127 ++++++++++++++++++++++++ 5 files changed, 335 insertions(+), 16 deletions(-) diff --git a/tests/unit/session-usage.test.mjs b/tests/unit/session-usage.test.mjs index a52d73c..3ce0f14 100644 --- a/tests/unit/session-usage.test.mjs +++ b/tests/unit/session-usage.test.mjs @@ -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); @@ -40,4 +51,7 @@ 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, []); }); diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 0c2ee8d..5f9227b 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -145,6 +145,20 @@ 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) { if (!session || typeof session !== 'object') continue; @@ -169,10 +183,44 @@ 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}`, + 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; @@ -181,16 +229,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, @@ -200,12 +273,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 }; } diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 25913d4..95b4f46 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -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() { diff --git a/web-ui/partials/index/panel-usage.html b/web-ui/partials/index/panel-usage.html index 80ab3ba..e8f4d28 100644 --- a/web-ui/partials/index/panel-usage.html +++ b/web-ui/partials/index/panel-usage.html @@ -46,24 +46,48 @@
-
来源占比
+
消息趋势
+
+
+
+
+
+
{{ bucket.label }}
+
+
+
+ +
+
活跃时段
+
+
+
+
+
+
{{ item.label }}
+
+
+
+ +
+
来源洞察
{{ item.label }}
{{ item.percent }}%
+
{{ item.value }} 会话 · {{ item.messageTotal }} 消息 · 均值 {{ item.avgMessages }}
-
消息趋势
-
-
-
-
-
-
{{ bucket.label }}
+
工作日分布
+
+
+
{{ item.label }}
+
+
{{ item.count }}
@@ -76,6 +100,43 @@
{{ item.count }} 次
{{ item.path }}
+
{{ item.messageTotal }} 消息 · 最近 {{ item.updatedAtLabel }}
+ + + + +
+
近期活跃会话
+
暂无会话数据
+
+
+
+
{{ item.title }}
+ {{ item.sourceLabel }} +
+
+ {{ item.messageCount }} 消息 + {{ item.updatedAtLabel }} +
+
{{ item.cwd }}
+
+
+
+ +
+
消息密度最高
+
暂无会话数据
+
+
+
+
{{ item.title }}
+
{{ item.messageCount }} 消息
+
+
+ {{ item.sourceLabel }} + {{ item.updatedAtLabel }} +
+
{{ item.cwd }}
diff --git a/web-ui/styles/sessions-usage.css b/web-ui/styles/sessions-usage.css index edd881c..30234e9 100644 --- a/web-ui/styles/sessions-usage.css +++ b/web-ui/styles/sessions-usage.css @@ -222,12 +222,28 @@ min-width: 0; } +.usage-list-label { + font-weight: 600; + color: var(--color-text-primary); +} + .usage-list-value { word-break: break-word; overflow-wrap: anywhere; text-align: right; } +.usage-list-subvalue { + grid-column: 1 / -1; + font-size: 11px; + color: var(--color-text-tertiary, var(--color-text-secondary)); + line-height: 1.45; +} + +.usage-list-row-compact { + grid-template-columns: minmax(52px, 64px) minmax(0, 1fr) minmax(32px, auto); +} + .usage-progress { height: 8px; border-radius: 999px; @@ -241,6 +257,104 @@ background: linear-gradient(90deg, var(--color-brand), #8b6bd6); } +.usage-mini-bars { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 10px 8px; + align-items: end; +} + +.usage-mini-bar-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} + +.usage-mini-bar-track { + width: 100%; + min-width: 16px; + max-width: 24px; + height: 72px; + border-radius: 10px; + background: rgba(71, 60, 52, 0.08); + display: flex; + align-items: flex-end; + overflow: hidden; +} + +.usage-mini-bar-fill { + width: 100%; + min-height: 6px; + border-radius: 10px 10px 4px 4px; + background: linear-gradient(180deg, #8b6bd6 0%, var(--color-brand) 100%); +} + +.usage-mini-bar-label { + font-size: 10px; + color: var(--color-text-secondary); +} + +.usage-session-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.usage-session-item { + padding: 12px; + border-radius: 12px; + background: var(--color-surface-alt); + border: 1px solid var(--color-border-soft); + min-width: 0; +} + +.usage-session-row { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; + min-width: 0; +} + +.usage-session-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.usage-inline-stat { + font-size: 11px; + color: var(--color-brand-dark); + background: var(--color-brand-light); + border-radius: 999px; + padding: 4px 8px; + flex-shrink: 0; + font-weight: 700; +} + +.usage-session-meta { + display: flex; + gap: 10px; + flex-wrap: wrap; + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 6px; +} + +.usage-session-path { + margin-top: 6px; + font-size: 11px; + color: var(--color-text-secondary); + word-break: break-word; + overflow-wrap: anywhere; +} + .usage-empty { padding: 24px 16px; border-radius: 16px; @@ -261,10 +375,18 @@ gap: 6px; } + .usage-list-subvalue { + grid-column: auto; + } + .usage-list-value { text-align: left; } + .usage-mini-bars { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } + .usage-bar-group { flex-basis: 36px; min-width: 36px; @@ -273,4 +395,9 @@ .usage-bar-stack { max-width: 28px; } + + .usage-session-row { + flex-direction: column; + align-items: stretch; + } } From ab81d55c0673a55dd1bbfa58f7f413f84b89b48b Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 9 Apr 2026 14:54:01 +0000 Subject: [PATCH 2/4] fix(usage): use local time for activity insights --- web-ui/logic.sessions.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 5f9227b..8ba4bd0 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -185,11 +185,11 @@ export function buildUsageChartGroups(sessions = [], options = {}) { messageTotal += messageCount; sourceMessageTotals[source] += messageCount; - const utcHour = stamp.getUTCHours(); - if (hourCounts[utcHour]) { - hourCounts[utcHour].count += 1; + const localHour = stamp.getHours(); + if (hourCounts[localHour]) { + hourCounts[localHour].count += 1; } - const dayIndex = (stamp.getUTCDay() + 6) % 7; + const dayIndex = (stamp.getDay() + 6) % 7; if (weekdayCounts[dayIndex]) { weekdayCounts[dayIndex].count += 1; } From 5c36572c8a01d04e4da7173716454069bb7025fd Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 9 Apr 2026 15:03:19 +0000 Subject: [PATCH 3/4] fix(usage): stabilize insights session rendering --- tests/unit/session-usage.test.mjs | 15 +++++++++++++++ web-ui/logic.sessions.mjs | 13 ++++++++++--- web-ui/styles/sessions-usage.css | 4 ++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/unit/session-usage.test.mjs b/tests/unit/session-usage.test.mjs index 3ce0f14..9ed0e23 100644 --- a/tests/unit/session-usage.test.mjs +++ b/tests/unit/session-usage.test.mjs @@ -55,3 +55,18 @@ test('buildUsageChartGroups ignores invalid sessions and keeps empty buckets sta 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); +}); diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index 8ba4bd0..aa1684d 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -160,7 +160,7 @@ export function buildUsageChartGroups(sessions = [], options = {}) { 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; @@ -208,7 +208,14 @@ export function buildUsageChartGroups(sessions = [], options = {}) { ? session.title.trim() : (typeof session.sessionId === 'string' && session.sessionId.trim() ? session.sessionId.trim() : '未命名会话'); const sessionEntry = { - key: `${source}:${session.sessionId || ''}:${session.filePath || normalizedTitle}`, + key: [ + source, + session.sessionId || '', + session.filePath || normalizedTitle, + String(updatedAtMs), + String(messageCount), + String(sessionIndex) + ].join(':'), title: normalizedTitle, source, sourceLabel: source === 'codex' ? 'Codex' : 'Claude Code', @@ -220,7 +227,7 @@ export function buildUsageChartGroups(sessions = [], options = {}) { hasExactMessageCount: session.__messageCountExact === true }; recentSessions.push(sessionEntry); - topSessionsByMessages.push(sessionEntry); + topSessionsByMessages.push({ ...sessionEntry }); } const totalSessions = codexTotal + claudeTotal; diff --git a/web-ui/styles/sessions-usage.css b/web-ui/styles/sessions-usage.css index 30234e9..afdfbf2 100644 --- a/web-ui/styles/sessions-usage.css +++ b/web-ui/styles/sessions-usage.css @@ -259,7 +259,7 @@ .usage-mini-bars { display: grid; - grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 10px 8px; align-items: end; } @@ -384,7 +384,7 @@ } .usage-mini-bars { - grid-template-columns: repeat(8, minmax(0, 1fr)); + grid-template-columns: repeat(12, minmax(0, 1fr)); } .usage-bar-group { From 562da99e65327df71dfc867447629257d91772d8 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Thu, 9 Apr 2026 15:10:42 +0000 Subject: [PATCH 4/4] fix(usage): align activity buckets with trend data --- web-ui/logic.sessions.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web-ui/logic.sessions.mjs b/web-ui/logic.sessions.mjs index aa1684d..4ca5a2b 100644 --- a/web-ui/logic.sessions.mjs +++ b/web-ui/logic.sessions.mjs @@ -185,11 +185,11 @@ export function buildUsageChartGroups(sessions = [], options = {}) { messageTotal += messageCount; sourceMessageTotals[source] += messageCount; - const localHour = stamp.getHours(); - if (hourCounts[localHour]) { - hourCounts[localHour].count += 1; + const utcHour = stamp.getUTCHours(); + if (hourCounts[utcHour]) { + hourCounts[utcHour].count += 1; } - const dayIndex = (stamp.getDay() + 6) % 7; + const dayIndex = (stamp.getUTCDay() + 6) % 7; if (weekdayCounts[dayIndex]) { weekdayCounts[dayIndex].count += 1; }