feat(app): rebuild Plans on live quota (real 5h/weekly windows + auto-detected tier)

Replace the $-budget model as the primary view: per-provider cards (Claude/Codex)
from getQuota show the real 5-hour/weekly windows (% used, resets-in, severity
colors), the auto-detected subscription tier, connection states, and the credit
footer. Manual budget plans demoted to a secondary section.

typecheck clean; 148/148 tests pass. Needs a full relaunch for live quota.
This commit is contained in:
iamtoruk 2026-07-12 16:48:58 -07:00
parent 880f84d593
commit a216b841ae
4 changed files with 207 additions and 75 deletions

View file

@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
getSessions: vi.fn(),
getCompareModels: vi.fn(),
getCompare: vi.fn(),
getQuota: vi.fn(),
getPlans: vi.fn(),
getActReport: vi.fn(),
getYield: vi.fn(),
@ -104,6 +105,10 @@ describe('App shortcuts', () => {
mocks.getModels.mockResolvedValue([])
mocks.getSessions.mockResolvedValue([])
mocks.getCompareModels.mockResolvedValue([])
mocks.getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
])
mocks.getPlans.mockResolvedValue({})
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
@ -180,7 +185,7 @@ describe('App shortcuts', () => {
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '7', metaKey: true })
expect(await screen.findByText('No plans configured')).toBeInTheDocument()
expect(await screen.findByText('Connect Claude — log in with the Claude CLI')).toBeInTheDocument()
fireEvent.keyDown(document, { key: ',', metaKey: true })
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)

View file

