mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-04 05:41:29 +00:00
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:
parent
e1bb78399c
commit
73fb86dec6
2 changed files with 125 additions and 53 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue