fix(app): Plans — inclusive local cycle end (periodEnd-1d), correct day count, honest near copy

Review fix: periodEnd is exclusive (next reset), so the cycle end now renders periodEnd-1 day
with totalDays=diff (was Jul 15/31, now Jul 14/30); dates format in local time (not UTC);
test mock re-derived from the real emitter. Minor: en/em dashes, CLI provider order, non-
contradictory near-status copy, +near-status and plan-singular tests.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
iamtoruk 2026-07-10 19:12:42 -07:00
parent e1bb78399c
commit 73fb86dec6
2 changed files with 125 additions and 53 deletions

View file

@ -2,7 +2,7 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { StatusJson } from '../lib/types'
import type { JsonPlanSummary, StatusJson } from '../lib/types'
import { Plans } from './Plans'
const { getPlans } = vi.hoisted(() => ({
@ -13,47 +13,60 @@ vi.mock('../lib/ipc', async orig => {
return { ...actual, codeburn: { getPlans } }
})
const statusWithPlans: StatusJson = {
const periodStart = new Date(2026, 5, 15).toISOString()
const periodEnd = new Date(2026, 6, 15).toISOString()
const claudePlan: JsonPlanSummary = {
id: 'claude-max',
provider: 'claude',
budget: 200,
spent: 230,
percentUsed: 115,
status: 'over',
projectedMonthEnd: 254,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const cursorPlan: JsonPlanSummary = {
id: 'cursor-pro',
provider: 'cursor',
budget: 20,
spent: 8.2,
percentUsed: 41,
status: 'under',
projectedMonthEnd: 12.4,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const codexPlan: JsonPlanSummary = {
id: 'none',
provider: 'codex',
budget: 0,
spent: 31.02,
percentUsed: 15,
status: 'under',
projectedMonthEnd: 31.02,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const baseStatus = {
currency: 'USD',
today: { cost: 22.5, savings: 4.2, calls: 19 },
month: { cost: 269.02, savings: 52, calls: 181 },
} satisfies Omit<StatusJson, 'plan' | 'plans'>
const statusWithPlans: StatusJson = {
...baseStatus,
plans: {
claude: {
id: 'claude-max',
provider: 'claude',
budget: 200,
spent: 230,
percentUsed: 115,
status: 'over',
projectedMonthEnd: 254,
daysUntilReset: 4,
periodStart: '2026-06-15T00:00:00.000Z',
periodEnd: '2026-07-14T00:00:00.000Z',
},
cursor: {
id: 'cursor-pro',
provider: 'cursor',
budget: 20,
spent: 8.2,
percentUsed: 41,
status: 'under',
projectedMonthEnd: 12.4,
daysUntilReset: 4,
periodStart: '2026-06-15T00:00:00.000Z',
periodEnd: '2026-07-14T00:00:00.000Z',
},
codex: {
id: 'none',
provider: 'codex',
budget: 0,
spent: 31.02,
percentUsed: 15,
status: 'under',
projectedMonthEnd: 31.02,
daysUntilReset: 4,
periodStart: '2026-06-15T00:00:00.000Z',
periodEnd: '2026-07-14T00:00:00.000Z',
},
claude: claudePlan,
cursor: cursorPlan,
codex: codexPlan,
},
}
@ -68,8 +81,13 @@ describe('Plans', () => {
const { container } = render(<Plans period="30days" />)
expect(await screen.findByText('Claude Max')).toBeInTheDocument()
expect(screen.getByText('Cycle Jun 15 - Jul 14 · day 26 of 30')).toBeInTheDocument()
expect(screen.getByText('Cycle: Jun 15 - Jul 14')).toBeInTheDocument()
expect([...container.querySelectorAll('.plrow b')].map(row => row.textContent)).toEqual([
'Claude Max',
'API usage',
'Cursor Pro',
])
expect(screen.getByText('Cycle Jun 15 Jul 14 · day 26 of 30')).toBeInTheDocument()
expect(screen.getByText('Cycle: Jun 15 Jul 14')).toBeInTheDocument()
expect(screen.getByText('$200.00 / month · claude')).toBeInTheDocument()
expect(screen.getByText('$230.00 · 115% · $30.00 over')).toBeInTheDocument()
@ -77,7 +95,7 @@ describe('Plans', () => {
expect(claudeFill).toHaveStyle({ width: '100%' })
expect(claudeFill).toHaveClass('over')
const hotPace = screen.getByText('On pace to exceed - projected $254.00 by Jul 14')
const hotPace = screen.getByText('On pace to exceed projected $254.00 by Jul 14')
expect(hotPace).toHaveClass('pace', 'hot')
expect(screen.getByText('Cursor Pro')).toBeInTheDocument()
@ -96,6 +114,44 @@ describe('Plans', () => {
expect(codexFill).toHaveClass('mut')
})
it('renders near status as an amber non-exceeding projection when below budget', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plans: {
grok: {
id: 'supergrok-heavy',
provider: 'grok',
budget: 300,
spent: 255,
percentUsed: 85,
status: 'near',
projectedMonthEnd: 280,
daysUntilReset: 4,
periodStart,
periodEnd,
},
},
})
render(<Plans period="30days" />)
const pace = await screen.findByText('85% of budget used — projected $280.00 by Jul 14')
expect(pace).toHaveClass('pace', 'hot')
expect(screen.queryByText(/On pace to exceed/)).not.toBeInTheDocument()
})
it('falls back to StatusJson.plan when the CLI returns a singular plan summary', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plan: cursorPlan,
})
render(<Plans period="month" />)
expect(await screen.findByText('Cursor Pro')).toBeInTheDocument()
expect(screen.getByText('Cycle Jun 15 Jul 14 · day 26 of 30')).toBeInTheDocument()
})
it('renders an honest empty state when StatusJson has no plan summaries', async () => {
getPlans.mockResolvedValue({
currency: 'USD',

View file

@ -3,7 +3,7 @@ import { usePolled } from '../hooks/usePolled'
import { codeburn } from '../lib/ipc'
import type { JsonPlanSummary, Period, PlanId, PlanProvider, StatusJson } from '../lib/types'
const PROVIDER_ORDER: PlanProvider[] = ['claude', 'codex', 'cursor', 'grok', 'all']
const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
const MS_PER_DAY = 24 * 60 * 60 * 1000
const PLAN_NAMES: Record<PlanId, string> = {
@ -28,16 +28,22 @@ function fmtPct(n: number): string {
function parseIsoDay(iso: string): number | null {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
}
function formatShortDate(iso: string): string {
const date = new Date(iso)
function cycleEndDate(plan: JsonPlanSummary): Date | null {
const date = new Date(plan.periodEnd)
if (Number.isNaN(date.getTime())) return null
date.setDate(date.getDate() - 1)
return date
}
function formatShortDate(value: string | Date): string {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return 'unknown'
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(date)
}
@ -46,15 +52,16 @@ function cycleLabels(plan: JsonPlanSummary | undefined): { caption: string; pop:
const startDay = parseIsoDay(plan.periodStart)
const endDay = parseIsoDay(plan.periodEnd)
const start = formatShortDate(plan.periodStart)
const end = formatShortDate(plan.periodEnd)
const pop = `Cycle: ${start} - ${end}`
const inclusiveEnd = cycleEndDate(plan)
const end = inclusiveEnd ? formatShortDate(inclusiveEnd) : 'unknown'
const pop = `Cycle: ${start} ${end}`
if (startDay === null || endDay === null) return { caption: `Cycle ${start} - ${end}`, pop }
if (startDay === null || endDay === null) return { caption: `Cycle ${start} ${end}`, pop }
const totalDays = Math.max(1, Math.round((endDay - startDay) / MS_PER_DAY) + 1)
const totalDays = Math.max(1, Math.round((endDay - startDay) / MS_PER_DAY))
const day = Math.min(totalDays, Math.max(1, totalDays - plan.daysUntilReset))
return {
caption: `Cycle ${start} - ${end} · day ${day} of ${totalDays}`,
caption: `Cycle ${start} ${end} · day ${day} of ${totalDays}`,
pop,
}
}
@ -170,10 +177,19 @@ function PlanPanel({ plan }: { plan: JsonPlanSummary }) {
}
function PaceLine({ plan }: { plan: JsonPlanSummary }) {
if (plan.status === 'over' || plan.status === 'near') {
const end = cycleEndDate(plan)
const endLabel = end ? formatShortDate(end) : 'unknown'
if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) {
return (
<div className="pace hot">
On pace to exceed - projected {fmtUsd(plan.projectedMonthEnd)} by {formatShortDate(plan.periodEnd)}
On pace to exceed projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}
if (plan.status === 'near') {
return (
<div className="pace hot">
{fmtPct(plan.percentUsed)} of budget used projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}