fix(plans): say monthly budget, not calendar month, and fit 80 columns

The budget window comes from computePeriodFromResetDay, which builds an
anniversary period from plan.resetDay (1-28, settable per plan with
`codeburn plan set --reset-day`). "Calendar-month budget" and "Next
calendar reset" are therefore wrong for anyone who moved the reset day,
which is the same class of inaccuracy this change set exists to remove.
Say "budget" and "Next budget reset" instead, and use one wording across
the TUI and the desktop cards.

Both TUI lines truncate end-first at the terminal width. The headline had
grown past the point where an 80-column terminal still showed the
percentage, so it drops "vs ... /mo" for "/ $300.00 budget", and the
status line drops the clause repeating "budget" from the headline. At 80
columns the longest label (custom plans carry their provider) now fits
the percentage, and the status line still shows the projection.
This commit is contained in:
iamtoruk 2026-08-19 11:30:07 -07:00
parent a4384d28f2
commit 911bd3f486
4 changed files with 54 additions and 16 deletions

View file

@ -119,7 +119,7 @@ describe('Plans', () => {
expect(screen.getByRole('heading', { name: 'Budget plans' })).toBeInTheDocument()
expect(screen.getByText('Cursor Pro')).toBeInTheDocument()
expect(screen.getByText('$20.00 / calendar month · API-equivalent budget, not live quota · cursor')).toBeInTheDocument()
expect(screen.getByText('$20.00 / month budget · API-equivalent, not a live provider window · cursor')).toBeInTheDocument()
expect(screen.getByText('$8.20 · 41%')).toBeInTheDocument()
const cursorFill = container.querySelector('[data-testid="plan-track-cursor"] i')
expect(cursorFill).toHaveStyle({ width: '41%' })
@ -214,7 +214,7 @@ describe('Plans', () => {
render(<Plans period="30days" />)
expect(await screen.findByText('€20.00 / calendar month · API-equivalent budget, not live quota · cursor')).toBeInTheDocument()
expect(await screen.findByText('€20.00 / month budget · API-equivalent, not a live provider window · cursor')).toBeInTheDocument()
expect(screen.getByText('€8.20 · 41%')).toBeInTheDocument()
})

View file

@ -232,7 +232,7 @@ function PlanPanel({ plan }: { plan: JsonPlanSummary }) {
? `${formatConverted(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${formatConverted(overage)} over` : ''}`
: `${formatConverted(plan.spent)} this cycle`
const detail = hasBudget
? `${formatConverted(plan.budget)} / calendar month · API-equivalent budget, not live quota · ${plan.provider}`
? `${formatConverted(plan.budget)} / month budget · API-equivalent, not a live provider window · ${plan.provider}`
: `${plan.provider} · pay as you go, no plan`
return (

View file

@ -344,19 +344,25 @@ function planColor(planUsage: PlanUsage): string {
: '#5BF58C'
}
// Headline and status share one line's worth of terminal each, both truncated
// end-first, so the headline stays short enough to keep the percentage visible
// at 80 columns and the status leads with the disclaimer, not the arithmetic.
export function planBudgetHeadline(planUsage: PlanUsage): string {
return `${planLabel(planUsage)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs ${formatCost(planUsage.budgetUsd)}/mo budget`
return `${planLabel(planUsage)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent / ${formatCost(planUsage.budgetUsd)} budget`
}
export function planStatusText(planUsage: PlanUsage): string {
const calendar = `Calendar-month budget, not a live provider window. Projected: ${formatCost(planUsage.projectedMonthUsd)}. Next calendar reset in ${planUsage.daysUntilReset} days.`
// The period is anniversary-based (plan.resetDay, 1-28, settable per plan via
// `codeburn plan set --reset-day`), so this is a monthly budget window, not a
// calendar month. The headline already says "budget"; do not repeat it here.
const detail = `Not a live provider window. Projected: ${formatCost(planUsage.projectedMonthUsd)}. Next budget reset in ${planUsage.daysUntilReset} days.`
if (planUsage.status === 'under') {
return `Well within budget. ${calendar}`
return `Well within budget. ${detail}`
}
if (planUsage.status === 'near') {
return `Approaching budget. ${calendar}`
return `Approaching budget. ${detail}`
}
return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x the sticker price. ${calendar}`
return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x the sticker price. ${detail}`
}
function Overview({ projects, label, width, planUsages, durable }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[]; durable?: DurableOverview }) {

View file

@ -24,15 +24,18 @@ function usage(overrides: Partial<PlanUsage> = {}): PlanUsage {
}
}
// The TUI truncates both lines end-first at the terminal width, so the row is
// headline + two spaces + the 10-cell bar + a space + the percentage.
const PLAN_ROW_TAIL = ' ' + '#'.repeat(10) + ' '
function planRow(planUsage: PlanUsage): string {
return planBudgetHeadline(planUsage) + PLAN_ROW_TAIL + planUsage.percentUsed.toFixed(1) + '%'
}
describe('plan budget copy', () => {
it('labels SuperGrok as a calendar-month budget, not a live window', () => {
const text = planBudgetHeadline(usage())
expect(text).toContain('/mo budget')
expect(text).not.toMatch(/\bplan\b/)
it('labels SuperGrok as a budget, not a live window', () => {
expect(planBudgetHeadline(usage())).toBe('SuperGrok Heavy: $33.82 API-equivalent / $300.00 budget')
const status = planStatusText(usage())
expect(status).toContain('not a live provider window')
expect(status).toContain('Calendar-month budget')
expect(status).not.toMatch(/Well within plan/)
expect(status).toBe('Well within budget. Not a live provider window. Projected: $40.00. Next budget reset in 13 days.')
})
it('uses the same budget language for every preset, not only Grok', () => {
@ -48,7 +51,36 @@ describe('plan budget copy', () => {
spentApiEquivalentUsd: 8.2,
status: 'near',
})
expect(planBudgetHeadline(cursor)).toContain('/mo budget')
expect(planBudgetHeadline(cursor)).toBe('Cursor Pro: $8.20 API-equivalent / $20.00 budget')
expect(planStatusText(cursor)).toContain('Approaching budget')
})
// computePeriodFromResetDay builds an anniversary window from plan.resetDay
// (1-28, settable with `codeburn plan set --reset-day`), so a plan that resets
// on the 15th is NOT on a calendar month and must never be called one.
it('never calls an anniversary reset window a calendar month', () => {
for (const resetDay of [1, 15, 28]) {
const status = planStatusText(usage({
plan: { id: 'supergrok-heavy', monthlyUsd: 300, provider: 'grok', resetDay, setAt: '2026-08-01T00:00:00.000Z' },
daysUntilReset: 4,
}))
expect(status).not.toMatch(/calendar/i)
expect(status).toContain('Next budget reset in 4 days.')
}
})
// The row and the status each get one truncate-end line. At 80 columns the
// percentage must survive on the row and the projection on the status line.
it('keeps the percentage and the projection readable at 80 columns', () => {
for (const planUsage of [
usage(),
usage({ status: 'over', spentApiEquivalentUsd: 640, percentUsed: 213.3 }),
usage({ plan: { id: 'custom', monthlyUsd: 300, provider: 'openrouter', resetDay: 1, setAt: '2026-08-01T00:00:00.000Z' } }),
]) {
const row = planRow(planUsage)
expect(row.length).toBeLessThanOrEqual(80)
expect(row.slice(0, 80)).toContain(`${planUsage.percentUsed.toFixed(1)}%`)
expect(planStatusText(planUsage).slice(0, 80)).toContain('Projected: $40.00.')
}
})
})