diff --git a/src/main/usageScan.test.ts b/src/main/usageScan.test.ts index ad1b6c0..9e73fa3 100644 --- a/src/main/usageScan.test.ts +++ b/src/main/usageScan.test.ts @@ -47,4 +47,19 @@ describe('scanUsage', () => { expect(r.global.input).toBe(0); expect(r.byProject[0].sessions).toBe(0); }); + + it('sums active time from message gaps, capping idle stretches', () => { + const d = join(root, 'C--g-time'); + mkdirSync(d, { recursive: true }); + // 0m → 3m (active 3m) → 120m idle (skipped) → 121m (active 1m). Total active = 4m. + writeFileSync(join(d, 's.jsonl'), [ + asst('claude-opus-4-8', { input_tokens: 1 }, '2026-06-01T10:00:00.000Z'), + asst('claude-opus-4-8', { input_tokens: 1 }, '2026-06-01T10:03:00.000Z'), + asst('claude-opus-4-8', { input_tokens: 1 }, '2026-06-01T12:00:00.000Z'), + asst('claude-opus-4-8', { input_tokens: 1 }, '2026-06-01T12:01:00.000Z'), + ].join('\n')); + const r = scanUsage([{ path: 'C:\\g\\time', name: 'time' }], root, Infinity); + expect(r.activeMs).toBe(4 * 60_000); + expect(r.byProject[0].activeMs).toBe(4 * 60_000); + }); }); diff --git a/src/main/usageScan.ts b/src/main/usageScan.ts index 63852ca..5922b2a 100644 --- a/src/main/usageScan.ts +++ b/src/main/usageScan.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { encodeProjectPath } from '../shared/paths'; -import { emptyTotals, addUsage, estimateCost, MODEL_PRICING, type UsageTotals, type RawUsage } from '../shared/usage'; +import { emptyTotals, addUsage, estimateCost, activeMsFromTimestamps, MODEL_PRICING, type UsageTotals, type RawUsage } from '../shared/usage'; import type { UsageReport, ProjectUsage, ModelUsage } from '../shared/types'; // Cache: key = filepath + ':' + mtimeMs → parsed lines @@ -31,13 +31,13 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs: const perModelGlobal = new Map(); const perDay = new Map(); const byProject: ProjectUsage[] = []; - let webSearch = 0, webFetch = 0, sessions = 0, hasUnknownModel = false; + let webSearch = 0, webFetch = 0, sessions = 0, hasUnknownModel = false, globalActiveMs = 0; for (const repo of repos) { const dir = join(claudeProjectsDir, encodeProjectPath(repo.path)); const projTotals = emptyTotals(); const projByModel = new Map(); - let projSessions = 0, projUnknown = false; + let projSessions = 0, projUnknown = false, projActiveMs = 0; if (existsSync(dir)) { let files: string[] = []; @@ -57,14 +57,17 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs: _fileCache.set(cacheKey, lines); } projSessions++; + const stamps: number[] = []; // in-range message timestamps, for active-time gaps for (const line of lines) { if (!line.trim()) continue; let o: { type?: string; timestamp?: string; message?: { model?: string; usage?: RawUsage & { server_tool_use?: { web_search_requests?: number; web_fetch_requests?: number } } } }; try { o = JSON.parse(line); } catch { continue; } - const u = o.message?.usage; - if (o.type !== 'assistant' || !u) continue; const day = dayKey(o.timestamp, fileMs); if (sinceMs !== Infinity && new Date(day + 'T00:00:00.000Z').getTime() < sinceMs) continue; + // Collect every line's timestamp (user + assistant + tool) so gaps reflect real wall-clock activity. + if (o.timestamp) { const ms = new Date(o.timestamp).getTime(); if (!Number.isNaN(ms)) stamps.push(ms); } + const u = o.message?.usage; + if (o.type !== 'assistant' || !u) continue; const model = o.message?.model ?? 'unknown'; if (!MODEL_PRICING[model]) { hasUnknownModel = true; projUnknown = true; } Object.assign(global, addUsage(global, u)); @@ -75,13 +78,16 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs: webSearch += u.server_tool_use?.web_search_requests ?? 0; webFetch += u.server_tool_use?.web_fetch_requests ?? 0; } + projActiveMs += activeMsFromTimestamps(stamps); } } sessions += projSessions; + globalActiveMs += projActiveMs; byProject.push({ path: repo.path, name: repo.name, sessions: projSessions, totals: projTotals, costEstimate: sumModelCost(projByModel), hasUnknownModel: projUnknown, + activeMs: projActiveMs, }); } @@ -92,5 +98,5 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs: day, tokens: tokensOf(t), cost: null as number | null, })); - return { global, globalCost: sumModelCost(perModelGlobal), hasUnknownModel, webSearch, webFetch, sessions, byModel, byProject, daily }; + return { global, globalCost: sumModelCost(perModelGlobal), hasUnknownModel, webSearch, webFetch, sessions, activeMs: globalActiveMs, byModel, byProject, daily }; } diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index ed1f152..3cdbf47 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -44,6 +44,8 @@ "usage.cache_r": "Cache R", "usage.web": "web", "usage.sessions": "sessions", + "usage.active_time": "Active time", + "usage.col_time": "Time", "usage.daily_tokens": "Daily tokens", "usage.col_project": "Project", "usage.col_cost": "Est. cost", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index cc80f0d..afdcefe 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -44,6 +44,8 @@ "usage.cache_r": "キャッシュ R", "usage.web": "web", "usage.sessions": "セッション", + "usage.active_time": "作業時間", + "usage.col_time": "時間", "usage.daily_tokens": "日別トークン", "usage.col_project": "プロジェクト", "usage.col_cost": "推定コスト", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 6745684..b69e574 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -44,6 +44,8 @@ "usage.cache_r": "캐시 R", "usage.web": "web", "usage.sessions": "세션", + "usage.active_time": "작업 시간", + "usage.col_time": "시간", "usage.daily_tokens": "일별 토큰", "usage.col_project": "프로젝트", "usage.col_cost": "추정비용", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 58e83dc..ee8577d 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -44,6 +44,8 @@ "usage.cache_r": "缓存 R", "usage.web": "web", "usage.sessions": "会话", + "usage.active_time": "工作时间", + "usage.col_time": "时间", "usage.daily_tokens": "每日令牌", "usage.col_project": "项目", "usage.col_cost": "预估费用", diff --git a/src/renderer/usageView.ts b/src/renderer/usageView.ts index 5f28758..96be1b8 100644 --- a/src/renderer/usageView.ts +++ b/src/renderer/usageView.ts @@ -1,4 +1,5 @@ import { barChart, shareBar } from './charts'; +import { formatDuration } from '../shared/usage'; import { tr, localeTag } from './i18n-runtime'; type UsageReport = Awaited>; @@ -13,7 +14,7 @@ const COLORS = ['#6366f1', '#3b82f6', '#d98a1f', '#e0623f', '#9aa1ad']; let viewEl: HTMLElement; let activeRange = '30d'; -let sortKey: 'cost' | 'input' | 'output' | 'sessions' = 'cost'; +let sortKey: 'cost' | 'input' | 'output' | 'sessions' | 'active' = 'cost'; let sortDir: 'desc' | 'asc' = 'desc'; function fmt(n: number): string { return new Intl.NumberFormat(localeTag()).format(n); } @@ -52,6 +53,7 @@ function render(r: UsageReport): void { [tr('usage.input'), fmt(r.global.input)], [tr('usage.output'), fmt(r.global.output)], [tr('usage.cache_w'), fmt(r.global.cacheWrite)], [tr('usage.cache_r'), fmt(r.global.cacheRead)], [tr('usage.web'), `${r.webSearch + r.webFetch}`], [tr('usage.sessions'), `${r.sessions}`], + [tr('usage.active_time'), formatDuration(r.activeMs)], ] as [string, string][]).map(([k, v]) => { const d = document.createElement('div'); d.className = 'stat'; d.innerHTML = ``; d.querySelector('b')!.textContent = v; d.querySelector('span')!.textContent = k; return d; })); sum.appendChild(stats); sum.appendChild(shareBar(r.byModel.map((m, i) => ({ label: m.model, value: m.totals.input + m.totals.output, color: COLORS[i % COLORS.length] })))); @@ -75,13 +77,14 @@ function render(r: UsageReport): void { } const rows = [...r.byProject].filter((p) => p.sessions > 0).sort((a, b) => { - const av = sortKey === 'cost' ? (a.costEstimate ?? -1) : sortKey === 'sessions' ? a.sessions : a.totals[sortKey]; - const bv = sortKey === 'cost' ? (b.costEstimate ?? -1) : sortKey === 'sessions' ? b.sessions : b.totals[sortKey]; + const val = (p: typeof a): number => + sortKey === 'cost' ? (p.costEstimate ?? -1) : sortKey === 'sessions' ? p.sessions : sortKey === 'active' ? p.activeMs : p.totals[sortKey]; + const av = val(a), bv = val(b); return sortDir === 'desc' ? bv - av : av - bv; }); const table = document.createElement('table'); table.className = 'usage-table'; const head = document.createElement('tr'); - for (const [key, label] of [['name', tr('usage.col_project')], ['sessions', tr('proj.sessions')], ['input', tr('usage.input')], ['output', tr('usage.output')], ['cost', tr('usage.col_cost')]] as const) { + for (const [key, label] of [['name', tr('usage.col_project')], ['sessions', tr('proj.sessions')], ['active', tr('usage.col_time')], ['input', tr('usage.input')], ['output', tr('usage.output')], ['cost', tr('usage.col_cost')]] as const) { const th = document.createElement('th'); const isSortable = key !== 'name'; const isActive = isSortable && key === sortKey; @@ -110,7 +113,7 @@ function render(r: UsageReport): void { table.appendChild(head); for (const p of rows) { const tr2 = document.createElement('tr'); - const cells = [p.name, String(p.sessions), fmt(p.totals.input), fmt(p.totals.output), usd(p.costEstimate)]; + const cells = [p.name, String(p.sessions), formatDuration(p.activeMs), fmt(p.totals.input), fmt(p.totals.output), usd(p.costEstimate)]; for (const c of cells) { const td = document.createElement('td'); td.textContent = c; tr2.appendChild(td); } table.appendChild(tr2); } diff --git a/src/shared/types.ts b/src/shared/types.ts index b085d41..f7942aa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,10 +13,14 @@ export interface ModelUsage { model: string; totals: UsageTotals; costEstimate: export interface ProjectUsage { path: string; name: string; sessions: number; totals: UsageTotals; costEstimate: number | null; hasUnknownModel: boolean; + /** Active working time (sum of message gaps within the idle cap), in ms. */ + activeMs: number; } export interface UsageReport { global: UsageTotals; globalCost: number | null; hasUnknownModel: boolean; webSearch: number; webFetch: number; sessions: number; + /** Total active working time across all scanned sessions, in ms. */ + activeMs: number; byModel: ModelUsage[]; byProject: ProjectUsage[]; daily: { day: string; cost: number | null; tokens: number }[]; diff --git a/src/shared/usage.test.ts b/src/shared/usage.test.ts index 2853e2d..1d68a5a 100644 --- a/src/shared/usage.test.ts +++ b/src/shared/usage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { emptyTotals, addUsage, estimateCost, MODEL_PRICING } from './usage'; +import { emptyTotals, addUsage, estimateCost, MODEL_PRICING, activeMsFromTimestamps, formatDuration, ACTIVE_GAP_CAP_MS } from './usage'; describe('addUsage', () => { it('accumulates the four token categories', () => { @@ -26,3 +26,42 @@ describe('estimateCost', () => { expect(MODEL_PRICING['claude-opus-4-8']).toBeDefined(); }); }); + +describe('activeMsFromTimestamps', () => { + const T = (min: number) => Date.UTC(2026, 5, 1, 10, 0, 0) + min * 60_000; + + it('sums consecutive gaps within the idle cap', () => { + // gaps: 2m, 3m — both <= 5m → 5m total + expect(activeMsFromTimestamps([T(0), T(2), T(5)])).toBe(5 * 60_000); + }); + + it('excludes gaps larger than the idle cap (overnight / away)', () => { + // 2m active, then a 600m idle gap (skipped), then 1m active → 3m total + expect(activeMsFromTimestamps([T(0), T(2), T(602), T(603)])).toBe(3 * 60_000); + }); + + it('drops a gap exactly over the cap but keeps one exactly at the cap', () => { + const capMin = ACTIVE_GAP_CAP_MS / 60_000; + expect(activeMsFromTimestamps([T(0), T(capMin)])).toBe(ACTIVE_GAP_CAP_MS); + expect(activeMsFromTimestamps([T(0), T(capMin + 1)])).toBe(0); + }); + + it('sorts unordered input and returns 0 for <2 stamps', () => { + expect(activeMsFromTimestamps([T(2), T(0), T(5)])).toBe(5 * 60_000); + expect(activeMsFromTimestamps([T(0)])).toBe(0); + expect(activeMsFromTimestamps([])).toBe(0); + }); +}); + +describe('formatDuration', () => { + it('formats hours and minutes', () => { + expect(formatDuration((14 * 60 + 9) * 60_000)).toBe('14h 9m'); + }); + it('drops the hours part below one hour', () => { + expect(formatDuration(45 * 60_000)).toBe('45m'); + }); + it('floors to whole minutes and clamps negatives to 0m', () => { + expect(formatDuration(59_000)).toBe('0m'); + expect(formatDuration(-1000)).toBe('0m'); + }); +}); diff --git a/src/shared/usage.ts b/src/shared/usage.ts index 976c2ea..2431299 100644 --- a/src/shared/usage.ts +++ b/src/shared/usage.ts @@ -19,6 +19,33 @@ export const MODEL_PRICING: Record = { 'claude-haiku-4-5': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 }, }; +/** + * Gap between two consecutive session messages longer than this counts as idle + * (overnight, stepped away) and is excluded from "active" time. Keeps the summed + * working time meaningful instead of the full first→last span the terminal ⏱️ shows. + */ +export const ACTIVE_GAP_CAP_MS = 5 * 60 * 1000; + +/** Sum of consecutive-timestamp gaps that are within the idle cap. Input may be unsorted. */ +export function activeMsFromTimestamps(timestampsMs: number[]): number { + if (timestampsMs.length < 2) return 0; + const sorted = [...timestampsMs].sort((a, b) => a - b); + let active = 0; + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i] - sorted[i - 1]; + if (gap > 0 && gap <= ACTIVE_GAP_CAP_MS) active += gap; + } + return active; +} + +/** Human-friendly duration: "14h 9m", "45m", "0m". Floors to whole minutes. */ +export function formatDuration(ms: number): string { + const totalMin = Math.floor(Math.max(0, ms) / 60000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + export function emptyTotals(): UsageTotals { return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 }; } export function addUsage(t: UsageTotals, u: RawUsage): UsageTotals {