@ -2,15 +2,16 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JsonPlanSummary, StatusJson } from '../lib/types'
import type { JsonPlanSummary, QuotaProvider, StatusJson } from '../lib/types'
import { Plans } from './Plans'
const { getPlans } = vi.hoisted(() => ({
const { getPlans, getQuota } = vi.hoisted(() => ({
getPlans: vi.fn<(period: string) => Promise<StatusJson>>(),
getQuota: vi.fn<() => Promise<QuotaProvider[]>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getPlans } }
return { ...actual, codeburn: { getPlans, getQuota } }
})
const periodStart = new Date(2026, 5, 15).toISOString()
@ -70,34 +71,51 @@ const statusWithPlans: StatusJson = {
},
}
function quotaProviders(): QuotaProvider[] {
const now = Date.now()
return [
{
provider: 'claude',
connection: 'connected',
primary: { label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
details: [
{ label: '5-hour', percent: 0.25, resetsAt: new Date(now + 2 * 60 * 60_000 + 30 * 60_000).toISOString() },
{ label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
],
planLabel: 'Max 20x',
footerLines: [],
},
{
provider: 'codex',
connection: 'disconnected',
primary: null,
details: [],
planLabel: null,
footerLines: [],
},
]
}
describe('Plans', () => {
beforeEach(() => {
getPlans.mockReset()
getQuota.mockReset()
getQuota.mockResolvedValue(quotaProviders())
})
it('renders plan rows from StatusJson with clamped tracks, overage, pace, and cycle caption', async () => {
it('renders live quota windows, tier, severity, disconnected hint, and manual plans below', async () => {
getPlans.mockResolvedValue(statusWithPlans)
const { container } = render(<Plans period="30days" />)
expect(await screen.findByText('Claude Max')).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()
const claudeFill = container.querySelector('[data-testid="plan-track-claude"] i')
expect(claudeFill).toHaveStyle({ width: '100%' })
expect(claudeFill).toHaveClass('over')
const hotPace = screen.getByText('On pace to exceed; projected $254.00 by Jul 14')
expect(hotPace).toHaveClass('pace', 'hot')
expect(await screen.findByText('Max 20x')).toBeInTheDocument()
expect(screen.getByText('25% used · resets in 2h 29m')).toBeInTheDocument()
expect(screen.getByText('92% used · resets in 3d 14h')).toBeInTheDocument()
expect(container.querySelector('[data-testid="quota-track-5-hour"] i')).toHaveClass('accent')
expect(container.querySelector('[data-testid="quota-track-Weekly"] i')).toHaveClass('bad')
expect(screen.getByText('Connect Codex — log in with the Codex CLI')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Budget plans' })).toBeInTheDocument()
expect(screen.getByText('Cursor Pro')).toBeInTheDocument()
expect(screen.getByText('$20.00 / month · cursor')).toBeInTheDocument()
expect(screen.getByText('$8.20 · 41%')).toBeInTheDocument()
@ -105,13 +123,26 @@ describe('Plans', () => {
expect(cursorFill).toHaveStyle({ width: '41%' })
expect(cursorFill).not.toHaveClass('over')
expect(screen.getByText('On track')).toHaveClass('pace', 'ok')
expect(screen.queryByText('Claude Max')).not.toBeInTheDocument()
expect(screen.queryByText('API usage')).not.toBeInTheDocument()
})
expect(screen.getByText('API usage')).toBeInTheDocument()
expect(screen.getByText('codex · pay as you go, no plan')).toBeInTheDocument()
expect(screen.getByText('$31.02 this cycle')).toBeInTheDocument()
const codexFill = container.querySelector('[data-testid="plan-track-codex"] i')
expect(codexFill).toHaveStyle({ width: '15%' })
expect(codexFill).toHaveClass('mut')
it('keeps manual budget overage and clamped-track behavior', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plans: {
grok: { ...claudePlan, id: 'supergrok', provider: 'grok' },
},
})
const { container } = render(<Plans period="30days" />)
expect(await screen.findByText('SuperGrok')).toBeInTheDocument()
expect(screen.getByText('$230.00 · 115% · $30.00 over')).toBeInTheDocument()
const fill = container.querySelector('[data-testid="plan-track-grok"] i')
expect(fill).toHaveStyle({ width: '100%' })
expect(fill).toHaveClass('over')
expect(screen.getByText('On pace to exceed; projected $254.00 by Jul 14')).toHaveClass('pace', 'hot')
})
it('renders near status as an amber non-exceeding projection when below budget', async () => {
@ -149,10 +180,9 @@ describe('Plans', () => {
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 () => {
it('omits the budget section when StatusJson has no manual plan summaries', async () => {
getPlans.mockResolvedValue({
currency: 'USD',
today: { cost: 0, savings: 0, calls: 0 },
@ -161,8 +191,8 @@ describe('Plans', () => {
render(<Plans period="month" />)
expect(await screen.findByText('No plans configured')).toBeInTheDocument()
expect(screen.getByText('Add a plan in the CLI settings to see budget pacing here.')).toBeInTheDocument()
expect(await screen.findByText('Connect Codex — log in with the Codex CLI')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Budget plans' })).not.toBeInTheDocument()
})
it('renders the CLI locate state when getPlans reports not-found', async () => {

View file

@ -4,11 +4,10 @@ import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { JsonPlanSummary, Period, PlanId, PlanProvider, StatusJson } from '../lib/types'
import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types'
import type { SettingsPane } from './Settings'
const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
const MS_PER_DAY = 24 * 60 * 60 * 1000
const PLAN_NAMES: Record<PlanId, string> = {
'claude-pro': 'Claude Pro',
@ -25,12 +24,6 @@ function fmtPct(n: number): string {
return Number.isInteger(n) ? `${n}%` : `${n.toFixed(1)}%`
}
function parseIsoDay(iso: string): number | null {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
}
function cycleEndDate(plan: JsonPlanSummary): Date | null {
const date = new Date(plan.periodEnd)
if (Number.isNaN(date.getTime())) return null
@ -47,25 +40,6 @@ function formatShortDate(value: string | Date): string {
}).format(date)
}
function cycleLabels(plan: JsonPlanSummary | undefined): { caption: string; pop: string } | null {
if (!plan) return null
const startDay = parseIsoDay(plan.periodStart)
const endDay = parseIsoDay(plan.periodEnd)
const start = formatShortDate(plan.periodStart)
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 }
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}`,
pop,
}
}
function planSummaries(status: StatusJson): JsonPlanSummary[] {
const plans = status.plans
if (plans) {
@ -78,48 +52,149 @@ function planSummaries(status: StatusJson): JsonPlanSummary[] {
return status.plan ? [status.plan] : []
}
function manualPlanSummaries(status: StatusJson): JsonPlanSummary[] {
return planSummaries(status).filter(plan => plan.provider !== 'claude' && plan.provider !== 'codex')
}
export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period; refreshToken?: number; onNavigate?: (section: Section, pane?: SettingsPane) => void }) {
const report = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken])
const plans = report.data ? planSummaries(report.data) : []
const cycle = cycleLabels(plans[0])
const quota = usePolled<QuotaProvider[]>(() => codeburn.getQuota(), [refreshToken])
const budgetReport = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken])
const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : []
return (
<>
<div className="bar">
<div className="t">Plans</div>
{cycle ? <span className="scope">{cycle.caption}</span> : <span className="scope">Cycle unavailable</span>}
<div className="sp" />
<span className="scope">{cycle ? cycle.pop : 'Cycle unavailable'}</span>
<button type="button" className="btn btn-s" onClick={() => onNavigate?.('settings', 'plans')}>
Add plan
</button>
</div>
<div className="body">{renderBody(report.data, report.error, plans)}</div>
<div className="body">
{renderQuota(quota.data, quota.error)}
{renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)}
</div>
</>
)
}
function renderBody(data: StatusJson | null, error: ReturnType<typeof usePolled<StatusJson>>['error'], plans: JsonPlanSummary[]) {
function renderQuota(data: QuotaProvider[] | null, error: ReturnType<typeof usePolled<QuotaProvider[]>>['error']) {
if (!data) {
if (error) return <CliErrorPanel error={error} subject="plan pacing" />
if (error) {
return (
<Panel title="Live quota">
<p className="quota-connection-note quota-terminal">Live quota is unavailable.</p>
</Panel>
)
}
return (
<Panel title="Plans">
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>Scanning plan usage</p>
<Panel title="Live quota">
<p className="quota-connection-note">Loading quota</p>
</Panel>
)
}
if (plans.length === 0) {
if (data.length === 0) {
return (
<Panel title="No plans configured">
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>
Add a plan in the CLI settings to see budget pacing here.
</p>
<Panel title="Live quota">
<p className="quota-connection-note">No quota providers available.</p>
</Panel>
)
}
return plans.map(plan => <PlanPanel key={`${plan.provider}-${plan.id}`} plan={plan} />)
return data.map(provider => <QuotaPanel key={provider.provider} quota={provider} />)
}
function renderBudgetPlans(data: StatusJson | null, error: ReturnType<typeof usePolled<StatusJson>>['error'], plans: JsonPlanSummary[]) {
if (!data && error) {
return (
<section className="budget-plans">
<h2 className="plans-section-heading">Budget plans</h2>
<CliErrorPanel error={error} subject="plan pacing" />
</section>
)
}
if (plans.length === 0) return null
return (
<section className="budget-plans">
<h2 className="plans-section-heading">Budget plans</h2>
{plans.map(plan => <PlanPanel key={`${plan.provider}-${plan.id}`} plan={plan} />)}
</section>
)
}
function QuotaPanel({ quota }: { quota: QuotaProvider }) {
const providerName = quota.provider === 'claude' ? 'Claude' : 'Codex'
return (
<Panel
className="quota-card"
title={<span className="quota-title">{providerName}{quota.planLabel ? <small>{quota.planLabel}</small> : null}</span>}
right={<ConnectionIndicator connection={quota.connection} />}
>
<QuotaContent quota={quota} providerName={providerName} />
</Panel>
)
}
function ConnectionIndicator({ connection }: { connection: QuotaProvider['connection'] }) {
const label = connection === 'transientFailure' ? 'waiting' : connection === 'terminalFailure' ? 'error' : connection
return <span className={`quota-connection quota-connection-${connection}`}><i />{label}</span>
}
function QuotaContent({ quota, providerName }: { quota: QuotaProvider; providerName: string }) {
if (quota.connection === 'disconnected') {
return <p className="quota-connection-note">Connect {providerName} log in with the {providerName} CLI</p>
}
if (quota.connection === 'loading') return <p className="quota-connection-note">Loading quota</p>
if (quota.connection === 'stale' || quota.connection === 'transientFailure') {
return <p className="quota-connection-note">waiting on the CLI</p>
}
if (quota.connection === 'terminalFailure') {
return <p className="quota-connection-note quota-terminal">Quota is currently unavailable.</p>
}
return (
<>
<div className="quota-windows">
{quota.details.map((window, index) => <QuotaMeter key={`${window.label}-${index}`} window={window} />)}
</div>
{quota.footerLines.length > 0 ? (
<div className="quota-footer">{quota.footerLines.map((line, index) => <span key={`${line}-${index}`}>{line}</span>)}</div>
) : null}
</>
)
}
function QuotaMeter({ window }: { window: QuotaWindow }) {
const percent = Math.round(window.percent * 100)
const severity = window.percent >= 0.9 ? 'bad' : window.percent >= 0.7 ? 'warn' : 'accent'
const reset = formatResetTime(window.resetsAt)
return (
<div className="quota-window">
<div className="quota-window-labels">
<span>{window.label}</span>
<span>{percent}% used{reset ? ` · resets ${reset}` : ''}</span>
</div>
<div className="track" data-testid={`quota-track-${window.label}`}>
<i className={severity} style={{ width: `${Math.min(100, Math.max(0, percent))}%` }} />
</div>
</div>
)
}
function formatResetTime(resetsAt: string | null): string | null {
if (!resetsAt) return null
const reset = Date.parse(resetsAt)
if (!Number.isFinite(reset)) return null
const remainingMinutes = Math.floor((reset - Date.now()) / 60_000)
if (remainingMinutes <= 0) return 'now'
const days = Math.floor(remainingMinutes / (24 * 60))
const hours = Math.floor((remainingMinutes % (24 * 60)) / 60)
const minutes = remainingMinutes % 60
if (days > 0) return `in ${days}d${hours > 0 ? ` ${hours}h` : ''}`
if (hours > 0) return `in ${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`
return `in ${minutes}m`
}
function PlanPanel({ plan }: { plan: JsonPlanSummary }) {

View file

@ -408,6 +408,28 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.track i, .tglon { background: var(--accent); box-shadow: none; }
.track i.over { background: var(--bad); }
.track i.mut { background: var(--bar); }
.track i.accent { background: var(--accent); }
.track i.warn { background: var(--warn); }
.track i.bad { background: var(--bad); }
/* Plans: live subscription quota, followed by manual dollar budgets. */
.quota-title { display: inline-flex; align-items: baseline; gap: 8px; }
.quota-title small { color: var(--mut); font-size: 10.5px; font-weight: 520; }
.quota-connection { display: inline-flex; align-items: center; gap: 5px; color: var(--mut); font-size: 10px; font-weight: 520; }
.quota-connection i { width: 6px; height: 6px; border-radius: 50%; background: var(--mut2); }
.quota-connection-connected i { background: var(--ok); }
.quota-connection-stale i, .quota-connection-transientFailure i { background: var(--warn); }
.quota-connection-terminalFailure i { background: var(--bad); }
.quota-connection-note { margin: 0; color: var(--mut); font-size: 12px; }
.quota-connection-note.quota-terminal { color: var(--bad); }
.quota-windows { display: grid; gap: 15px; }
.quota-window-labels { display: flex; align-items: baseline; gap: 12px; color: var(--ink); font-size: 12px; }
.quota-window-labels span:last-child { margin-left: auto; color: var(--mut); font-family: var(--mono); font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; }
.quota-window .track { margin-top: 7px; }
.quota-footer { display: flex; flex-direction: column; gap: 3px; margin-top: 13px; padding-top: 10px; border-top: 1px solid var(--line2); color: var(--mut); font-size: 10.5px; }
.plans-section-heading { margin: 2px 0 8px; color: var(--mut); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.budget-plans { display: grid; width: 100%; gap: 12px; }
.budget-plans > .panel { width: 100%; }
.ov-dashboard { display: grid; width: 100%; gap: 12px; }
.ov-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--card-shadow); }