Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/main/usageScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
18 changes: 12 additions & 6 deletions src/main/usageScan.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,13 +31,13 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs:
const perModelGlobal = new Map<string, UsageTotals>();
const perDay = new Map<string, UsageTotals>();
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<string, UsageTotals>();
let projSessions = 0, projUnknown = false;
let projSessions = 0, projUnknown = false, projActiveMs = 0;

if (existsSync(dir)) {
let files: string[] = [];
Expand All @@ -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));
Expand All @@ -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,
});
}

Expand All @@ -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 };
}
2 changes: 2 additions & 0 deletions src/renderer/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "推定コスト",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "추정비용",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "预估费用",
Expand Down
13 changes: 8 additions & 5 deletions src/renderer/usageView.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { barChart, shareBar } from './charts';
import { formatDuration } from '../shared/usage';
import { tr, localeTag } from './i18n-runtime';

type UsageReport = Awaited<ReturnType<Window['devdeck']['usageReport']>>;
Expand All @@ -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); }
Expand Down Expand Up @@ -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 = `<b></b><span></span>`; 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] }))));
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand Down
41 changes: 40 additions & 1 deletion src/shared/usage.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
});
27 changes: 27 additions & 0 deletions src/shared/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ export const MODEL_PRICING: Record<string, PriceCard> = {
'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 {
Expand Down
Loading