From 73fb86dec6d2b4c54fed5e350058640f01e1e585 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 10 Jul 2026 19:12:42 -0700 Subject: [PATCH] =?UTF-8?q?fix(app):=20Plans=20=E2=80=94=20inclusive=20loc?= =?UTF-8?q?al=20cycle=20end=20(periodEnd-1d),=20correct=20day=20count,=20h?= =?UTF-8?q?onest=20near=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app/renderer/sections/Plans.test.tsx | 138 +++++++++++++++++++-------- app/renderer/sections/Plans.tsx | 40 +++++--- 2 files changed, 125 insertions(+), 53 deletions(-) diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index 1ac4328..a875abb 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -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 + +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() 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() + + 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() + + 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', diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index 2c88c35..227f29d 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -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 = { @@ -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 (
- On pace to exceed - projected {fmtUsd(plan.projectedMonthEnd)} by {formatShortDate(plan.periodEnd)} + On pace to exceed — projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel} +
+ ) + } + if (plan.status === 'near') { + return ( +
+ {fmtPct(plan.percentUsed)} of budget used — projected {fmtUsd(plan.projectedMonthEnd)} by {endLabel}
) }