mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
065a80580b
commit
ace7f7b657
@ -63,6 +63,10 @@ vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/ActivitySettings', () => ({
|
||||
ActivitySettings: () => <div>Activity Settings Mock</div>,
|
||||
}))
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
|
||||
70
desktop/src/api/activityStats.ts
Normal file
70
desktop/src/api/activityStats.ts
Normal file
@ -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<string, number>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<ActivityStatsResponse> {
|
||||
const suffix = range === 'all' ? '' : `/${range}`
|
||||
const response = await api.get<ActivityStatsApiResponse>(`/api/activity-stats${suffix}`, { timeout: 120_000 })
|
||||
return {
|
||||
...response.stats,
|
||||
range: response.range,
|
||||
generatedAt: response.generatedAt,
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -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 <args> with claude-haha <args>, for example: claude-haha plugin install ... or claude-haha mcp add ...',
|
||||
|
||||
@ -91,6 +91,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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 ...',
|
||||
|
||||
117
desktop/src/pages/ActivitySettings.test.tsx
Normal file
117
desktop/src/pages/ActivitySettings.test.tsx
Normal file
@ -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(<ActivitySettings />)
|
||||
|
||||
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(<ActivitySettings />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
433
desktop/src/pages/ActivitySettings.tsx
Normal file
433
desktop/src/pages/ActivitySettings.tsx
Normal file
@ -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<Locale, string> = {
|
||||
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<typeof useTranslation>) {
|
||||
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<string, number>()
|
||||
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<HTMLDivElement | null>(null)
|
||||
const [stats, setStats] = useState<ActivityStatsResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hoveredDate, setHoveredDate] = useState<string | null>(null)
|
||||
const [focusedDate, setFocusedDate] = useState<string | null>(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 (
|
||||
<div className="w-full max-w-[1400px] min-w-0">
|
||||
<div className="mb-5 flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 pt-1">
|
||||
<h2 className="text-base font-semibold tracking-normal text-[var(--color-text-primary)]">{t('settings.activity.title')}</h2>
|
||||
<div className="mt-1 text-sm leading-5 text-[var(--color-text-tertiary)]">
|
||||
{dateRange && <div>{dateRange}</div>}
|
||||
<div>{t('settings.activity.subtitleLoading')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid w-full gap-2 xl:max-w-[640px]"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))' }}
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label} className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2.5">
|
||||
<div className="truncate text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">{metric.label}</div>
|
||||
<div className="mt-1 truncate text-xl font-semibold tracking-normal text-[var(--color-text-primary)]">{metric.value}</div>
|
||||
{metric.detail && <div className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{metric.detail}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-sm">
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[190px] items-center justify-center text-sm text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined mr-2 animate-spin text-[18px]">progress_activity</span>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div ref={heatmapMeasureRef} className="min-w-0 pb-2">
|
||||
<div className="relative" style={{ width: heatmapWidth, maxWidth: '100%' }}>
|
||||
<div
|
||||
className="mb-4 grid h-5 text-[11px] leading-none text-[var(--color-text-tertiary)]"
|
||||
style={{
|
||||
marginLeft: HEAT_LABEL_WIDTH,
|
||||
gridTemplateColumns: `repeat(${WEEK_COUNT}, ${heatCellSize}px)`,
|
||||
columnGap: HEAT_CELL_GAP,
|
||||
}}
|
||||
>
|
||||
{monthLabels.map((month) => (
|
||||
<div key={`${month.week}-${month.label}`} style={{ gridColumn: `${month.week + 1} / span 4` }}>
|
||||
{month.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start" style={{ gap: HEAT_CELL_GAP }}>
|
||||
<div
|
||||
className="grid shrink-0 grid-rows-7 text-[11px] leading-none text-[var(--color-text-tertiary)]"
|
||||
style={{ width: HEAT_LABEL_WIDTH, height: heatGridHeight, rowGap: HEAT_CELL_GAP }}
|
||||
>
|
||||
<div className="row-start-2 flex items-center">{t(WEEKDAY_LABEL_KEYS[0])}</div>
|
||||
<div className="row-start-4 flex items-center">{t(WEEKDAY_LABEL_KEYS[1])}</div>
|
||||
<div className="row-start-6 flex items-center">{t(WEEKDAY_LABEL_KEYS[2])}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="grid"
|
||||
aria-label={t('settings.activity.heatmapLabel')}
|
||||
className="grid grid-flow-col"
|
||||
style={{
|
||||
gridTemplateRows: `repeat(7, ${heatCellSize}px)`,
|
||||
gridAutoColumns: `${heatCellSize}px`,
|
||||
columnGap: HEAT_CELL_GAP,
|
||||
rowGap: HEAT_CELL_GAP,
|
||||
}}
|
||||
onMouseLeave={() => setHoveredDate(null)}
|
||||
>
|
||||
{days.map((day) => {
|
||||
const isSelected = activeTooltipDate === day.date
|
||||
const tooltipId = `activity-day-tooltip-${day.date}`
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-label={`${formatDateLabel(day.date, locale)}: ${formatSessionCount(day.sessionCount, t)}, ${formatTokens(day.tokens)} tokens`}
|
||||
aria-describedby={activeTooltipDate === day.date ? tooltipId : undefined}
|
||||
className={`rounded-[3px] border transition-[border-color,transform] hover:scale-110 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] ${
|
||||
isSelected
|
||||
? 'border-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)]/30 hover:border-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
style={{
|
||||
width: heatCellSize,
|
||||
height: heatCellSize,
|
||||
backgroundColor: HEAT_COLORS[day.level],
|
||||
}}
|
||||
onFocus={() => setFocusedDate(day.date)}
|
||||
onBlur={() => setFocusedDate(null)}
|
||||
onMouseEnter={() => setHoveredDate(day.date)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tooltipDay && (
|
||||
<div
|
||||
id={`activity-day-tooltip-${tooltipDay.date}`}
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute z-20 min-w-[172px] rounded-md border border-[#2f2825] bg-[#2f2825] px-3 py-2 text-xs shadow-xl"
|
||||
style={tooltipStyle}
|
||||
>
|
||||
<div className="font-medium text-[#fffaf6]">{formatDateLabel(tooltipDay.date, locale)}</div>
|
||||
<div className="mt-1 text-[#f2d0c0]">
|
||||
{formatSessionCount(tooltipDay.sessionCount, t)} · {formatTokens(tooltipDay.tokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-end gap-2 text-xs text-[var(--color-text-tertiary)] xl:mt-4">
|
||||
<span>{t('settings.activity.less')}</span>
|
||||
{HEAT_COLORS.map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
aria-hidden="true"
|
||||
className="rounded-[3px] border border-[var(--color-border)]/30"
|
||||
style={{ width: heatCellSize, height: heatCellSize, backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
<span>{t('settings.activity.more')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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() {
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
|
||||
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
||||
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
|
||||
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
|
||||
</div>
|
||||
<div className="border-t border-[var(--color-border)]/40 pt-1">
|
||||
@ -83,6 +85,7 @@ export function Settings() {
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === 'providers' && <ProviderSettings />}
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'activity' && <ActivitySettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'terminal' && <TerminalSettings />}
|
||||
|
||||
@ -31,6 +31,7 @@ export type Toast = {
|
||||
export type SettingsTab =
|
||||
| 'providers'
|
||||
| 'permissions'
|
||||
| 'activity'
|
||||
| 'general'
|
||||
| 'adapters'
|
||||
| 'terminal'
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
43
src/server/api/activityStats.ts
Normal file
43
src/server/api/activityStats.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
aggregateClaudeCodeStatsForRange,
|
||||
type StatsDateRange,
|
||||
} from '../../utils/stats.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const VALID_RANGES = new Set<StatsDateRange>(['7d', '30d', 'all'])
|
||||
|
||||
export async function handleActivityStatsApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
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')
|
||||
}
|
||||
@ -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<Response> {
|
||||
const path = url.pathname
|
||||
@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'doctor':
|
||||
return handleDoctorApi(req, url, segments)
|
||||
|
||||
case 'activity-stats':
|
||||
return handleActivityStatsApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
262
src/utils/__tests__/stats.test.ts
Normal file
262
src/utils/__tests__/stats.test.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { aggregateClaudeCodeStatsForRange } from '../stats.js'
|
||||
import { loadStatsCache, STATS_CACHE_VERSION } from '../statsCache.js'
|
||||
|
||||
let tmpConfigDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
function dateKey(offsetDays: number): string {
|
||||
const date = new Date()
|
||||
date.setUTCDate(date.getUTCDate() + offsetDays)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function at(date: string, time: string): string {
|
||||
return `${date}T${time}.000Z`
|
||||
}
|
||||
|
||||
function userEntry(uuid: string, timestamp: string, isSidechain = false) {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
isSidechain,
|
||||
timestamp,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function assistantEntry(
|
||||
uuid: string,
|
||||
timestamp: string,
|
||||
usage: {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
},
|
||||
options: { model?: string; isSidechain?: boolean; parentUuid?: string } = {},
|
||||
) {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
parentUuid: options.parentUuid ?? null,
|
||||
isSidechain: options.isSidechain ?? false,
|
||||
timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: options.model ?? 'claude-test',
|
||||
content: [],
|
||||
usage,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonl(path: string, entries: unknown[]) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(
|
||||
path,
|
||||
`${entries.map(entry => 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)
|
||||
})
|
||||
})
|
||||
@ -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<TranscriptMessage, 'timestamp'>,
|
||||
): 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<string, DailyActivity>()
|
||||
const dailySessionIdsMap = new Map<string, Set<string>>()
|
||||
const dailyModelTokensMap = new Map<string, { [modelName: string]: number }>()
|
||||
const sessions: SessionStats[] = []
|
||||
const hourCounts = new Map<number, number>()
|
||||
@ -134,6 +169,30 @@ async function processSessionFiles(
|
||||
// Track parent sessions that already recorded a shot count (dedup across subagents)
|
||||
const sessionsWithShotCount = new Set<string>()
|
||||
|
||||
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<Entry>(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)
|
||||
|
||||
@ -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<PersistedStatsCache> & { version: number },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user