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:
程序员阿江(Relakkes) 2026-05-10 11:37:49 +08:00
parent 065a80580b
commit ace7f7b657
14 changed files with 1164 additions and 58 deletions

View File

@ -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 />)

View 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,
}
},
}

View File

@ -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 ...',

View File

@ -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 ...',

View 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()
})
})

View 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>
)
}

View File

@ -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 />}

View File

@ -31,6 +31,7 @@ export type Toast = {
export type SettingsTab =
| 'providers'
| 'permissions'
| 'activity'
| 'general'
| 'adapters'
| 'terminal'

View File

@ -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)
})
})

View 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')
}

View File

@ -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)

View 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)
})
})

View File

@ -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)

View File

@ -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 },