diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index 2ce7cfa7..18db5665 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -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() - 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() }) diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index e51e2fa4..127944d2 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -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 ( diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 0e5b1f0e..86ba2145 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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 }) { diff --git a/tests/plan-budget-copy.test.ts b/tests/plan-budget-copy.test.ts index 7ab7991f..787df94f 100644 --- a/tests/plan-budget-copy.test.ts +++ b/tests/plan-budget-copy.test.ts @@ -24,15 +24,18 @@ function usage(overrides: Partial = {}): 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.') + } + }) })