From ace7f7b6579891c7d04fc968d52571a81711f8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 11:37:49 +0800 Subject: [PATCH] feat: add desktop token usage activity view Expose local Claude Code CLI transcript usage in Settings so users can inspect recent token consumption and daily activity without leaving the desktop app. The page uses server-side transcript aggregation for session, message, tool, model-token, and subagent token data. Daily token buckets use assistant message timestamps, and daily session counts use active parent sessions for the same date bucket so resumed sessions and cross-midnight work do not produce token-only days. Cache accounting is bumped to v5 to force recomputation under the corrected daily semantics. Constraint: Usage data must come from local Claude Code CLI transcripts rather than mock/demo data. Constraint: Desktop navigation keeps Token usage directly above Diagnostics. Rejected: Bucket all token usage by session start date | hides resumed-session and cross-midnight consumption from the actual day it was spent. Confidence: high Scope-risk: moderate Directive: Keep daily token and daily session counts on the same date-bucketing semantics. Tested: bun run check:desktop Tested: bun run check:server Tested: Browser verification for Token usage in English and Chinese locale date labels Not-tested: Full bun run verify quality gate --- .../src/__tests__/generalSettings.test.tsx | 16 + desktop/src/api/activityStats.ts | 70 +++ desktop/src/i18n/locales/en.ts | 26 ++ desktop/src/i18n/locales/zh.ts | 26 ++ desktop/src/pages/ActivitySettings.test.tsx | 117 +++++ desktop/src/pages/ActivitySettings.tsx | 433 ++++++++++++++++++ desktop/src/pages/Settings.tsx | 3 + desktop/src/stores/uiStore.ts | 1 + src/server/__tests__/settings.test.ts | 58 ++- src/server/api/activityStats.ts | 43 ++ src/server/router.ts | 4 + src/utils/__tests__/stats.test.ts | 262 +++++++++++ src/utils/stats.ts | 151 +++--- src/utils/statsCache.ts | 12 +- 14 files changed, 1164 insertions(+), 58 deletions(-) create mode 100644 desktop/src/api/activityStats.ts create mode 100644 desktop/src/pages/ActivitySettings.test.tsx create mode 100644 desktop/src/pages/ActivitySettings.tsx create mode 100644 src/server/api/activityStats.ts create mode 100644 src/utils/__tests__/stats.test.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index e03dbe24..70a1ed19 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -63,6 +63,10 @@ vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) +vi.mock('../pages/ActivitySettings', () => ({ + ActivitySettings: () =>
Activity Settings Mock
, +})) + vi.mock('../stores/agentStore', () => ({ useAgentStore: () => ({ activeAgents: [], @@ -167,6 +171,18 @@ describe('Settings > General tab', () => { expect(toggle).toBeChecked() }) + it('opens the Token usage tab from Settings navigation above Diagnostics', () => { + render() + + const usageTab = screen.getByText('Token usage') + const diagnosticsTab = screen.getByText('Diagnostics') + expect((usageTab.compareDocumentPosition(diagnosticsTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + + fireEvent.click(usageTab) + + expect(screen.getByText('Activity Settings Mock')).toBeInTheDocument() + }) + it('lets the user disable WebFetch preflight skipping', () => { render() diff --git a/desktop/src/api/activityStats.ts b/desktop/src/api/activityStats.ts new file mode 100644 index 00000000..bd7d274c --- /dev/null +++ b/desktop/src/api/activityStats.ts @@ -0,0 +1,70 @@ +import { api } from './client' + +export type ActivityStatsRange = '7d' | '30d' | 'all' + +export type DailyActivity = { + date: string + messageCount: number + sessionCount: number + toolCallCount: number +} + +export type DailyModelTokens = { + date: string + tokensByModel: Record +} + +export type StreakInfo = { + currentStreak: number + longestStreak: number + currentStreakStart: string | null + longestStreakStart: string | null + longestStreakEnd: string | null +} + +export type SessionStats = { + sessionId: string + duration: number + messageCount: number + timestamp: string +} + +export type ActivityStats = { + totalSessions: number + totalMessages: number + totalDays: number + activeDays: number + streaks: StreakInfo + dailyActivity: DailyActivity[] + dailyModelTokens: DailyModelTokens[] + longestSession: SessionStats | null + modelUsage: Record + firstSessionDate: string | null + lastSessionDate: string | null + peakActivityDay: string | null + peakActivityHour: number | null + totalSpeculationTimeSavedMs: number +} + +export type ActivityStatsApiResponse = { + stats: ActivityStats + range: ActivityStatsRange + generatedAt: string +} + +export type ActivityStatsResponse = ActivityStats & { + range: ActivityStatsRange + generatedAt: string +} + +export const activityStatsApi = { + async getStats(range: ActivityStatsRange = 'all'): Promise { + const suffix = range === 'all' ? '' : `/${range}` + const response = await api.get(`/api/activity-stats${suffix}`, { timeout: 120_000 }) + return { + ...response.stats, + range: response.range, + generatedAt: response.generatedAt, + } + }, +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bd824673..a6cb8d6b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -89,6 +89,7 @@ export const en = { 'settings.title': 'Settings', 'settings.tab.providers': 'Providers', 'settings.tab.permissions': 'Permissions', + 'settings.tab.activity': 'Token usage', 'settings.tab.general': 'General', 'settings.tab.terminal': 'Terminal', 'settings.tab.skills': 'Skills', @@ -96,6 +97,31 @@ export const en = { 'settings.tab.plugins': 'Plugins', 'settings.tab.diagnostics': 'Diagnostics', + // Settings > Usage + 'settings.activity.title': 'Token usage', + 'settings.activity.subtitleLoading': 'Based on local Claude Code CLI session transcripts', + 'settings.activity.metric.yesterday': 'Yesterday', + 'settings.activity.metric.today': 'Today', + 'settings.activity.metric.last4': 'Last 4 days', + 'settings.activity.metric.last30': '30 days', + 'settings.activity.metric.streak': 'Streak', + 'settings.activity.metric.bestStreak': 'best {days} days', + 'settings.activity.heatmapLabel': 'Token usage by day', + 'settings.activity.count.sessionOne': '{count} session', + 'settings.activity.count.sessionOther': '{count} sessions', + 'settings.activity.count.dayOne': '{count} day', + 'settings.activity.count.dayOther': '{count} days', + 'settings.activity.weekday.mon': 'Mon', + 'settings.activity.weekday.wed': 'Wed', + 'settings.activity.weekday.fri': 'Fri', + 'settings.activity.selectedDay': 'Selected day', + 'settings.activity.sessions': 'Sessions', + 'settings.activity.tokens': 'Tokens', + 'settings.activity.messages': 'Messages', + 'settings.activity.tools': 'Tools', + 'settings.activity.less': 'Less', + 'settings.activity.more': 'More', + // Settings > Terminal 'settings.terminal.title': 'Terminal', 'settings.terminal.description': 'Run host-machine commands for plugin, skill, and MCP setup. The desktop app includes claude-haha; replace documented claude with claude-haha , for example: claude-haha plugin install ... or claude-haha mcp add ...', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a6f3560c..66a1dde3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -91,6 +91,7 @@ export const zh: Record = { 'settings.title': '设置', 'settings.tab.providers': '服务商', 'settings.tab.permissions': '权限', + 'settings.tab.activity': 'Token 用量', 'settings.tab.general': '通用', 'settings.tab.terminal': '终端', 'settings.tab.skills': '技能', @@ -98,6 +99,31 @@ export const zh: Record = { 'settings.tab.plugins': '插件', 'settings.tab.diagnostics': '诊断', + // Settings > Usage + 'settings.activity.title': 'Token 用量', + 'settings.activity.subtitleLoading': '基于本机 Claude Code CLI 会话记录统计', + 'settings.activity.metric.yesterday': '昨天', + 'settings.activity.metric.today': '今天', + 'settings.activity.metric.last4': '近 4 天', + 'settings.activity.metric.last30': '30 天', + 'settings.activity.metric.streak': '连续活跃', + 'settings.activity.metric.bestStreak': '最长 {days} 天', + 'settings.activity.heatmapLabel': '按天统计的 Token 用量', + 'settings.activity.count.sessionOne': '{count} 次会话', + 'settings.activity.count.sessionOther': '{count} 次会话', + 'settings.activity.count.dayOne': '{count} 天', + 'settings.activity.count.dayOther': '{count} 天', + 'settings.activity.weekday.mon': '周一', + 'settings.activity.weekday.wed': '周三', + 'settings.activity.weekday.fri': '周五', + 'settings.activity.selectedDay': '选中日期', + 'settings.activity.sessions': '会话', + 'settings.activity.tokens': 'Token', + 'settings.activity.messages': '消息', + 'settings.activity.tools': '工具', + 'settings.activity.less': '少', + 'settings.activity.more': '多', + // Settings > Terminal 'settings.terminal.title': '终端', 'settings.terminal.description': '直接运行宿主机命令,用于安装插件、Skills、MCP 等扩展。桌面端已内置 claude-haha;文档里的 claude <参数> 可替换成 claude-haha <参数>,例如 claude-haha plugin install ... 或 claude-haha mcp add ...', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx new file mode 100644 index 00000000..a21bbbca --- /dev/null +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { ActivitySettings } from './ActivitySettings' +import { useSettingsStore } from '../stores/settingsStore' + +const { getStatsMock } = vi.hoisted(() => ({ + getStatsMock: vi.fn(), +})) + +vi.mock('../api/activityStats', () => ({ + activityStatsApi: { + getStats: getStatsMock, + }, +})) + +const activityResponse = { + range: 'all', + generatedAt: '2026-05-09T12:00:00.000Z', + totalSessions: 52, + totalMessages: 900, + totalDays: 365, + activeDays: 20, + streaks: { + currentStreak: 9, + longestStreak: 18, + currentStreakStart: '2026-05-01', + longestStreakStart: '2026-03-01', + longestStreakEnd: '2026-03-18', + }, + dailyActivity: [ + { date: '2026-04-20', sessionCount: 38, messageCount: 420, toolCallCount: 160 }, + { date: '2026-05-07', sessionCount: 2, messageCount: 30, toolCallCount: 12 }, + { date: '2026-05-09', sessionCount: 4, messageCount: 58, toolCallCount: 21 }, + ], + dailyModelTokens: [ + { date: '2026-04-20', tokensByModel: { 'claude-sonnet': 2_672_000 } }, + { date: '2026-05-07', tokensByModel: { 'claude-sonnet': 64_000 } }, + { date: '2026-05-09', tokensByModel: { 'claude-sonnet': 128_000 } }, + ], + longestSession: null, + modelUsage: {}, + firstSessionDate: '2025-06-01T10:00:00.000Z', + lastSessionDate: '2026-05-09T11:00:00.000Z', + peakActivityDay: '2026-04-20', + peakActivityHour: 14, + totalSpeculationTimeSavedMs: 0, +} + +async function flushActivityLoad() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('ActivitySettings', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-09T12:00:00')) + getStatsMock.mockReset() + getStatsMock.mockResolvedValue(activityResponse) + useSettingsStore.setState({ locale: 'en' }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders summary metrics and a GitHub-style trailing heatmap without future days', async () => { + render() + + await flushActivityLoad() + + expect(getStatsMock).toHaveBeenCalledWith('all') + + expect(screen.getByText('Token usage')).toBeInTheDocument() + expect(screen.getByText('2025.05 - 2026.05')).toBeInTheDocument() + expect(screen.getByText('Based on local Claude Code CLI session transcripts')).toBeInTheDocument() + expect(screen.getByText('Yesterday')).toBeInTheDocument() + expect(screen.getByText('Today')).toBeInTheDocument() + expect(screen.getByText('30 days')).toBeInTheDocument() + expect(screen.getByText('0 tokens')).toBeInTheDocument() + expect(screen.getByText('128K tokens')).toBeInTheDocument() + expect(screen.getByText('2.9M tokens')).toBeInTheDocument() + expect(screen.getAllByText('May').length).toBeGreaterThan(0) + expect(screen.queryByText('5月')).not.toBeInTheDocument() + expect( + screen.getByText('Today').compareDocumentPosition(screen.getByText('Yesterday')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + const todayCell = screen.getByRole('gridcell', { + name: /May 9, 2026: 4 sessions, 128K tokens/i, + }) + expect(todayCell).toBeInTheDocument() + expect(screen.queryByRole('gridcell', { name: /May 10, 2026/i })).not.toBeInTheDocument() + }) + + it('shows a compact hover preview without a persistent selected-day panel', async () => { + render() + + await flushActivityLoad() + + const todayCell = screen.getByRole('gridcell', { + name: /May 9, 2026: 4 sessions, 128K tokens/i, + }) + + fireEvent.mouseEnter(todayCell) + const tooltip = screen.getByRole('tooltip') + expect(tooltip).toHaveTextContent('May 9, 2026') + expect(tooltip).toHaveTextContent('4 sessions · 128K tokens') + expect(tooltip).not.toHaveTextContent(/messages|tools/i) + expect(screen.queryByText('Selected day')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx new file mode 100644 index 00000000..3cbd71e3 --- /dev/null +++ b/desktop/src/pages/ActivitySettings.tsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats' +import { type Locale, useTranslation } from '../i18n' +import { useSettingsStore } from '../stores/settingsStore' + +type HeatmapDay = { + date: string + sessionCount: number + messageCount: number + toolCallCount: number + tokens: number + level: number +} + +type SummaryMetric = { + label: string + value: string + detail?: string +} + +const WEEK_COUNT = 52 +const WEEKDAY_LABEL_KEYS = [ + 'settings.activity.weekday.mon', + 'settings.activity.weekday.wed', + 'settings.activity.weekday.fri', +] as const +const HEAT_CELL_GAP = 3 +const HEAT_LABEL_WIDTH = 38 +const HEAT_CELL_MIN = 6 +const HEAT_CELL_MAX = 22 +const TOOLTIP_WIDTH = 172 +const HEAT_COLORS = [ + 'var(--color-surface-container)', + '#f5d4c4', + '#eea47f', + '#df6b47', + '#bd432c', +] +const DATE_LOCALES: Record = { + en: 'en-US', + zh: 'zh-CN', +} + +function localDateKey(date: Date) { + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +function parseLocalDate(dateKey: string) { + return new Date(`${dateKey}T00:00:00`) +} + +function addDays(date: Date, days: number) { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +function startOfWeek(date: Date) { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + next.setDate(next.getDate() - next.getDay()) + return next +} + +function formatDateLabel(dateKey: string, locale: Locale) { + return parseLocalDate(dateKey).toLocaleDateString(DATE_LOCALES[locale], { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatMonthKey(dateKey: string) { + const date = parseLocalDate(dateKey) + return `${date.getFullYear()}.${`${date.getMonth() + 1}`.padStart(2, '0')}` +} + +function formatDateRange(start: string, end: string) { + return `${formatMonthKey(start)} - ${formatMonthKey(end)}` +} + +function formatTokens(tokens: number) { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(tokens >= 10_000_000_000 ? 0 : 1)}B` + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M` + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K` + return `${tokens}` +} + +function formatSessionCount(value: number, t: ReturnType) { + return t(value === 1 ? 'settings.activity.count.sessionOne' : 'settings.activity.count.sessionOther', { count: value }) +} + +function calculateHeatCellSize(width: number) { + const available = width - HEAT_LABEL_WIDTH - (WEEK_COUNT - 1) * HEAT_CELL_GAP + return Math.max(HEAT_CELL_MIN, Math.min(HEAT_CELL_MAX, Math.floor(available / WEEK_COUNT))) +} + +function sumDailyUsage(days: HeatmapDay[]) { + return days.reduce( + (sum, day) => ({ + sessions: sum.sessions + day.sessionCount, + tokens: sum.tokens + day.tokens, + }), + { sessions: 0, tokens: 0 }, + ) +} + +function getDailyTokenMap(stats: ActivityStatsResponse | null) { + const map = new Map() + for (const day of stats?.dailyModelTokens ?? []) { + const total = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0) + map.set(day.date, total) + } + return map +} + +function getHeatLevel(day: DailyActivity | undefined, tokens: number, maxScore: number) { + const sessionCount = day?.sessionCount ?? 0 + if (sessionCount === 0 && tokens === 0) return 0 + if (maxScore <= 0) return 1 + + const score = sessionCount * 3 + Math.ceil(tokens / 50_000) + const ratio = score / maxScore + if (ratio >= 0.78) return 4 + if (ratio >= 0.5) return 3 + if (ratio >= 0.24) return 2 + return 1 +} + +function buildHeatmapDays(stats: ActivityStatsResponse | null) { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const finalWeekStart = startOfWeek(today) + const start = addDays(finalWeekStart, -(WEEK_COUNT - 1) * 7) + const activityMap = new Map((stats?.dailyActivity ?? []).map((day) => [day.date, day])) + const tokenMap = getDailyTokenMap(stats) + + const scores: number[] = [] + for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { + const dateKey = localDateKey(cursor) + const day = activityMap.get(dateKey) + const tokens = tokenMap.get(dateKey) ?? 0 + scores.push((day?.sessionCount ?? 0) * 3 + Math.ceil(tokens / 50_000)) + } + const maxScore = Math.max(...scores, 0) + + const days: HeatmapDay[] = [] + for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) { + const dateKey = localDateKey(cursor) + const day = activityMap.get(dateKey) + const tokens = tokenMap.get(dateKey) ?? 0 + days.push({ + date: dateKey, + sessionCount: day?.sessionCount ?? 0, + messageCount: day?.messageCount ?? 0, + toolCallCount: day?.toolCallCount ?? 0, + tokens, + level: getHeatLevel(day, tokens, maxScore), + }) + } + + return days +} + +function buildMonthLabels(days: HeatmapDay[], locale: Locale) { + if (days.length === 0) return [] + const labels: Array<{ week: number; label: string }> = [] + const firstDay = days[0] + const lastDay = days[days.length - 1] + if (!firstDay || !lastDay) return labels + + const firstDate = parseLocalDate(firstDay.date) + const lastDate = parseLocalDate(lastDay.date) + let previousMonth = -1 + + for (let week = 0; week < WEEK_COUNT; week += 1) { + const weekDate = addDays(firstDate, week * 7) + if (weekDate > lastDate) break + if (weekDate.getMonth() !== previousMonth) { + labels.push({ + week, + label: weekDate.toLocaleDateString(DATE_LOCALES[locale], { month: 'short' }), + }) + previousMonth = weekDate.getMonth() + } + } + + return labels +} + +export function ActivitySettings() { + const t = useTranslation() + const locale = useSettingsStore((state) => state.locale) + const heatmapMeasureRef = useRef(null) + const [stats, setStats] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [hoveredDate, setHoveredDate] = useState(null) + const [focusedDate, setFocusedDate] = useState(null) + const [heatCellSize, setHeatCellSize] = useState(10) + + useEffect(() => { + let cancelled = false + setIsLoading(true) + setError(null) + + activityStatsApi.getStats('all') + .then((nextStats) => { + if (cancelled) return + setStats(nextStats) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (isLoading || error) return + const element = heatmapMeasureRef.current + if (!element) return + + const updateCellSize = () => { + const nextSize = calculateHeatCellSize(element.clientWidth) + setHeatCellSize((current) => (current === nextSize ? current : nextSize)) + } + + updateCellSize() + + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(updateCellSize) + observer.observe(element) + return () => observer.disconnect() + } + + window.addEventListener('resize', updateCellSize) + return () => window.removeEventListener('resize', updateCellSize) + }, [error, isLoading]) + + const days = useMemo(() => buildHeatmapDays(stats), [stats]) + const monthLabels = useMemo(() => buildMonthLabels(days, locale), [days, locale]) + const today = days.length > 0 ? days[days.length - 1] : null + const activeTooltipDate = hoveredDate ?? focusedDate + const tooltipDay = days.find((day) => day.date === activeTooltipDate) ?? null + const tooltipIndex = tooltipDay ? days.findIndex((day) => day.date === tooltipDay.date) : -1 + const heatGridWidth = WEEK_COUNT * heatCellSize + (WEEK_COUNT - 1) * HEAT_CELL_GAP + const heatGridHeight = 7 * heatCellSize + 6 * HEAT_CELL_GAP + const heatmapWidth = HEAT_LABEL_WIDTH + heatGridWidth + const tooltipStyle = tooltipIndex >= 0 + ? { + left: Math.max( + HEAT_LABEL_WIDTH, + Math.min( + heatmapWidth - TOOLTIP_WIDTH, + HEAT_LABEL_WIDTH + Math.floor(tooltipIndex / 7) * (heatCellSize + HEAT_CELL_GAP) - 52, + ), + ), + top: Math.max(28, 30 + (tooltipIndex % 7) * (heatCellSize + HEAT_CELL_GAP) - 50), + } + : undefined + const dateRange = days.length > 0 && days[0] && today ? formatDateRange(days[0].date, today.date) : '' + const yesterdayDate = today ? localDateKey(addDays(parseLocalDate(today.date), -1)) : null + const yesterday = days.find((day) => day.date === yesterdayDate) ?? null + const last30Usage = sumDailyUsage(days.slice(-30)) + + const metrics: SummaryMetric[] = [ + { + label: t('settings.activity.metric.today'), + value: today ? `${formatTokens(today.tokens)} tokens` : '0 tokens', + detail: today ? formatSessionCount(today.sessionCount, t) : formatSessionCount(0, t), + }, + { + label: t('settings.activity.metric.yesterday'), + value: `${formatTokens(yesterday?.tokens ?? 0)} tokens`, + detail: formatSessionCount(yesterday?.sessionCount ?? 0, t), + }, + { + label: t('settings.activity.metric.last30'), + value: `${formatTokens(last30Usage.tokens)} tokens`, + detail: formatSessionCount(last30Usage.sessions, t), + }, + ] + + return ( +
+
+
+

{t('settings.activity.title')}

+
+ {dateRange &&
{dateRange}
} +
{t('settings.activity.subtitleLoading')}
+
+
+ +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+ {metric.detail &&
{metric.detail}
} +
+ ))} +
+
+ +
+ {isLoading ? ( +
+ progress_activity + {t('common.loading')} +
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> +
+
+
+ {monthLabels.map((month) => ( +
+ {month.label} +
+ ))} +
+ +
+
+
{t(WEEKDAY_LABEL_KEYS[0])}
+
{t(WEEKDAY_LABEL_KEYS[1])}
+
{t(WEEKDAY_LABEL_KEYS[2])}
+
+ +
setHoveredDate(null)} + > + {days.map((day) => { + const isSelected = activeTooltipDate === day.date + const tooltipId = `activity-day-tooltip-${day.date}` + return ( +
+
+ + {tooltipDay && ( + + )} +
+
+ +
+ {t('settings.activity.less')} + {HEAT_COLORS.map((color) => ( +
+ + )} +
+
+ ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 14177398..b4d341ef 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -26,6 +26,7 @@ import { ComputerUseSettings } from './ComputerUseSettings' import { McpSettings } from './McpSettings' import { TerminalSettings } from './TerminalSettings' import { DiagnosticsSettings } from './DiagnosticsSettings' +import { ActivitySettings } from './ActivitySettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' @@ -72,6 +73,7 @@ export function Settings() { setActiveTab('skills')} /> setActiveTab('plugins')} /> setActiveTab('computerUse')} /> + setActiveTab('activity')} /> setActiveTab('diagnostics')} />
@@ -83,6 +85,7 @@ export function Settings() {
{activeTab === 'providers' && } {activeTab === 'permissions' && } + {activeTab === 'activity' && } {activeTab === 'general' && } {activeTab === 'adapters' && } {activeTab === 'terminal' && } diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 0d9630b8..1e4f8cfc 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -31,6 +31,7 @@ export type Toast = { export type SettingsTab = | 'providers' | 'permissions' + | 'activity' | 'general' | 'adapters' | 'terminal' diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 035d69a6..1bc3bbc1 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -2,7 +2,7 @@ * Unit tests for Settings, Models, and Status APIs */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' @@ -702,3 +702,59 @@ describe('Status API', () => { expect(res.status).toBe(404) }) }) + +// ============================================================================= +// Activity Stats API +// ============================================================================= + +describe('Activity Stats API', () => { + let handleApiRequest: typeof import('../router.js').handleApiRequest + + beforeAll(async () => { + ;({ handleApiRequest } = await import('../router.js')) + }) + + beforeEach(async () => { + await setup() + }) + + afterEach(teardown) + + it('GET /api/activity-stats should default to the all range', async () => { + const { req, url } = makeRequest('GET', '/api/activity-stats') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.range).toBe('all') + expect(body.stats.totalSessions).toBe(0) + expect(new Date(body.generatedAt).toString()).not.toBe('Invalid Date') + }) + + it('GET /api/activity-stats/:range should return stats for supported ranges', async () => { + for (const range of ['7d', '30d', 'all'] as const) { + const { req, url } = makeRequest('GET', `/api/activity-stats/${range}`) + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.range).toBe(range) + expect(body.stats).toBeDefined() + } + }) + + it('should reject non-GET methods', async () => { + const { req, url } = makeRequest('POST', '/api/activity-stats') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(405) + }) + + it('should reject unknown activity stats ranges', async () => { + const { req, url } = makeRequest('GET', '/api/activity-stats/90d') + const res = await handleApiRequest(req, url) + + expect(res.status).toBe(400) + }) +}) diff --git a/src/server/api/activityStats.ts b/src/server/api/activityStats.ts new file mode 100644 index 00000000..9829dfb3 --- /dev/null +++ b/src/server/api/activityStats.ts @@ -0,0 +1,43 @@ +import { + aggregateClaudeCodeStatsForRange, + type StatsDateRange, +} from '../../utils/stats.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +const VALID_RANGES = new Set(['7d', '30d', 'all']) + +export async function handleActivityStatsApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + if (req.method !== 'GET') { + throw methodNotAllowed(req.method) + } + + const requestedRange = segments[2] + const range: StatsDateRange = requestedRange === undefined ? 'all' : parseRange(requestedRange) + const stats = await aggregateClaudeCodeStatsForRange(range) + + return Response.json({ + stats, + range, + generatedAt: new Date().toISOString(), + }) + } catch (error) { + return errorResponse(error) + } +} + +function parseRange(range: string): StatsDateRange { + if (VALID_RANGES.has(range as StatsDateRange)) { + return range as StatsDateRange + } + + throw ApiError.badRequest(`Unknown activity stats range: ${range}`) +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} diff --git a/src/server/router.ts b/src/server/router.ts index a66e778a..771d385d 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -22,6 +22,7 @@ import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js' import { handleMcpApi } from './api/mcp.js' import { handleDiagnosticsApi } from './api/diagnostics.js' import { handleDoctorApi } from './api/doctor.js' +import { handleActivityStatsApi } from './api/activityStats.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise JSON.stringify(entry)).join('\n')}\n`, + 'utf-8', + ) +} + +function projectFile(sessionId: string): string { + return join(tmpConfigDir, 'projects', 'test-project', `${sessionId}.jsonl`) +} + +function subagentFile(sessionId: string, agentId: string): string { + return join( + tmpConfigDir, + 'projects', + 'test-project', + sessionId, + 'subagents', + `agent-${agentId}.jsonl`, + ) +} + +function totalForDate( + dailyModelTokens: Array<{ + date: string + tokensByModel: { [model: string]: number } + }>, + date: string, +): number { + return Object.values( + dailyModelTokens.find(day => day.date === date)?.tokensByModel ?? {}, + ).reduce((sum, tokens) => sum + tokens, 0) +} + +describe('activity stats token accounting', () => { + beforeEach(async () => { + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + tmpConfigDir = await mkdtemp(join(tmpdir(), 'cc-haha-stats-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + }) + + afterEach(async () => { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await rm(tmpConfigDir, { recursive: true, force: true }) + }) + + it('buckets assistant usage by message date and includes cache tokens', async () => { + const sessionStart = dateKey(-2) + const nextDay = dateKey(-1) + + await writeJsonl(projectFile('cross-midnight'), [ + userEntry('user-1', at(sessionStart, '23:55:00')), + assistantEntry( + 'assistant-1', + at(sessionStart, '23:58:00'), + { input_tokens: 1, output_tokens: 2 }, + { parentUuid: 'user-1' }, + ), + assistantEntry( + 'assistant-2', + at(nextDay, '00:05:00'), + { + input_tokens: 10, + output_tokens: 20, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 40, + }, + { parentUuid: 'assistant-1' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('all') + + expect(totalForDate(stats.dailyModelTokens, sessionStart)).toBe(3) + expect(totalForDate(stats.dailyModelTokens, nextDay)).toBe(100) + expect(stats.dailyActivity.find(day => day.date === sessionStart)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.dailyActivity.find(day => day.date === nextDay)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.modelUsage['claude-test']).toMatchObject({ + inputTokens: 11, + outputTokens: 22, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 40, + }) + }) + + it('counts subagent tokens without counting subagent transcripts as sessions', async () => { + const today = dateKey(0) + + await writeJsonl(projectFile('parent-session'), [ + userEntry('parent-user', at(today, '10:00:00')), + assistantEntry( + 'parent-assistant', + at(today, '10:01:00'), + { input_tokens: 5, output_tokens: 5 }, + { parentUuid: 'parent-user' }, + ), + ]) + await writeJsonl(subagentFile('parent-session', '001'), [ + userEntry('agent-user', at(today, '10:02:00'), true), + assistantEntry( + 'agent-assistant', + at(today, '10:03:00'), + { + input_tokens: 7, + output_tokens: 8, + cache_read_input_tokens: 9, + }, + { isSidechain: true, parentUuid: 'agent-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('all') + + expect(stats.totalSessions).toBe(1) + expect(stats.dailyActivity.find(day => day.date === today)).toMatchObject({ + sessionCount: 1, + messageCount: 2, + }) + expect(totalForDate(stats.dailyModelTokens, today)).toBe(34) + }) + + it('keeps resumed old sessions in range token totals with active daily session counts', async () => { + const oldDate = dateKey(-50) + const inRangeDate = dateKey(-1) + + await writeJsonl(projectFile('resumed-old-session'), [ + userEntry('old-user', at(oldDate, '08:00:00')), + assistantEntry( + 'recent-assistant', + at(inRangeDate, '09:00:00'), + { input_tokens: 12, output_tokens: 13 }, + { parentUuid: 'old-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('30d') + + expect(stats.totalSessions).toBe(0) + expect(totalForDate(stats.dailyModelTokens, inRangeDate)).toBe(25) + expect(totalForDate(stats.dailyModelTokens, oldDate)).toBe(0) + expect(stats.dailyActivity.find(day => day.date === inRangeDate)).toMatchObject({ + sessionCount: 1, + }) + expect(stats.dailyActivity.find(day => day.date === oldDate)).toBeUndefined() + }) + + it('does not include future transcript timestamps in bounded ranges', async () => { + const today = dateKey(0) + const future = dateKey(3) + + await writeJsonl(projectFile('future-session'), [ + userEntry('today-user', at(today, '10:00:00')), + assistantEntry( + 'future-assistant', + at(future, '10:00:00'), + { input_tokens: 100, output_tokens: 100 }, + { parentUuid: 'today-user' }, + ), + ]) + + const stats = await aggregateClaudeCodeStatsForRange('7d') + + expect(totalForDate(stats.dailyModelTokens, future)).toBe(0) + }) + + it('invalidates pre-v5 stats caches because daily activity accounting changed', async () => { + await mkdir(tmpConfigDir, { recursive: true }) + await writeFile( + join(tmpConfigDir, 'stats-cache.json'), + JSON.stringify({ + version: 3, + lastComputedDate: dateKey(-1), + dailyActivity: [{ date: dateKey(-1), messageCount: 1, sessionCount: 1, toolCallCount: 0 }], + dailyModelTokens: [{ date: dateKey(-1), tokensByModel: { stale: 1 } }], + modelUsage: {}, + totalSessions: 1, + totalMessages: 1, + longestSession: null, + firstSessionDate: null, + hourCounts: {}, + totalSpeculationTimeSavedMs: 0, + }), + 'utf-8', + ) + + const cache = await loadStatsCache() + + expect(STATS_CACHE_VERSION).toBe(5) + expect(cache.dailyModelTokens).toEqual([]) + expect(cache.totalSessions).toBe(0) + }) +}) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 7b8434ae..f5ee0ed6 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -32,7 +32,7 @@ export type DailyActivity = { export type DailyModelTokens = { date: string // YYYY-MM-DD format - tokensByModel: { [modelName: string]: number } // total tokens (input + output) per model + tokensByModel: { [modelName: string]: number } // total tokens (input + output + cache read + cache creation) per model } export type StreakInfo = { @@ -110,6 +110,40 @@ type ProcessOptions = { toDate?: string } +type UsageLike = { + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number +} + +function getTotalUsageTokens(usage: UsageLike): number { + return ( + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_read_input_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + ) +} + +function isDateInRange( + date: string, + fromDate?: string, + toDate?: string, +): boolean { + if (fromDate && isDateBefore(date, fromDate)) return false + if (toDate && isDateBefore(toDate, date)) return false + return true +} + +function getMessageDateKey( + message: Pick, +): string | null { + const messageTimestamp = new Date(message.timestamp) + if (isNaN(messageTimestamp.getTime())) return null + return toDateString(messageTimestamp) +} + /** * Process session files and extract stats. * Can filter by date range. @@ -122,6 +156,7 @@ async function processSessionFiles( const fs = getFsImplementation() const dailyActivityMap = new Map() + const dailySessionIdsMap = new Map>() const dailyModelTokensMap = new Map() const sessions: SessionStats[] = [] const hourCounts = new Map() @@ -134,6 +169,30 @@ async function processSessionFiles( // Track parent sessions that already recorded a shot count (dedup across subagents) const sessionsWithShotCount = new Set() + const getDailyActivity = (date: string): DailyActivity => { + let activity = dailyActivityMap.get(date) + if (!activity) { + activity = { + date, + messageCount: 0, + sessionCount: 0, + toolCallCount: 0, + } + dailyActivityMap.set(date, activity) + } + return activity + } + + const markSessionActiveOnDate = (date: string, parentSessionId: string) => { + let sessionIds = dailySessionIdsMap.get(date) + if (!sessionIds) { + sessionIds = new Set() + dailySessionIdsMap.set(date, sessionIds) + } + sessionIds.add(parentSessionId) + getDailyActivity(date) + } + // Process session files in parallel batches for better performance const BATCH_SIZE = 20 for (let i = 0; i < sessionFiles.length; i += BATCH_SIZE) { @@ -143,7 +202,6 @@ async function processSessionFiles( try { // If we have a fromDate filter, skip files that haven't been modified since then if (fromDate) { - let fileSize = 0 try { const fileStat = await fs.stat(sessionFile) const fileModifiedDate = toDateString(fileStat.mtime) @@ -155,24 +213,9 @@ async function processSessionFiles( skipped: true, } } - fileSize = fileStat.size } catch { // If we can't stat the file, try to read it anyway } - // For large files, peek at the session start date before reading everything. - // Sessions that pass the mtime filter but started before fromDate are skipped - // (e.g. a month-old session resumed today gets a new mtime write but old start date). - if (fileSize > 65536) { - const startDate = await readSessionStartDate(sessionFile) - if (startDate && isDateBefore(startDate, fromDate)) { - return { - sessionFile, - entries: null, - error: null, - skipped: true, - } - } - } } const entries = await readJSONLFile(sessionFile) return { sessionFile, entries, error: null, skipped: false } @@ -207,15 +250,14 @@ async function processSessionFiles( // Subagent transcripts mark all messages as sidechain. We still want // their token usage counted, but not as separate sessions. const isSubagentFile = sessionFile.includes(`${sep}subagents${sep}`) + const parentSessionId = isSubagentFile + ? basename(dirname(dirname(sessionFile))) + : sessionId // Extract shot count from PR attribution in gh pr create calls (ant-only) // This must run before the sidechain filter since subagent transcripts // mark all messages as sidechain if (feature('SHOT_STATS') && shotDistributionMap) { - const parentSessionId = isSubagentFile - ? basename(dirname(dirname(sessionFile))) - : sessionId - if (!sessionsWithShotCount.has(parentSessionId)) { const shotCount = extractShotCountFromMessages(messages) if (shotCount !== null) { @@ -253,21 +295,12 @@ async function processSessionFiles( } const dateKey = toDateString(firstTimestamp) + const includeSessionInRange = isDateInRange(dateKey, fromDate, toDate) - // Apply date filters - if (fromDate && isDateBefore(dateKey, fromDate)) continue - if (toDate && isDateBefore(toDate, dateKey)) continue - - // Track daily activity (use first message date as session date) - const existing = dailyActivityMap.get(dateKey) || { - date: dateKey, - messageCount: 0, - sessionCount: 0, - toolCallCount: 0, - } - - // Subagent files contribute tokens and tool calls, but aren't sessions. - if (!isSubagentFile) { + // Session-level aggregates still represent newly started top-level + // sessions. Daily activity is tracked below by each message date so token + // totals and visible per-day session counts share one date bucket. + if (!isSubagentFile && includeSessionInRange) { const duration = lastTimestamp.getTime() - firstTimestamp.getTime() sessions.push({ @@ -279,28 +312,34 @@ async function processSessionFiles( totalMessages += mainMessages.length - existing.sessionCount++ - existing.messageCount += mainMessages.length - const hour = firstTimestamp.getHours() hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1) } - if (!isSubagentFile || dailyActivityMap.has(dateKey)) { - dailyActivityMap.set(dateKey, existing) - } - // Process messages for tool usage and model stats for (const message of mainMessages) { + const messageDateKey = getMessageDateKey(message) + const includeMessageInRange = + messageDateKey !== null && + isDateInRange(messageDateKey, fromDate, toDate) + + if (includeMessageInRange && messageDateKey !== null) { + markSessionActiveOnDate(messageDateKey, parentSessionId) + if (!isSubagentFile) { + getDailyActivity(messageDateKey).messageCount++ + } + } + if (message.type === 'assistant') { const content = message.message?.content - if (Array.isArray(content)) { + if ( + includeMessageInRange && + messageDateKey !== null && + Array.isArray(content) + ) { for (const block of content) { if (block.type === 'tool_use') { - const activity = dailyActivityMap.get(dateKey) - if (activity) { - activity.toolCallCount++ - } + getDailyActivity(messageDateKey).toolCallCount++ } } } @@ -315,6 +354,10 @@ async function processSessionFiles( continue } + if (!includeMessageInRange || messageDateKey === null) { + continue + } + if (!modelUsageAgg[model]) { modelUsageAgg[model] = { inputTokens: 0, @@ -336,12 +379,12 @@ async function processSessionFiles( usage.cache_creation_input_tokens || 0 // Track daily tokens per model - const totalTokens = - (usage.input_tokens || 0) + (usage.output_tokens || 0) + const totalTokens = getTotalUsageTokens(usage) if (totalTokens > 0) { - const dayTokens = dailyModelTokensMap.get(dateKey) || {} + const tokenDateKey = messageDateKey + const dayTokens = dailyModelTokensMap.get(tokenDateKey) || {} dayTokens[model] = (dayTokens[model] || 0) + totalTokens - dailyModelTokensMap.set(dateKey, dayTokens) + dailyModelTokensMap.set(tokenDateKey, dayTokens) } } } @@ -349,6 +392,10 @@ async function processSessionFiles( } } + for (const [date, sessionIds] of dailySessionIdsMap) { + getDailyActivity(date).sessionCount = sessionIds.size + } + return { dailyActivity: Array.from(dailyActivityMap.values()).sort((a, b) => a.date.localeCompare(b.date), @@ -733,10 +780,12 @@ export async function aggregateClaudeCodeStatsForRange( const fromDate = new Date(today) fromDate.setDate(today.getDate() - daysBack + 1) // +1 to include today const fromDateStr = toDateString(fromDate) + const toDateStr = toDateString(today) // Process session files for the date range const stats = await processSessionFiles(allSessionFiles, { fromDate: fromDateStr, + toDate: toDateStr, }) return processedStatsToClaudeCodeStats(stats) diff --git a/src/utils/statsCache.ts b/src/utils/statsCache.ts index 109268d7..4bafe631 100644 --- a/src/utils/statsCache.ts +++ b/src/utils/statsCache.ts @@ -11,8 +11,8 @@ import { logError } from './log.js' import { jsonParse, jsonStringify } from './slowOperations.js' import type { DailyActivity, DailyModelTokens, SessionStats } from './stats.js' -export const STATS_CACHE_VERSION = 3 -const MIN_MIGRATABLE_VERSION = 1 +export const STATS_CACHE_VERSION = 5 +const MIN_MIGRATABLE_VERSION = 5 const STATS_CACHE_FILENAME = 'stats-cache.json' /** @@ -99,10 +99,10 @@ function getEmptyCache(): PersistedStatsCache { * Migrate an older cache to the current schema. * Returns null if the version is unknown or too old to migrate. * - * Preserves historical aggregates that would otherwise be lost when - * transcript files have already aged out past cleanupPeriodDays. - * Pre-migration days may undercount (e.g. v2 lacked subagent tokens); - * we accept that rather than drop the history. + * Daily activity accounting changed in v5 to count per-day active parent + * sessions from message timestamps instead of session-start dates. Older + * caches are intentionally rejected so the next aggregation recomputes daily + * session counts and token buckets with the same date semantics. */ function migrateStatsCache( parsed: Partial & { version: number },