Expose per-day one-shot data in daily JSON output (#279) (#280)

* Expose per-day one-shot data in daily JSON output

Closes #279.

Adds turns, editTurns, oneShotTurns, oneShotRate to each entry of the
`daily[]` array in `codeburn report --format json` output. The data was
already computed internally for activity-level rollups; this just buckets
it by date so consumers building daily-resolution efficiency dashboards
(streak tracking, heatmaps, rolling-window charts) don't have to re-derive
the rate from period-level activities.

Counting matches parser.ts categoryBreakdown semantics:
- every turn counts toward `turns`
- turns with hasEdits=true count toward `editTurns`
- edit turns with retries=0 count toward `oneShotTurns`
- oneShotRate is null (not 0) when editTurns=0 — a chat-only day's rate
  is undefined, and reading it as 0% would be misleading

Real consumer named in the issue: a 10-developer internal usage tracker
that scores days by cache hit + cost/call + (now) one-shot rate.

* Strengthen daily/activities reconciliation + CHANGELOG entry

- Fall back to turn.assistantCalls[0]?.timestamp when turn.timestamp is
  missing so daily aggregate doesn't drop turns that activities[] keeps.
  Previously sum(daily[].editTurns) could be < sum(activities[].editTurns)
  for sessions starting with assistant entries before any user line.
- Add Unreleased CHANGELOG entry for the daily one-shot fields.
This commit is contained in:
Resham Joshi 2026-05-09 21:01:05 -07:00 committed by GitHub
parent 4c29f6b880
commit d1eb13fb91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 207 additions and 4 deletions

View file

@ -19,6 +19,11 @@
is yellow, provider name is dim. Inspired by tokscale's per-model table
and ccusage's responsive cli-table3 layout, ported to plain Node with
no new runtime dependency.
- **Per-day one-shot data in `--format json`.** Each entry of `daily[]` now
carries `turns`, `editTurns`, `oneShotTurns`, and `oneShotRate` (0-100,
one decimal, `null` when no edit turns). Counts match the existing
period-level `activities[]` rollup so a consumer can sum across days and
reconcile. Closes #279.
### Changed (CLI)
- **`optimize` suggestions now declare their destination.** Every paste-style

View file

@ -138,12 +138,29 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
const dailyMap: Record<string, { cost: number; calls: number }> = {}
// Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
// consumer summing daily[].editTurns over a period gets the same total as
// sum(activities[].editTurns) for that period: every turn counts once for
// `turns`, edit turns count for `editTurns`, edit turns with zero retries
// count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
// dashboards need this without re-deriving from activity-level rollups.
const dailyMap: Record<string, { cost: number; calls: number; turns: number; editTurns: number; oneShotTurns: number }> = {}
for (const sess of sessions) {
for (const turn of sess.turns) {
if (!turn.timestamp) { continue }
const day = dateKey(turn.timestamp)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
// Prefer the user-message timestamp on the turn; fall back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions where the JSONL begins mid-conversation). Previously these
// turns dropped from daily but stayed in activities, breaking the
// sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
if (!ts) { continue }
const day = dateKey(ts)
if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
dailyMap[day].turns += 1
if (turn.hasEdits) {
dailyMap[day].editTurns += 1
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
dailyMap[day].cost += call.costUSD
dailyMap[day].calls += 1
@ -154,6 +171,15 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
date,
cost: convertCost(d.cost),
calls: d.calls,
turns: d.turns,
editTurns: d.editTurns,
oneShotTurns: d.oneShotTurns,
// Pre-computed convenience for dashboards that don't want to do the math.
// null when there are no edit turns (the rate is undefined, not zero —
// a day where the user only had Q&A turns shouldn't read as 0% one-shot).
oneShotRate: d.editTurns > 0
? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
: null,
}))
const projectList = projects.map(p => ({

View file

@ -0,0 +1,172 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
function runCli(args: string[], home: string) {
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
cwd: process.cwd(),
env: {
...process.env,
CLAUDE_CONFIG_DIR: join(home, '.claude'),
HOME: home,
TZ: 'UTC',
},
encoding: 'utf-8',
})
}
function userLine(sessionId: string, timestamp: string): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp,
message: { role: 'user', content: 'do the thing' },
})
}
function assistantEditLine(sessionId: string, timestamp: string, messageId: string): string {
// Includes a tool_use of `Edit` so the parser flags this turn as hasEdits=true.
// Single edit-turn with no retry (one assistant message in the turn) → counts
// as one oneShotTurn.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [
{ type: 'text', text: 'editing' },
{ type: 'tool_use', id: 'tu-1', name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } },
],
usage: { input_tokens: 1000, output_tokens: 100 },
},
})
}
function assistantNoEditLine(sessionId: string, timestamp: string, messageId: string): string {
// No edit tool — this turn does not count toward editTurns/oneShotTurns,
// but does count toward `turns` and `calls`.
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp,
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [{ type: 'text', text: 'just chatting' }],
usage: { input_tokens: 200, output_tokens: 30 },
},
})
}
describe('codeburn report --format json daily[] one-shot fields (issue #279)', () => {
it('exposes per-day turns / editTurns / oneShotTurns / oneShotRate', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
// Day 1 (2026-04-10): one edit-turn (one-shot), one chat-turn
// Day 2 (2026-04-11): one edit-turn (one-shot)
await writeFile(
join(projectDir, 'session.jsonl'),
[
userLine('s1', '2026-04-10T09:00:00Z'),
assistantEditLine('s1', '2026-04-10T09:01:00Z', 'm-d1-edit'),
userLine('s1', '2026-04-10T10:00:00Z'),
assistantNoEditLine('s1', '2026-04-10T10:01:00Z', 'm-d1-chat'),
userLine('s1', '2026-04-11T09:00:00Z'),
assistantEditLine('s1', '2026-04-11T09:01:00Z', 'm-d2-edit'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-11',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{
date: string
cost: number
calls: number
turns: number
editTurns: number
oneShotTurns: number
oneShotRate: number | null
}>
}
expect(report.daily).toHaveLength(2)
const day1 = report.daily.find(d => d.date === '2026-04-10')
expect(day1).toBeDefined()
expect(day1!.turns).toBe(2)
expect(day1!.editTurns).toBe(1)
expect(day1!.oneShotTurns).toBe(1)
expect(day1!.oneShotRate).toBe(100)
const day2 = report.daily.find(d => d.date === '2026-04-11')
expect(day2).toBeDefined()
expect(day2!.turns).toBe(1)
expect(day2!.editTurns).toBe(1)
expect(day2!.oneShotTurns).toBe(1)
expect(day2!.oneShotRate).toBe(100)
} finally {
await rm(home, { recursive: true, force: true })
}
})
it('reports null oneShotRate when the day has no edit turns', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-daily-'))
try {
const projectDir = join(home, '.claude', 'projects', 'app')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'chat-only.jsonl'),
[
userLine('s2', '2026-04-10T09:00:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:01:00Z', 'm-chat-1'),
userLine('s2', '2026-04-10T09:30:00Z'),
assistantNoEditLine('s2', '2026-04-10T09:31:00Z', 'm-chat-2'),
].join('\n'),
)
const result = runCli([
'--format', 'json',
'--from', '2026-04-10',
'--to', '2026-04-10',
'--provider', 'claude',
], home)
expect(result.status).toBe(0)
const report = JSON.parse(result.stdout) as {
daily: Array<{ date: string; turns: number; editTurns: number; oneShotTurns: number; oneShotRate: number | null }>
}
const day = report.daily.find(d => d.date === '2026-04-10')!
expect(day.turns).toBe(2)
expect(day.editTurns).toBe(0)
expect(day.oneShotTurns).toBe(0)
// null, not 0 — the rate is undefined when no edits happened, and a
// chat-only day would otherwise read as 0% one-shot which is misleading.
expect(day.oneShotRate).toBeNull()
} finally {
await rm(home, { recursive: true, force: true })
}
})
})