mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
* Add multi-day calendar selection with custom popover UI Replace native DatePicker with a custom-themed CalendarPopover supporting non-contiguous multi-day selection. Add --days CLI flag for comma-separated date filtering that post-filters from the bounding range scan. CLI: parseDaysFlag() parses and validates dates, filterProjectsByDays() filters turns to only selected days. Verified that sum of individual --day calls matches --days output across cost, calls, activities, models, and providers. Swift: DataClient passes --days for multi-day, with single-day fallback. AppStore tracks selectedDays as Set<String> with PayloadCacheKey support. CalendarPopover uses pending state committed only on Done tap. * Clear + Done in calendar resets to current period When user clears all selected days and taps Done, switch back to the active period instead of silently keeping the old day selection.
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
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 })
|
|
}
|
|
})
|
|
|
|
it('filters a single review day with --day', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
|
|
|
|
try {
|
|
const projectDir = join(home, '.claude', 'projects', 'app')
|
|
await mkdir(projectDir, { recursive: true })
|
|
|
|
// runCli pins TZ=UTC, so these exact boundaries exercise the inclusive
|
|
// start/end of the selected calendar day without local-offset drift.
|
|
await writeFile(
|
|
join(projectDir, 'day-selector.jsonl'),
|
|
[
|
|
userLine('day-before', '2026-04-09T23:59:00Z'),
|
|
assistantNoEditLine('day-before', '2026-04-09T23:59:30Z', 'm-before'),
|
|
userLine('day-selected', '2026-04-10T00:00:00Z'),
|
|
assistantEditLine('day-selected', '2026-04-10T23:59:59Z', 'm-selected'),
|
|
userLine('day-after', '2026-04-11T00:00:00Z'),
|
|
assistantNoEditLine('day-after', '2026-04-11T00:00:30Z', 'm-after'),
|
|
].join('\n'),
|
|
)
|
|
|
|
const result = runCli([
|
|
'--format', 'json',
|
|
'--day', '2026-04-10',
|
|
'--provider', 'claude',
|
|
], home)
|
|
|
|
expect(result.status).toBe(0)
|
|
const report = JSON.parse(result.stdout) as {
|
|
period: string
|
|
periodKey: string
|
|
daily: Array<{ date: string; calls: number; editTurns: number }>
|
|
projects: Array<{ sessions: number; calls: number }>
|
|
}
|
|
|
|
expect(report.period).toBe('Day (2026-04-10)')
|
|
expect(report.periodKey).toBe('day')
|
|
expect(report.daily.map(d => d.date)).toEqual(['2026-04-10'])
|
|
expect(report.daily[0]?.calls).toBe(1)
|
|
expect(report.daily[0]?.editTurns).toBe(1)
|
|
expect(report.projects[0]?.sessions).toBe(1)
|
|
expect(report.projects[0]?.calls).toBe(1)
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects --day combined with --from/--to', async () => {
|
|
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-json-day-'))
|
|
|
|
try {
|
|
const result = runCli([
|
|
'--format', 'json',
|
|
'--day', '2026-04-10',
|
|
'--from', '2026-04-10',
|
|
'--provider', 'claude',
|
|
], home)
|
|
|
|
expect(result.status).toBe(1)
|
|
expect(result.stderr).toContain('--day cannot be combined with --from or --to')
|
|
} finally {
|
|
await rm(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|