mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-02 00:40:14 +00:00
feat(plan): subscription plan tracking with usage progress bar
Adds `codeburn plan set <id>` to configure a subscription plan (Claude Pro, Claude Max, Cursor Pro, or custom). When set, the Overview panel renders an API-equivalent progress bar against subscription price with a projected month-end cost. Closes the loudest demand signal on the repo: issue #11 ("Subscription vs API Use") from two independent voices, plus the routing-decision use case raised in #12. - src/config.ts: extends CodeburnConfig with Plan, adds readPlan/savePlan/clearPlan - src/plans.ts: presets (claude-pro $20, claude-max $200, cursor-pro $20) - src/plan-usage.ts: getPlanUsage, resetDay-aware period math (1-28), median-of-7-day-trailing projection - src/cli.ts: `codeburn plan [show|set|reset]` subcommand, plan wired into JSON outputs for report/today/month/status (only when active) - src/dashboard.tsx: Plan row in Overview, color-coded (green under 80%, orange near, red over), with days-until-reset - README.md: Plans section with honest framing (API-equivalent vs subscription price, not token allowance) - tests/plan-usage.test.ts, tests/plans.test.ts, tests/cli-plan.test.ts: period math, presets, CLI round-trip Resets respect resetDay across month boundaries. Uses median daily spend (not mean) so one huge day doesn't distort the month-end projection. Fixes #11
This commit is contained in:
parent
8e39a89fe0
commit
3f7470d29b
9 changed files with 753 additions and 22 deletions
16
README.md
16
README.md
|
|
@ -166,6 +166,22 @@ The currency setting applies everywhere: dashboard, status bar, menu bar widget,
|
|||
|
||||
The menu bar widget includes a currency picker with 17 common currencies. For any currency not listed, use the CLI command above.
|
||||
|
||||
## Plans (subscription tracking)
|
||||
|
||||
If you're on Claude Pro, Claude Max, or Cursor Pro, set your plan so the dashboard shows subscription-relative usage:
|
||||
|
||||
```bash
|
||||
codeburn plan set claude-max # $200/month
|
||||
codeburn plan set claude-pro # $20/month
|
||||
codeburn plan set cursor-pro # $20/month
|
||||
codeburn plan set custom --monthly-usd 150 --provider claude # custom
|
||||
codeburn plan set none # disable plan view
|
||||
codeburn plan # show current
|
||||
codeburn plan reset # remove plan config
|
||||
```
|
||||
|
||||
The progress bar shows API-equivalent cost vs subscription price. Presets use publicly stated plan prices (as of April 2026); they do not model exact token allowances, because vendors do not publish precise consumer-plan limits.
|
||||
|
||||
## Menu Bar
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/getagentseal/codeburn@main/assets/menubar-0.8.0.png" alt="CodeBurn macOS menubar app" width="420" />
|
||||
|
|
|
|||
178
src/cli.ts
178
src/cli.ts
|
|
@ -15,7 +15,9 @@ import { parseDateRangeFlags } from './cli-date.js'
|
|||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
|
||||
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath } from './config.js'
|
||||
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
|
@ -84,11 +86,50 @@ function collect(val: string, acc: string[]): string[] {
|
|||
return acc
|
||||
}
|
||||
|
||||
function parseNumber(value: string): number {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
function parseInteger(value: string): number {
|
||||
return parseInt(value, 10)
|
||||
}
|
||||
|
||||
type JsonPlanSummary = {
|
||||
id: 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom'
|
||||
budget: number
|
||||
spent: number
|
||||
percentUsed: number
|
||||
status: 'under' | 'near' | 'over'
|
||||
projectedMonthEnd: number
|
||||
daysUntilReset: number
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
}
|
||||
|
||||
function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
|
||||
return {
|
||||
id: planUsage.plan.id,
|
||||
budget: convertCost(planUsage.budgetUsd),
|
||||
spent: convertCost(planUsage.spentApiEquivalentUsd),
|
||||
percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
|
||||
status: planUsage.status,
|
||||
projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
|
||||
daysUntilReset: planUsage.daysUntilReset,
|
||||
periodStart: planUsage.periodStart.toISOString(),
|
||||
periodEnd: planUsage.periodEnd.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
|
||||
console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
|
||||
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
report.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
|
||||
const program = new Command()
|
||||
|
|
@ -483,11 +524,21 @@ program
|
|||
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
|
||||
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
|
||||
const { code, rate } = getCurrency()
|
||||
console.log(JSON.stringify({
|
||||
const payload: {
|
||||
currency: string
|
||||
today: { cost: number; calls: number }
|
||||
month: { cost: number; calls: number }
|
||||
plan?: JsonPlanSummary
|
||||
} = {
|
||||
currency: code,
|
||||
today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
|
||||
month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
|
||||
}))
|
||||
}
|
||||
const planUsage = await getPlanUsageOrNull()
|
||||
if (planUsage) {
|
||||
payload.plan = toJsonPlanSummary(planUsage)
|
||||
}
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +689,125 @@ program
|
|||
console.log(` Config saved to ${getConfigFilePath()}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('plan [action] [id]')
|
||||
.description('Show or configure a subscription plan for overage tracking')
|
||||
.option('--format <format>', 'Output format: text or json', 'text')
|
||||
.option('--monthly-usd <n>', 'Monthly plan price in USD (for custom)', parseNumber)
|
||||
.option('--provider <name>', 'Provider scope: all, claude, codex, cursor', 'all')
|
||||
.option('--reset-day <n>', 'Day of month plan resets (1-28)', parseInteger, 1)
|
||||
.action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
|
||||
const mode = action ?? 'show'
|
||||
|
||||
if (mode === 'show') {
|
||||
const plan = await readPlan()
|
||||
const displayPlan = !plan || plan.id === 'none'
|
||||
? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
|
||||
: {
|
||||
id: plan.id,
|
||||
monthlyUsd: plan.monthlyUsd,
|
||||
provider: plan.provider,
|
||||
resetDay: clampResetDay(plan.resetDay),
|
||||
setAt: plan.setAt,
|
||||
}
|
||||
if (opts?.format === 'json') {
|
||||
console.log(JSON.stringify(displayPlan))
|
||||
return
|
||||
}
|
||||
if (!plan || plan.id === 'none') {
|
||||
console.log('\n Plan: none')
|
||||
console.log(' API-pricing view is active.')
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
|
||||
console.log(` Budget: $${plan.monthlyUsd}/month`)
|
||||
console.log(` Provider: ${plan.provider}`)
|
||||
console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
|
||||
console.log(` Set at: ${plan.setAt}`)
|
||||
console.log(` Config: ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'reset') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode !== 'set') {
|
||||
console.error('\n Usage: codeburn plan [set <id> | reset]\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!id || !isPlanId(id)) {
|
||||
console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const resetDay = opts?.resetDay ?? 1
|
||||
if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
|
||||
console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (id === 'none') {
|
||||
await clearPlan()
|
||||
console.log('\n Plan reset. API-pricing view is active.\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (id === 'custom') {
|
||||
if (opts?.monthlyUsd === undefined) {
|
||||
console.error('\n Custom plans require --monthly-usd <positive number>.\n')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const monthlyUsd = opts.monthlyUsd
|
||||
if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
|
||||
console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const provider = opts?.provider ?? 'all'
|
||||
if (!isPlanProvider(provider)) {
|
||||
console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await savePlan({
|
||||
id: 'custom',
|
||||
monthlyUsd,
|
||||
provider,
|
||||
resetDay,
|
||||
setAt: new Date().toISOString(),
|
||||
})
|
||||
console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
|
||||
console.log(` Config saved to ${getConfigFilePath()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const preset = getPresetPlan(id)
|
||||
if (!preset) {
|
||||
console.error(`\n Unknown preset "${id}".\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await savePlan({
|
||||
...preset,
|
||||
resetDay,
|
||||
setAt: new Date().toISOString(),
|
||||
})
|
||||
console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
|
||||
console.log(` Provider: ${preset.provider}`)
|
||||
console.log(` Reset day: ${resetDay}`)
|
||||
console.log(` Config saved to ${getConfigFilePath()}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('optimize')
|
||||
.description('Find token waste and get exact fixes')
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { readFile, writeFile, mkdir, rename } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
export type PlanId = 'claude-pro' | 'claude-max' | 'cursor-pro' | 'custom' | 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all'
|
||||
|
||||
export type Plan = {
|
||||
id: PlanId
|
||||
monthlyUsd: number
|
||||
provider: PlanProvider
|
||||
resetDay?: number
|
||||
setAt: string
|
||||
}
|
||||
|
||||
export type CodeburnConfig = {
|
||||
currency?: {
|
||||
code: string
|
||||
symbol?: string
|
||||
}
|
||||
plan?: Plan
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
|
|
@ -21,14 +33,37 @@ export async function readConfig(): Promise<CodeburnConfig> {
|
|||
try {
|
||||
const raw = await readFile(getConfigPath(), 'utf-8')
|
||||
return JSON.parse(raw) as CodeburnConfig
|
||||
} catch {
|
||||
return {}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(config: CodeburnConfig): Promise<void> {
|
||||
await mkdir(getConfigDir(), { recursive: true })
|
||||
await writeFile(getConfigPath(), JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
const configPath = getConfigPath()
|
||||
const tmpPath = `${configPath}.tmp`
|
||||
await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
await rename(tmpPath, configPath)
|
||||
}
|
||||
|
||||
export async function readPlan(): Promise<Plan | undefined> {
|
||||
const config = await readConfig()
|
||||
return config.plan
|
||||
}
|
||||
|
||||
export async function savePlan(plan: Plan): Promise<void> {
|
||||
const config = await readConfig()
|
||||
config.plan = plan
|
||||
await saveConfig(config)
|
||||
}
|
||||
|
||||
export async function clearPlan(): Promise<void> {
|
||||
const config = await readConfig()
|
||||
delete config.plan
|
||||
await saveConfig(config)
|
||||
}
|
||||
|
||||
export function getConfigFilePath(): string {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult
|
|||
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import { CompareView } from './compare.js'
|
||||
import { getPlanUsageOrNullForProjects, type PlanUsage } from './plan-usage.js'
|
||||
import { planDisplayName } from './plans.js'
|
||||
import { join } from 'path'
|
||||
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
|
@ -29,6 +31,7 @@ const MIN_WIDE = 90
|
|||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
const GOLD = '#FFD700'
|
||||
const PLAN_BAR_WIDTH = 10
|
||||
|
||||
const LANG_DISPLAY_NAMES: Record<string, string> = {
|
||||
javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python',
|
||||
|
|
@ -154,7 +157,17 @@ function fit(s: string, n: number): string {
|
|||
return s.length > n ? s.slice(0, n) : s.padEnd(n)
|
||||
}
|
||||
|
||||
function Overview({ projects, label, width }: { projects: ProjectSummary[]; label: string; width: number }) {
|
||||
function formatUsd(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
function renderPlanBar(percentUsed: number, width: number): string {
|
||||
const capped = Math.max(0, Math.min(100, percentUsed))
|
||||
const filled = Math.round((capped / 100) * width)
|
||||
return `${'▓'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`
|
||||
}
|
||||
|
||||
function Overview({ projects, label, width, planUsage }: { projects: ProjectSummary[]; label: string; width: number; planUsage?: PlanUsage }) {
|
||||
const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
|
||||
|
|
@ -166,6 +179,15 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe
|
|||
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
|
||||
const cacheHit = allInputTokens > 0
|
||||
? (totalCacheRead / allInputTokens) * 100 : 0
|
||||
const planLabel = planUsage ? `${planDisplayName(planUsage.plan.id)} plan: ${formatUsd(planUsage.spentApiEquivalentUsd)} equiv / ${formatUsd(planUsage.budgetUsd)} included` : ''
|
||||
const planPct = planUsage ? `${planUsage.percentUsed.toFixed(1)}%` : ''
|
||||
const planColor = planUsage
|
||||
? planUsage.status === 'over'
|
||||
? '#F55B5B'
|
||||
: planUsage.status === 'near'
|
||||
? ORANGE
|
||||
: '#5BF58C'
|
||||
: DIM
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={PANEL_COLORS.overview} paddingX={1} width={width}>
|
||||
|
|
@ -186,6 +208,24 @@ function Overview({ projects, label, width }: { projects: ProjectSummary[]; labe
|
|||
<Text dimColor wrap="truncate-end">
|
||||
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
|
||||
</Text>
|
||||
{planUsage && (
|
||||
<>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={planColor}>{planLabel}</Text>
|
||||
<Text> </Text>
|
||||
<Text color={planColor}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
|
||||
<Text> </Text>
|
||||
<Text bold color={planColor}>{planPct}</Text>
|
||||
</Text>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{planUsage.status === 'under'
|
||||
? `Well within plan. Projected month: ${formatUsd(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
: planUsage.status === 'near'
|
||||
? `Approaching plan limit. Projected month: ${formatUsd(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
|
||||
: `You're ${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x over subscription value; running on API overage pricing.`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -558,7 +598,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children
|
|||
return <>{children}</>
|
||||
}
|
||||
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget> }) {
|
||||
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsage?: PlanUsage }) {
|
||||
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
|
||||
const isCursor = activeProvider === 'cursor'
|
||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
||||
|
|
@ -566,7 +606,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
|
|||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsage={planUsage} />
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
|
|
@ -579,10 +619,11 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
|
|||
)
|
||||
}
|
||||
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, refreshSeconds, projectFilter, excludeFilter }: {
|
||||
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter }: {
|
||||
initialProjects: ProjectSummary[]
|
||||
initialPeriod: Period
|
||||
initialProvider: string
|
||||
initialPlanUsage?: PlanUsage
|
||||
refreshSeconds?: number
|
||||
projectFilter?: string[]
|
||||
excludeFilter?: string[]
|
||||
|
|
@ -596,6 +637,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
const multipleProviders = detectedProviders.length > 1
|
||||
|
|
@ -605,6 +647,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
).size
|
||||
const compareAvailable = modelCount >= 2
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reloadGenerationRef = useRef(0)
|
||||
const findingCount = optimizeResult?.findings.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -648,11 +691,28 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
}, [projects, period, optimizeAvailable])
|
||||
|
||||
const reloadData = useCallback(async (p: Period, prov: string) => {
|
||||
const generation = ++reloadGenerationRef.current
|
||||
setLoading(true)
|
||||
const range = getDateRange(p)
|
||||
const data = filterProjectsByName(await parseAllSessions(range, prov), projectFilter, excludeFilter)
|
||||
setProjects(data)
|
||||
setLoading(false)
|
||||
setOptimizeResult(null)
|
||||
try {
|
||||
const range = getDateRange(p)
|
||||
const data = await parseAllSessions(range, prov)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
|
||||
const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
|
||||
setProjects(filteredProjects)
|
||||
const usage = await getPlanUsageOrNullForProjects(filteredProjects)
|
||||
if (reloadGenerationRef.current !== generation) return
|
||||
setPlanUsage(usage ?? undefined)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (reloadGenerationRef.current === generation) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [projectFilter, excludeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -721,19 +781,19 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||
: view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} />}
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsage={planUsage} />}
|
||||
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDashboard({ projects, period, activeProvider }: { projects: ProjectSummary[]; period: Period; activeProvider?: string }) {
|
||||
function StaticDashboard({ projects, period, activeProvider, planUsage }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsage?: PlanUsage }) {
|
||||
const { columns } = useWindowSize()
|
||||
const { dashWidth } = getLayout(columns)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} />
|
||||
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsage={planUsage} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -741,15 +801,16 @@ function StaticDashboard({ projects, period, activeProvider }: { projects: Proje
|
|||
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
|
||||
await loadPricing()
|
||||
const range = customRange ?? getDateRange(period)
|
||||
const projects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
|
||||
const planUsage = await getPlanUsageOrNullForProjects(filteredProjects)
|
||||
const isTTY = process.stdin.isTTY && process.stdout.isTTY
|
||||
if (isTTY) {
|
||||
const { waitUntilExit } = render(
|
||||
<InteractiveDashboard initialProjects={projects} initialPeriod={period} initialProvider={provider} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
|
||||
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
|
||||
)
|
||||
await waitUntilExit()
|
||||
} else {
|
||||
const { unmount } = render(<StaticDashboard projects={projects} period={period} activeProvider={provider} />, { patchConsole: false })
|
||||
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsage={planUsage ?? undefined} />, { patchConsole: false })
|
||||
unmount()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
src/plan-usage.ts
Normal file
154
src/plan-usage.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { readPlan, type Plan } from './config.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
import type { DateRange, ProjectSummary } from './types.js'
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
const PLAN_NEAR_THRESHOLD_PCT = 80
|
||||
|
||||
export type PlanStatus = 'under' | 'near' | 'over'
|
||||
|
||||
export type PlanUsage = {
|
||||
plan: Plan
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
spentApiEquivalentUsd: number
|
||||
budgetUsd: number
|
||||
percentUsed: number
|
||||
status: PlanStatus
|
||||
projectedMonthUsd: number
|
||||
daysUntilReset: number
|
||||
}
|
||||
|
||||
export function clampResetDay(resetDay: number | undefined): number {
|
||||
if (!Number.isInteger(resetDay)) return 1
|
||||
return Math.min(28, Math.max(1, resetDay ?? 1))
|
||||
}
|
||||
|
||||
export function computePeriodFromResetDay(resetDay: number | undefined, today: Date): { periodStart: Date; periodEnd: Date } {
|
||||
const day = clampResetDay(resetDay)
|
||||
const year = today.getFullYear()
|
||||
const month = today.getMonth()
|
||||
|
||||
if (today.getDate() >= day) {
|
||||
return {
|
||||
periodStart: new Date(year, month, day, 0, 0, 0, 0),
|
||||
periodEnd: new Date(year, month + 1, day, 0, 0, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
periodStart: new Date(year, month - 1, day, 0, 0, 0, 0),
|
||||
periodEnd: new Date(year, month, day, 0, 0, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
return sorted[mid]!
|
||||
}
|
||||
|
||||
function toLocalDateKey(d: Date): string {
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function toDayIndex(d: Date): number {
|
||||
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_PER_DAY)
|
||||
}
|
||||
|
||||
function diffCalendarDays(from: Date, to: Date): number {
|
||||
return toDayIndex(to) - toDayIndex(from)
|
||||
}
|
||||
|
||||
export function projectMonthEnd(
|
||||
projects: ProjectSummary[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
today: Date,
|
||||
spent: number,
|
||||
): number {
|
||||
const dayCosts = new Map<string, number>()
|
||||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
if (!turn.timestamp) continue
|
||||
const ts = new Date(turn.timestamp)
|
||||
if (Number.isNaN(ts.getTime())) continue
|
||||
if (ts < periodStart || ts > today) continue
|
||||
const dayKey = toLocalDateKey(ts)
|
||||
const turnCost = turn.assistantCalls.reduce((sum, call) => sum + call.costUSD, 0)
|
||||
dayCosts.set(dayKey, (dayCosts.get(dayKey) ?? 0) + turnCost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedDays = Math.max(1, diffCalendarDays(periodStart, today) + 1)
|
||||
const elapsedDailyCosts: number[] = []
|
||||
for (let i = 0; i < elapsedDays; i++) {
|
||||
const date = new Date(periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate() + i)
|
||||
elapsedDailyCosts.push(dayCosts.get(toLocalDateKey(date)) ?? 0)
|
||||
}
|
||||
|
||||
const trailingWindow = elapsedDailyCosts.slice(-7)
|
||||
const medianDailyCost = median(trailingWindow)
|
||||
const daysRemaining = Math.max(0, diffCalendarDays(today, periodEnd) - 1)
|
||||
|
||||
return spent + medianDailyCost * daysRemaining
|
||||
}
|
||||
|
||||
export function getPlanUsageFromProjects(plan: Plan, projects: ProjectSummary[], today = new Date()): PlanUsage {
|
||||
const { periodStart, periodEnd } = computePeriodFromResetDay(plan.resetDay, today)
|
||||
const spent = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
|
||||
const budgetUsd = plan.monthlyUsd
|
||||
const percentUsed = budgetUsd > 0 ? (spent / budgetUsd) * 100 : 0
|
||||
const status: PlanStatus = percentUsed > 100 ? 'over' : percentUsed >= PLAN_NEAR_THRESHOLD_PCT ? 'near' : 'under'
|
||||
const projectedMonthUsd = projectMonthEnd(projects, periodStart, periodEnd, today, spent)
|
||||
const daysUntilReset = Math.max(0, diffCalendarDays(today, periodEnd))
|
||||
|
||||
return {
|
||||
plan,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
spentApiEquivalentUsd: spent,
|
||||
budgetUsd,
|
||||
percentUsed,
|
||||
status,
|
||||
projectedMonthUsd,
|
||||
daysUntilReset,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlanUsage(plan: Plan, today = new Date()): Promise<PlanUsage> {
|
||||
const { periodStart } = computePeriodFromResetDay(plan.resetDay, today)
|
||||
const range: DateRange = {
|
||||
start: periodStart,
|
||||
end: today,
|
||||
}
|
||||
const provider = plan.provider === 'all' ? 'all' : plan.provider
|
||||
const projects = await parseAllSessions(range, provider)
|
||||
return getPlanUsageFromProjects(plan, projects, today)
|
||||
}
|
||||
|
||||
export async function getPlanUsageOrNull(today = new Date()): Promise<PlanUsage | null> {
|
||||
const plan = await readPlan()
|
||||
if (!isActivePlan(plan)) return null
|
||||
return getPlanUsage(plan, today)
|
||||
}
|
||||
|
||||
export async function getPlanUsageOrNullForProjects(projects: ProjectSummary[], today = new Date()): Promise<PlanUsage | null> {
|
||||
const plan = await readPlan()
|
||||
if (!isActivePlan(plan)) return null
|
||||
return getPlanUsageFromProjects(plan, projects, today)
|
||||
}
|
||||
|
||||
export function isActivePlan(plan: Plan | undefined): plan is Plan {
|
||||
return Boolean(plan) && plan.id !== 'none' && Number.isFinite(plan.monthlyUsd) && plan.monthlyUsd > 0
|
||||
}
|
||||
55
src/plans.ts
Normal file
55
src/plans.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { Plan, PlanId, PlanProvider } from './config.js'
|
||||
|
||||
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor']
|
||||
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'cursor-pro', 'custom', 'none']
|
||||
|
||||
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'cursor-pro', Omit<Plan, 'setAt'>> = {
|
||||
'claude-pro': {
|
||||
id: 'claude-pro',
|
||||
monthlyUsd: 20,
|
||||
provider: 'claude',
|
||||
resetDay: 1,
|
||||
},
|
||||
'claude-max': {
|
||||
id: 'claude-max',
|
||||
monthlyUsd: 200,
|
||||
provider: 'claude',
|
||||
resetDay: 1,
|
||||
},
|
||||
'cursor-pro': {
|
||||
id: 'cursor-pro',
|
||||
monthlyUsd: 20,
|
||||
provider: 'cursor',
|
||||
resetDay: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export function isPlanProvider(value: string): value is PlanProvider {
|
||||
return PLAN_PROVIDERS.includes(value as PlanProvider)
|
||||
}
|
||||
|
||||
export function isPlanId(value: string): value is PlanId {
|
||||
return PLAN_IDS.includes(value as PlanId)
|
||||
}
|
||||
|
||||
export function getPresetPlan(id: string): Omit<Plan, 'setAt'> | null {
|
||||
if (id in PRESET_PLANS) {
|
||||
return PRESET_PLANS[id as keyof typeof PRESET_PLANS]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function planDisplayName(id: PlanId): string {
|
||||
switch (id) {
|
||||
case 'claude-pro':
|
||||
return 'Claude Pro'
|
||||
case 'claude-max':
|
||||
return 'Claude Max'
|
||||
case 'cursor-pro':
|
||||
return 'Cursor Pro'
|
||||
case 'custom':
|
||||
return 'Custom'
|
||||
case 'none':
|
||||
return 'None'
|
||||
}
|
||||
}
|
||||
55
tests/cli-plan.test.ts
Normal file
55
tests/cli-plan.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
function runCli(args: string[], home: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
}
|
||||
|
||||
describe('codeburn plan command', () => {
|
||||
it('persists plan set and clears on reset', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
|
||||
|
||||
try {
|
||||
const setResult = runCli(['plan', 'set', 'claude-max'], home)
|
||||
expect(setResult.status).toBe(0)
|
||||
|
||||
const configPath = join(home, '.config', 'codeburn', 'config.json')
|
||||
const configRaw = await readFile(configPath, 'utf-8')
|
||||
const config = JSON.parse(configRaw) as { plan?: { id?: string; monthlyUsd?: number } }
|
||||
expect(config.plan?.id).toBe('claude-max')
|
||||
expect(config.plan?.monthlyUsd).toBe(200)
|
||||
|
||||
const resetResult = runCli(['plan', 'reset'], home)
|
||||
expect(resetResult.status).toBe(0)
|
||||
|
||||
const afterResetRaw = await readFile(configPath, 'utf-8')
|
||||
const afterReset = JSON.parse(afterResetRaw) as { plan?: unknown }
|
||||
expect(afterReset.plan).toBeUndefined()
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('shows invalid reset-day value in error output', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-cli-plan-'))
|
||||
|
||||
try {
|
||||
const result = runCli(['plan', 'set', 'claude-max', '--reset-day', '99'], home)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('--reset-day must be an integer from 1 to 28; got 99.')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
122
tests/plan-usage.test.ts
Normal file
122
tests/plan-usage.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { computePeriodFromResetDay, getPlanUsage, getPlanUsageFromProjects } from '../src/plan-usage.js'
|
||||
|
||||
const { parseAllSessionsMock } = vi.hoisted(() => ({
|
||||
parseAllSessionsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../src/parser.js', () => ({
|
||||
parseAllSessions: parseAllSessionsMock,
|
||||
}))
|
||||
|
||||
describe('computePeriodFromResetDay', () => {
|
||||
it('uses current month when today is on/after reset day', () => {
|
||||
const { periodStart, periodEnd } = computePeriodFromResetDay(1, new Date('2026-04-17T10:00:00.000Z'))
|
||||
expect(periodStart.getFullYear()).toBe(2026)
|
||||
expect(periodStart.getMonth()).toBe(3)
|
||||
expect(periodStart.getDate()).toBe(1)
|
||||
expect(periodEnd.getMonth()).toBe(4)
|
||||
expect(periodEnd.getDate()).toBe(1)
|
||||
})
|
||||
|
||||
it('uses previous month when today is before reset day', () => {
|
||||
const { periodStart, periodEnd } = computePeriodFromResetDay(15, new Date('2026-04-03T10:00:00.000Z'))
|
||||
expect(periodStart.getMonth()).toBe(2)
|
||||
expect(periodStart.getDate()).toBe(15)
|
||||
expect(periodEnd.getMonth()).toBe(3)
|
||||
expect(periodEnd.getDate()).toBe(15)
|
||||
})
|
||||
|
||||
it('clamps reset day into 1..28', () => {
|
||||
const { periodStart } = computePeriodFromResetDay(99, new Date('2026-04-27T10:00:00.000Z'))
|
||||
expect(periodStart.getDate()).toBe(28)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlanUsage', () => {
|
||||
beforeEach(() => {
|
||||
parseAllSessionsMock.mockReset()
|
||||
})
|
||||
|
||||
it('passes provider filter from plan and computes status', async () => {
|
||||
parseAllSessionsMock.mockResolvedValue([
|
||||
{
|
||||
totalCostUSD: 160,
|
||||
sessions: [],
|
||||
},
|
||||
])
|
||||
|
||||
const usage = await getPlanUsage({
|
||||
id: 'claude-max',
|
||||
monthlyUsd: 200,
|
||||
provider: 'claude',
|
||||
resetDay: 1,
|
||||
setAt: '2026-04-01T00:00:00.000Z',
|
||||
}, new Date('2026-04-10T10:00:00.000Z'))
|
||||
|
||||
expect(parseAllSessionsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
|
||||
'claude',
|
||||
)
|
||||
expect(usage.spentApiEquivalentUsd).toBe(160)
|
||||
expect(usage.percentUsed).toBe(80)
|
||||
expect(usage.status).toBe('near')
|
||||
})
|
||||
|
||||
it('projects using median daily spend (not mean)', async () => {
|
||||
const dailyCosts = [1, 100, 1, 100, 1, 100, 1]
|
||||
const turns = dailyCosts.map((cost, idx) => ({
|
||||
timestamp: `2026-04-${String(idx + 1).padStart(2, '0')}T12:00:00.000Z`,
|
||||
assistantCalls: [{ costUSD: cost }],
|
||||
}))
|
||||
|
||||
parseAllSessionsMock.mockResolvedValue([
|
||||
{
|
||||
totalCostUSD: dailyCosts.reduce((sum, value) => sum + value, 0),
|
||||
sessions: [{ turns }],
|
||||
},
|
||||
])
|
||||
|
||||
const usage = await getPlanUsage({
|
||||
id: 'custom',
|
||||
monthlyUsd: 500,
|
||||
provider: 'all',
|
||||
resetDay: 1,
|
||||
setAt: '2026-04-01T00:00:00.000Z',
|
||||
}, new Date('2026-04-07T12:00:00.000Z'))
|
||||
|
||||
// Median(1,100,1,100,1,100,1) = 1, so remaining 23 days adds 23.
|
||||
expect(Math.round(usage.projectedMonthUsd)).toBe(327)
|
||||
expect(parseAllSessionsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
|
||||
'all',
|
||||
)
|
||||
})
|
||||
|
||||
it('computes plan usage from pre-fetched projects', () => {
|
||||
const usage = getPlanUsageFromProjects({
|
||||
id: 'custom',
|
||||
monthlyUsd: 100,
|
||||
provider: 'all',
|
||||
resetDay: 1,
|
||||
setAt: '2026-04-01T00:00:00.000Z',
|
||||
}, [
|
||||
{
|
||||
totalCostUSD: 40,
|
||||
sessions: [
|
||||
{
|
||||
turns: [
|
||||
{ timestamp: '2026-04-02T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
|
||||
{ timestamp: '2026-04-03T12:00:00.000Z', assistantCalls: [{ costUSD: 20 }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
], new Date('2026-04-10T10:00:00.000Z'))
|
||||
|
||||
expect(usage.spentApiEquivalentUsd).toBe(40)
|
||||
expect(usage.budgetUsd).toBe(100)
|
||||
expect(usage.status).toBe('under')
|
||||
})
|
||||
})
|
||||
63
tests/plans.test.ts
Normal file
63
tests/plans.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { clearPlan, readPlan, savePlan } from '../src/config.js'
|
||||
import { getPresetPlan, isPlanId, isPlanProvider } from '../src/plans.js'
|
||||
|
||||
describe('plan presets', () => {
|
||||
it('resolves builtin presets', () => {
|
||||
expect(getPresetPlan('claude-pro')).toMatchObject({ id: 'claude-pro', monthlyUsd: 20, provider: 'claude' })
|
||||
expect(getPresetPlan('claude-max')).toMatchObject({ id: 'claude-max', monthlyUsd: 200, provider: 'claude' })
|
||||
expect(getPresetPlan('cursor-pro')).toMatchObject({ id: 'cursor-pro', monthlyUsd: 20, provider: 'cursor' })
|
||||
expect(getPresetPlan('custom')).toBeNull()
|
||||
})
|
||||
|
||||
it('validates ids and providers', () => {
|
||||
expect(isPlanId('claude-pro')).toBe(true)
|
||||
expect(isPlanId('none')).toBe(true)
|
||||
expect(isPlanId('bad-plan')).toBe(false)
|
||||
|
||||
expect(isPlanProvider('all')).toBe(true)
|
||||
expect(isPlanProvider('claude')).toBe(true)
|
||||
expect(isPlanProvider('invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan config persistence', () => {
|
||||
it('round-trips savePlan/readPlan and clearPlan', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-plan-test-'))
|
||||
const previousHome = process.env['HOME']
|
||||
process.env['HOME'] = dir
|
||||
|
||||
try {
|
||||
await savePlan({
|
||||
id: 'claude-max',
|
||||
monthlyUsd: 200,
|
||||
provider: 'claude',
|
||||
resetDay: 12,
|
||||
setAt: '2026-04-17T12:00:00.000Z',
|
||||
})
|
||||
|
||||
const plan = await readPlan()
|
||||
expect(plan).toMatchObject({
|
||||
id: 'claude-max',
|
||||
monthlyUsd: 200,
|
||||
provider: 'claude',
|
||||
resetDay: 12,
|
||||
})
|
||||
|
||||
await clearPlan()
|
||||
expect(await readPlan()).toBeUndefined()
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env['HOME']
|
||||
} else {
|
||||
process.env['HOME'] = previousHome
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue