diff --git a/src/main/codex-usage/codex-usage-event-attribution.ts b/src/main/codex-usage/codex-usage-event-attribution.ts index 4cde7a74af8..8ebf7fbc5e3 100644 --- a/src/main/codex-usage/codex-usage-event-attribution.ts +++ b/src/main/codex-usage/codex-usage-event-attribution.ts @@ -1,67 +1,13 @@ -import { normalizeComparablePath } from '../usage/usage-path-comparison' +import { attributeUsageEvent } from '../usage/usage-event-attribution' import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver' import type { CodexUsageAttributedEvent, CodexUsageParsedEvent } from './types' export type CodexUsageWorktreeRef = UsageScanWorktreeRef -function getDefaultProjectLabel(cwd: string | null): string { - if (!cwd) { - return 'Unknown location' - } - const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts.at(-1) ?? cwd -} - -function localDayFromTimestamp(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - export async function attributeCodexUsageEvent( event: CodexUsageParsedEvent, resolveWorktree: UsageWorktreeResolver ): Promise { - const day = localDayFromTimestamp(event.timestamp) - if (!day) { - return null - } - - let repoId: string | null = null - let worktreeId: string | null = null - let projectKey = 'unscoped' - let projectLabel = getDefaultProjectLabel(event.cwd) - - if (event.cwd) { - const worktree = resolveWorktree(event.cwd) - if (worktree) { - repoId = worktree.repoId - worktreeId = worktree.worktreeId - projectKey = `worktree:${worktree.worktreeId}` - projectLabel = worktree.displayName - } else { - // Why: all-local mode should still collapse repeated off-Orca sessions by - // location, but those keys must normalize slash/case differences so the - // same folder does not fragment into multiple "projects" across platforms. - projectKey = `cwd:${normalizeComparablePath(event.cwd)}` - } - } - - return { - ...event, - day, - projectKey, - projectLabel, - repoId, - worktreeId - } + return attributeUsageEvent(event, resolveWorktree) } diff --git a/src/main/codex-usage/codex-usage-scope-filters.ts b/src/main/codex-usage/codex-usage-scope-filters.ts index 3efd8dbcd57..de023f50864 100644 --- a/src/main/codex-usage/codex-usage-scope-filters.ts +++ b/src/main/codex-usage/codex-usage-scope-filters.ts @@ -1,6 +1,6 @@ import type { CodexUsageRange, CodexUsageScope } from '../../shared/codex-usage-types' import type { CodexUsagePersistedState } from './types' -import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { filterUsageDaily, filterUsageSessions } from '../usage/usage-scope-filters' export type ScopedCodexUsageModelRow = { modelKey: string @@ -19,16 +19,7 @@ export function getFilteredDaily( scope: CodexUsageScope, range: CodexUsageRange ) { - const cutoff = getUsageRangeCutoff(range) - return state.dailyAggregates.filter((entry) => { - if (cutoff && entry.day < cutoff) { - return false - } - if (scope === 'orca' && entry.worktreeId === null) { - return false - } - return true - }) + return filterUsageDaily(state.dailyAggregates, scope, range) } export function getFilteredSessions( @@ -36,20 +27,7 @@ export function getFilteredSessions( scope: CodexUsageScope, range: CodexUsageRange ) { - const cutoff = getUsageRangeCutoff(range) - return state.sessions.filter((session) => { - const day = getLocalUsageDay(session.lastTimestamp) - if (!day) { - return false - } - if (cutoff && day < cutoff) { - return false - } - if (scope === 'orca') { - return session.locationBreakdown.some((entry) => entry.worktreeId !== null) - } - return true - }) + return filterUsageSessions(state.sessions, scope, range) } export function getScopedSessionModels( diff --git a/src/main/usage/usage-event-attribution.ts b/src/main/usage/usage-event-attribution.ts new file mode 100644 index 00000000000..25c696e6263 --- /dev/null +++ b/src/main/usage/usage-event-attribution.ts @@ -0,0 +1,47 @@ +import { getLocalUsageDay } from './usage-calendar-range' +import { normalizeComparablePath } from './usage-path-comparison' +import type { UsageWorktreeResolver } from './usage-worktree-resolver' + +export type UnattributedUsageEvent = { + timestamp: string + cwd: string | null +} + +export type UsageEventAttribution = { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} + +function defaultProjectLabel(cwd: string | null): string { + if (!cwd) { + return 'Unknown location' + } + const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean) + return parts.length >= 2 ? parts.slice(-2).join('/') : (parts.at(-1) ?? cwd) +} + +export function attributeUsageEvent( + event: T, + resolveWorktree: UsageWorktreeResolver +): (T & UsageEventAttribution) | null { + const day = getLocalUsageDay(event.timestamp) + if (!day) { + return null + } + const worktree = event.cwd ? resolveWorktree(event.cwd) : null + return { + ...event, + day, + projectKey: worktree + ? `worktree:${worktree.worktreeId}` + : event.cwd + ? `cwd:${normalizeComparablePath(event.cwd)}` + : 'unscoped', + projectLabel: worktree?.displayName ?? defaultProjectLabel(event.cwd), + repoId: worktree?.repoId ?? null, + worktreeId: worktree?.worktreeId ?? null + } +} diff --git a/src/main/usage/usage-provider-contract.ts b/src/main/usage/usage-provider-contract.ts index 2937af8b05c..3bb478955e8 100644 --- a/src/main/usage/usage-provider-contract.ts +++ b/src/main/usage/usage-provider-contract.ts @@ -7,7 +7,7 @@ * consumer, so only the scan envelope is shared. */ -export type UsageProviderId = 'claude' | 'codex' | 'opencode' | `plugin:${string}` +export type UsageProviderId = 'claude' | 'codex' | 'devin' | 'opencode' | `plugin:${string}` /** Scan input. Distinct from `UsageWorktreeRef` in usage-worktree-metadata, which lacks `repoId`. */ export type UsageScanWorktreeRef = { diff --git a/src/main/usage/usage-scope-filters.ts b/src/main/usage/usage-scope-filters.ts new file mode 100644 index 00000000000..8d067e8199a --- /dev/null +++ b/src/main/usage/usage-scope-filters.ts @@ -0,0 +1,31 @@ +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +export function filterUsageDaily( + daily: readonly T[], + scope: 'orca' | 'all', + range: '7d' | '30d' | '90d' | 'all' +): T[] { + const cutoff = getUsageRangeCutoff(range) + return daily.filter((entry) => { + if (cutoff && entry.day < cutoff) { + return false + } + return scope === 'all' || entry.worktreeId !== null + }) +} + +export function filterUsageSessions< + T extends { + lastTimestamp: string + locationBreakdown: readonly { worktreeId: string | null }[] + } +>(sessions: readonly T[], scope: 'orca' | 'all', range: '7d' | '30d' | '90d' | 'all'): T[] { + const cutoff = getUsageRangeCutoff(range) + return sessions.filter((session) => { + const day = getLocalUsageDay(session.lastTimestamp) + if (!day || (cutoff && day < cutoff)) { + return false + } + return scope === 'all' || session.locationBreakdown.some((entry) => entry.worktreeId !== null) + }) +}