From a216b841ae474f87d690669fe60dc9ec731c5d39 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 12 Jul 2026 16:48:58 -0700 Subject: [PATCH] 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. --- app/renderer/App.test.tsx | 7 +- app/renderer/sections/Plans.test.tsx | 92 +++++++++------ app/renderer/sections/Plans.tsx | 161 ++++++++++++++++++++------- app/renderer/styles/plain.css | 22 ++++ 4 files changed, 207 insertions(+), 75 deletions(-) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 78de7ac..bd1a5db 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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) diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index 48e2429..3a5933f 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -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>(), + getQuota: vi.fn<() => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() - 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() - 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() + + 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() 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() - 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 () => { diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index 774ac06..efa8ba0 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -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 = { '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(() => codeburn.getPlans(period), [period, refreshToken]) - const plans = report.data ? planSummaries(report.data) : [] - const cycle = cycleLabels(plans[0]) + const quota = usePolled(() => codeburn.getQuota(), [refreshToken]) + const budgetReport = usePolled(() => codeburn.getPlans(period), [period, refreshToken]) + const manualPlans = budgetReport.data ? manualPlanSummaries(budgetReport.data) : [] return ( <>
Plans
- {cycle ? {cycle.caption} : Cycle unavailable}
- {cycle ? cycle.pop : 'Cycle unavailable'}
-
{renderBody(report.data, report.error, plans)}
+
+ {renderQuota(quota.data, quota.error)} + {renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)} +
) } -function renderBody(data: StatusJson | null, error: ReturnType>['error'], plans: JsonPlanSummary[]) { +function renderQuota(data: QuotaProvider[] | null, error: ReturnType>['error']) { if (!data) { - if (error) return + if (error) { + return ( + +

Live quota is unavailable.

+
+ ) + } return ( - -

Scanning plan usage…

+ +

Loading quota…

) } - if (plans.length === 0) { + if (data.length === 0) { return ( - -

- Add a plan in the CLI settings to see budget pacing here. -

+ +

No quota providers available.

) } - return plans.map(plan => ) + return data.map(provider => ) +} + +function renderBudgetPlans(data: StatusJson | null, error: ReturnType>['error'], plans: JsonPlanSummary[]) { + if (!data && error) { + return ( +
+

Budget plans

+ +
+ ) + } + if (plans.length === 0) return null + + return ( +
+

Budget plans

+ {plans.map(plan => )} +
+ ) +} + +function QuotaPanel({ quota }: { quota: QuotaProvider }) { + const providerName = quota.provider === 'claude' ? 'Claude' : 'Codex' + return ( + {providerName}{quota.planLabel ? {quota.planLabel} : null}} + right={} + > + + + ) +} + +function ConnectionIndicator({ connection }: { connection: QuotaProvider['connection'] }) { + const label = connection === 'transientFailure' ? 'waiting' : connection === 'terminalFailure' ? 'error' : connection + return {label} +} + +function QuotaContent({ quota, providerName }: { quota: QuotaProvider; providerName: string }) { + if (quota.connection === 'disconnected') { + return

Connect {providerName} — log in with the {providerName} CLI

+ } + if (quota.connection === 'loading') return

Loading quota…

+ if (quota.connection === 'stale' || quota.connection === 'transientFailure') { + return

waiting on the CLI…

+ } + if (quota.connection === 'terminalFailure') { + return

Quota is currently unavailable.

+ } + + return ( + <> +
+ {quota.details.map((window, index) => )} +
+ {quota.footerLines.length > 0 ? ( +
{quota.footerLines.map((line, index) => {line})}
+ ) : 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 ( +
+
+ {window.label} + {percent}% used{reset ? ` · resets ${reset}` : ''} +
+
+ +
+
+ ) +} + +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 }) { diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 8889034..43c74c8 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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); }