feat(app): working periods, detected-provider dropdown, drag-select date range

- Period seg now uses the 5 real CLI periods (Today/7D/30D/Month/6M);
  6M maps to 'all' (matches the menubar).
- Provider control is a real dropdown listing only DETECTED providers
  (accumulated from current.providers so it never shrinks) + All.
- New RangeCalendar: month grid with click-and-drag range selection
  (+ two-click fallback, future-date disabling) behind a calendar button;
  commits a custom {from,to} threaded to Overview/Spend/Models/Optimize
  via new --from/--to CLI args (Plans/Settings stay period-only).
- typecheck clean, 87 vitest pass.
This commit is contained in:
iamtoruk 2026-07-11 09:14:54 -07:00
parent 3ebeb38d67
commit d0ce20e49c
13 changed files with 480 additions and 73 deletions

View file

@ -46,6 +46,10 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getModels', args: ['week', 'all', false], argv: ['models', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getYield', args: ['today'], argv: ['yield', '--format', 'json', '--period', 'today'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getDevices', args: ['week'], argv: ['devices', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getDevicesScan', args: [], argv: ['devices', 'scan', '--format', 'json'] },
{ channel: 'codeburn:getShareStatus', args: [], argv: ['share', 'status', '--format', 'json'] },

View file

@ -11,6 +11,12 @@ function providerArgs(provider: string | undefined): string[] {
return provider && provider !== 'all' ? ['--provider', provider] : []
}
type DateRange = { from: string; to: string }
function rangeArgs(range: DateRange | undefined): string[] {
return range ? ['--from', range.from, '--to', range.to] : []
}
function toEnvelopeError(err: unknown): { kind: string; message: string } {
if (err instanceof CliError) return { kind: err.kind, message: err.message }
return { kind: 'nonzero', message: err instanceof Error ? err.message : String(err) }
@ -38,17 +44,19 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, resolveCodeburnPat
}
return {
'codeburn:getOverview': run((period: string, provider: string) => [
'status', '--format', 'menubar-json', '--period', period, ...providerArgs(provider),
'codeburn:getOverview': run((period: string, provider: string, range?: DateRange) => [
'status', '--format', 'menubar-json', '--period', period, ...providerArgs(provider), ...rangeArgs(range),
]),
'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', period]),
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean) => [
'models', '--format', 'json', '--period', period, ...providerArgs(provider), ...(byTask ? ['--by-task'] : []),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [
'models', '--format', 'json', '--period', period, ...providerArgs(provider), ...(byTask ? ['--by-task'] : []), ...rangeArgs(range),
]),
'codeburn:getYield': run((period: string) => ['yield', '--format', 'json', '--period', period]),
'codeburn:getSpendFlow': run((period: string, provider: string) => [
'spend', '--format', 'flow-json', '--period', period, ...providerArgs(provider),
'codeburn:getYield': run((period: string, range?: DateRange) => [
'yield', '--format', 'json', '--period', period, ...rangeArgs(range),
]),
'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [
'spend', '--format', 'flow-json', '--period', period, ...providerArgs(provider), ...rangeArgs(range),
]),
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', period]),
'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),

View file

@ -5,6 +5,8 @@ import { contextBridge, ipcRenderer } from 'electron'
// so this shares main.ts's declaration without pulling its runtime in.
import type { Envelope } from './main'
type DateRange = { from: string; to: string }
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
const res = (await ipcRenderer.invoke(channel, ...args)) as Envelope<T>
if (res.ok) return res.value
@ -16,12 +18,12 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
// Shape matches CodeburnBridge (app/renderer/lib/types.ts); typing is enforced
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
const bridge = {
getOverview: (period: string, provider: string) => invoke('codeburn:getOverview', period, provider),
getOverview: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOverview', period, provider, range),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean) => invoke('codeburn:getModels', period, provider, byTask),
getYield: (period: string) => invoke('codeburn:getYield', period),
getSpendFlow: (period: string, provider: string) => invoke('codeburn:getSpendFlow', period, provider),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
getYield: (period: string, range?: DateRange) => invoke('codeburn:getYield', period, range),
getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range),
getDevices: (period: string) => invoke('codeburn:getDevices', period),
getDevicesScan: () => invoke('codeburn:getDevicesScan'),
getShareStatus: () => invoke('codeburn:getShareStatus'),

View file

@ -3,11 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import type { MenubarPayload, SpendFlow } from './lib/types'
import type { DateRange, MenubarPayload, SpendFlow } from './lib/types'
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getModels: vi.fn(),
getPlans: vi.fn(),
getActReport: vi.fn(),
@ -46,7 +46,7 @@ function overviewPayload(): MenubarPayload {
topActivities: [],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
providers: { claude: 10, codex: 2 },
topProjects: [],
modelEfficiency: [],
topSessions: [],
@ -169,10 +169,36 @@ describe('App shortcuts', () => {
})
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude')
expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'claude')
})
})
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))
const to = new Date()
const from = new Date(to.getFullYear(), to.getMonth(), to.getDate() - 2)
const fromLabel = from.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const toLabel = to.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const range = { from: dateKey(from), to: dateKey(to) }
fireEvent.mouseDown(screen.getByRole('button', { name: fromLabel }))
fireEvent.mouseEnter(screen.getByRole('button', { name: toLabel }))
fireEvent.mouseUp(screen.getByRole('button', { name: toLabel }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', range)
expect(mocks.getSpendFlow).toHaveBeenCalledWith('30days', 'all', range)
})
expect(screen.getByRole('button', { name: // })).toBeInTheDocument()
expect(screen.getByText('30D')).not.toHaveClass('on')
})
})

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Hint } from './components/Hint'
import { Panel } from './components/Panel'
import { Sidebar, type Section } from './components/Sidebar'
import { TopBar } from './components/TopBar'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { formatUsd } from './lib/format'
@ -14,7 +14,7 @@ import { Models } from './sections/Models'
import { Plans } from './sections/Plans'
import { Settings } from './sections/Settings'
import { SpendContent } from './sections/Spend'
import type { MenubarPayload, Period } from './lib/types'
import type { DateRange, MenubarPayload, Period } from './lib/types'
const SECTION_TITLES: Record<Section, string> = {
overview: 'Overview',
@ -34,19 +34,20 @@ const PERIOD_LABELS: Record<Period, string> = {
}
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
const PROVIDER_OPTIONS = ['all', 'claude', 'codex', 'cursor', 'grok'] as const
const PROVIDER_LABELS: Record<(typeof PROVIDER_OPTIONS)[number], string> = {
all: 'All providers',
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
grok: 'Grok',
}
function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
}
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
.split(/[-\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: number): string {
if (loading && lastSuccessAt === null) return 'refreshing…'
if (lastSuccessAt === null) return 'not refreshed yet'
@ -60,13 +61,30 @@ function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: num
export function App() {
const [section, setSection] = useState<Section>('overview')
const [period, setPeriod] = useState<Period>('30days')
const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>('all')
const [provider, setProvider] = useState<string>('all')
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
const overview = usePolled<MenubarPayload>(
() => customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to],
)
const refreshOverview = overview.refresh
useEffect(() => {
if (!overview.data) return
const found = Object.keys(overview.data.current.providers)
setDetectedProviders(current => {
const next = [...current]
for (const item of found) if (!next.includes(item)) next.push(item)
return next.length === current.length ? current : next
})
}, [overview.data])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
@ -97,17 +115,18 @@ export function App() {
}, [refreshVisible])
const onPeriodChange = (value: string) => {
// 6M / Custom (date ranges) are M2; ignore for now so the
// highlight never lies about what was fetched.
if (isPeriod(value)) setPeriod(value)
if (isPeriod(value)) {
setCustomRange(null)
setPeriod(value)
}
}
const onProviderClick = () => {
setProvider(current => PROVIDER_OPTIONS[(PROVIDER_OPTIONS.indexOf(current) + 1) % PROVIDER_OPTIONS.length])
}
const providerLabel = PROVIDER_LABELS[provider]
const scope = `${PERIOD_LABELS[period]} · ${providerLabel}`
const providerOptions = [
{ value: 'all', label: 'All providers' },
...detectedProviders.map(value => ({ value, label: providerName(value) })),
]
const providerLabel = providerName(provider)
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}`
return (
<Window>
@ -124,18 +143,22 @@ export function App() {
scope={scope}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
onRangeSelect={setCustomRange}
provider={provider}
providerLabel={providerLabel}
onProviderClick={onProviderClick}
providerOptions={providerOptions}
onProviderSelect={setProvider}
/>
<div className="body">
{section === 'overview' ? (
<OverviewContent period={period} overview={overview} onNavigate={setSection} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} overview={overview} refreshToken={refreshToken} />
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
) : section === 'optimize' ? (
<OptimizeContent period={period} overview={overview} refreshToken={refreshToken} />
<OptimizeContent period={period} range={customRange} overview={overview} refreshToken={refreshToken} />
) : section === 'models' ? (
<Models period={period} provider={provider} refreshToken={refreshToken} />
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}

View file

@ -1,8 +1,82 @@
/** The `.pop` provider selector. Mutation (opening the menu) lands in T8. */
export function ProviderPop({ label, onClick }: { label: string; onClick?: () => void }) {
import { useEffect, useRef, useState } from 'react'
export type ProviderOption = { value: string; label: string }
/** Provider selector populated from providers detected in the CLI payload. */
export function ProviderPop({
value,
label,
options,
onSelect,
}: {
value: string
label: string
options: ProviderOption[]
onSelect: (value: string) => void
}) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
return (
<div className="pop" role="button" tabIndex={0} onClick={onClick}>
{label}
<div className="pop-wrap" ref={wrapRef}>
<div
className="pop"
role="button"
tabIndex={0}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOpen(current => !current)
}
}}
>
{label}
</div>
{open && (
<div className="pop-menu" role="listbox" aria-label="Providers">
{options.map(option => (
<div
key={option.value}
className={`pop-item${option.value === value ? ' on' : ''}`}
role="option"
tabIndex={0}
aria-selected={option.value === value}
onClick={() => {
onSelect(option.value)
setOpen(false)
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect(option.value)
setOpen(false)
}
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { DateRange } from '../lib/types'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function normalize(from: string, to: string): DateRange {
return from <= to ? { from, to } : { from: to, to: from }
}
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
}
export function RangeCalendar({ value, onSelect }: { value: DateRange | null; onSelect: (range: DateRange) => void }) {
const today = useMemo(() => new Date(), [])
const todayKey = dateKey(today)
const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1))
const [preview, setPreview] = useState<DateRange | null>(value)
const dragAnchor = useRef<string | null>(null)
const clickAnchor = useRef<string | null>(null)
useEffect(() => setPreview(value), [value])
useEffect(() => {
const finishDrag = () => {
dragAnchor.current = null
}
document.addEventListener('mouseup', finishDrag)
return () => document.removeEventListener('mouseup', finishDrag)
}, [])
const first = new Date(month.getFullYear(), month.getMonth(), 1)
const gridStart = addDays(first, -first.getDay())
const days = Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
const shown = preview ?? value
const commit = (from: string, to: string) => {
const range = normalize(from, to)
clickAnchor.current = null
dragAnchor.current = null
setPreview(range)
onSelect(range)
}
return (
<div className="range-calendar" aria-label="Date range calendar">
<div className="calendar-head">
<button
type="button"
className="calendar-nav"
aria-label="Previous month"
onClick={() => setMonth(current => new Date(current.getFullYear(), current.getMonth() - 1, 1))}
>
</button>
<strong>{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</strong>
<button
type="button"
className="calendar-nav"
aria-label="Next month"
disabled={month.getFullYear() === today.getFullYear() && month.getMonth() === today.getMonth()}
onClick={() => setMonth(current => new Date(current.getFullYear(), current.getMonth() + 1, 1))}
>
</button>
</div>
<div className="calendar-grid">
{WEEKDAYS.map(day => <span className="calendar-weekday" key={day}>{day}</span>)}
{days.map(day => {
const key = dateKey(day)
const outside = day.getMonth() !== month.getMonth()
const disabled = key > todayKey
const endpoint = shown ? key === shown.from || key === shown.to : false
const inRange = shown ? key >= shown.from && key <= shown.to : false
const className = [
'calendar-day',
outside ? 'outside' : '',
inRange ? 'in-range' : '',
endpoint ? 'endpoint' : '',
].filter(Boolean).join(' ')
return (
<button
type="button"
key={key}
className={className}
disabled={disabled}
aria-label={day.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
onMouseDown={event => {
event.preventDefault()
dragAnchor.current = key
if (!clickAnchor.current) setPreview({ from: key, to: key })
}}
onMouseEnter={() => {
if (dragAnchor.current) setPreview(normalize(dragAnchor.current, key))
}}
onMouseUp={() => {
const start = dragAnchor.current
if (start && start !== key) {
commit(start, key)
} else if (clickAnchor.current) {
commit(clickAnchor.current, key)
} else {
clickAnchor.current = key
dragAnchor.current = null
setPreview({ from: key, to: key })
}
}}
>
{day.getDate()}
</button>
)
})}
</div>
</div>
)
}

View file

@ -1,16 +1,17 @@
import type { ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { ProviderPop } from './ProviderPop'
import type { DateRange } from '../lib/types'
import { ProviderPop, type ProviderOption } from './ProviderPop'
import { RangeCalendar } from './RangeCalendar'
import { SegTabs, type SegOption } from './SegTabs'
/** Full period vocabulary per the wireframe. 6M/Custom are M2 no-ops for now. */
/** The real CLI period vocabulary (`codeburn ... --period`). */
export const PERIOD_OPTIONS: SegOption[] = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: '7D' },
{ value: '30days', label: '30D' },
{ value: 'month', label: 'Month' },
{ value: '6m', label: '6M' },
{ value: 'custom', label: 'Custom' },
{ value: 'all', label: '6M' },
]
/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */
@ -19,23 +20,98 @@ export function TopBar({
scope,
period,
onPeriodChange,
customRange,
onRangeSelect,
provider,
providerLabel,
onProviderClick,
providerOptions,
onProviderSelect,
}: {
title: ReactNode
scope?: ReactNode
period: string
onPeriodChange: (value: string) => void
customRange: DateRange | null
onRangeSelect: (range: DateRange) => void
provider: string
providerLabel: string
onProviderClick?: () => void
providerOptions: ProviderOption[]
onProviderSelect: (value: string) => void
}) {
return (
<div className="bar">
<div className="t">{title}</div>
{scope !== undefined && <span className="scope">{scope}</span>}
<div className="sp" />
<SegTabs options={PERIOD_OPTIONS} value={period} onChange={onPeriodChange} />
<ProviderPop label={providerLabel} onClick={onProviderClick} />
<SegTabs options={PERIOD_OPTIONS} value={customRange ? '' : period} onChange={onPeriodChange} />
<CalendarPop value={customRange} onSelect={onRangeSelect} />
<ProviderPop value={provider} label={providerLabel} options={providerOptions} onSelect={onProviderSelect} />
</div>
)
}
function formatRange(range: DateRange): string {
const from = new Date(`${range.from}T12:00:00`)
const to = new Date(`${range.to}T12:00:00`)
const sameYear = from.getFullYear() === to.getFullYear()
const sameMonth = sameYear && from.getMonth() === to.getMonth()
const left = from.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: sameYear ? undefined : 'numeric' })
const right = to.toLocaleDateString('en-US', { month: sameMonth ? undefined : 'short', day: 'numeric', year: sameYear ? undefined : 'numeric' })
return `${left} ${right}`
}
export function rangeLabel(range: DateRange): string {
return formatRange(range)
}
function CalendarPop({ value, onSelect }: { value: DateRange | null; onSelect: (range: DateRange) => void }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
const label = value ? formatRange(value) : 'Choose date range'
return (
<div className="calendar-wrap" ref={wrapRef}>
<button
type="button"
className={`calendar-trigger${value ? ' on' : ''}`}
aria-label={label}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(current => !current)}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<rect x="2.25" y="3.25" width="11.5" height="10.5" rx="1.5" />
<path d="M5 1.75v3M11 1.75v3M2.5 6.25h11" />
</svg>
{value && <span>{label}</span>}
</button>
{open && (
<div className="calendar-popover" role="dialog" aria-label="Choose date range">
<RangeCalendar
value={value}
onSelect={range => {
onSelect(range)
setOpen(false)
}}
/>
</div>
)}
</div>
)
}

View file

@ -6,6 +6,8 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export type DateRange = { from: string; to: string }
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout'
/** Structured failure surfaced across the IPC boundary as plain data. */
@ -360,13 +362,13 @@ export type ActReportJson = {
// ————— IPC surface (preload contextBridge → window.codeburn) —————
export interface CodeburnBridge {
getOverview(period: Period, provider: string): Promise<MenubarPayload>
getOverview(period: Period, provider: string, range?: DateRange): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string
getModels(period: Period, provider: string, byTask: boolean): Promise<ModelReportRow[]>
getYield(period: Period): Promise<YieldJsonReport>
getSpendFlow(period: Period, provider: string): Promise<SpendFlow>
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise<ModelReportRow[]>
getYield(period: Period, range?: DateRange): Promise<YieldJsonReport>
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
getDevices(period: Period): Promise<CombinedUsage>
getDevicesScan(): Promise<DeviceScanResult>
getShareStatus(): Promise<ShareStatus>

View file

@ -7,7 +7,7 @@ import { SegTabs } from '../components/SegTabs'
import { usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { ModelReportRow, Period } from '../lib/types'
import type { DateRange, ModelReportRow, Period } from '../lib/types'
type ModelsLens = 'model' | 'task'
@ -33,12 +33,22 @@ function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Models({ period, provider, refreshToken = 0 }: { period: Period; provider: string; refreshToken?: number }) {
export function Models({
period,
provider,
range = null,
refreshToken = 0,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
}) {
const [lens, setLens] = useState<ModelsLens>('model')
const byTask = lens === 'task'
const report = usePolled<ModelReportRow[]>(
() => codeburn.getModels(period, provider, byTask),
[period, provider, byTask, refreshToken],
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
[period, provider, byTask, range?.from, range?.to, refreshToken],
)
if (!report.data) {

View file

@ -6,7 +6,7 @@ import { SegTabs } from '../components/SegTabs'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { MenubarPayload, Period, SessionYieldJson, YieldJsonReport } from '../lib/types'
import type { DateRange, MenubarPayload, Period, SessionYieldJson, YieldJsonReport } from '../lib/types'
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
@ -14,21 +14,29 @@ function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Optimize({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
return <OptimizeContent period={period} overview={overview} />
export function Optimize({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) {
const overview = usePolled<MenubarPayload>(
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
[period, provider, range?.from, range?.to],
)
return <OptimizeContent period={period} range={range} overview={overview} />
}
export function OptimizeContent({
period,
range = null,
overview,
refreshToken = 0,
}: {
period: Period
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
}) {
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period), [period, refreshToken])
const yieldReport = usePolled<YieldJsonReport>(
() => range ? codeburn.getYield(period, range) : codeburn.getYield(period),
[period, range?.from, range?.to, refreshToken],
)
const [tab, setTab] = useState<OptimizeTab>('waste')
if (!overview.data) {

View file

@ -10,7 +10,7 @@ import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { sliceDailyToPeriod } from '../lib/period'
import type { MenubarPayload, Period, SpendFlow } from '../lib/types'
import type { DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
type Lens = 'projects' | 'activity' | 'tools' | 'mcp' | 'subagents'
@ -26,23 +26,31 @@ function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Spend({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
return <SpendContent period={period} provider={provider} overview={overview} />
export function Spend({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) {
const overview = usePolled<MenubarPayload>(
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
[period, provider, range?.from, range?.to],
)
return <SpendContent period={period} provider={provider} range={range} overview={overview} />
}
export function SpendContent({
period,
provider,
range = null,
overview,
refreshToken = 0,
}: {
period: Period
provider: string
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
}) {
const flow = usePolled<SpendFlow>(() => codeburn.getSpendFlow(period, provider), [period, provider, refreshToken])
const flow = usePolled<SpendFlow>(
() => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
)
const [lens, setLens] = useState<Lens>('projects')
if (!overview.data) {
@ -58,7 +66,7 @@ export function SpendContent({
<>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as Lens)} style={{ alignSelf: 'flex-start' }} />
{lens === 'projects' ? (
<ProjectsLens data={overview.data} flow={flow} period={period} />
<ProjectsLens data={overview.data} flow={flow} period={period} range={range} />
) : (
<DetailLens data={overview.data} lens={lens} />
)}
@ -70,12 +78,16 @@ function ProjectsLens({
data,
flow,
period,
range,
}: {
data: MenubarPayload
flow: ReturnType<typeof usePolled<SpendFlow>>
period: Period
range: DateRange | null
}) {
const daily = sliceDailyToPeriod(data.history.daily, period)
const daily = range
? data.history.daily.filter(day => day.date >= range.from && day.date <= range.to)
: sliceDailyToPeriod(data.history.daily, period)
const projects = data.current.topProjects
return (

View file

@ -134,6 +134,47 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ni.on::before { background: var(--accent); }
.seg { background: var(--bg); border: 1px solid var(--hair); }
.seg .on { background: var(--panel); box-shadow: inset 0 -2px 0 var(--accent); }
.pop-wrap, .calendar-wrap { position: relative; }
.pop { background: var(--panel); color: var(--t1); cursor: pointer; user-select: none; }
.pop-menu {
position: absolute; top: calc(100% + 6px); right: 0; min-width: 150px; padding: 4px;
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.14); z-index: 20;
}
.pop-item { padding: 6px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; color: var(--t1); white-space: nowrap; }
.pop-item:hover, .pop-item:focus-visible { background: var(--hover); outline: none; }
.pop-item.on { color: var(--accent); font-weight: 560; }
.calendar-trigger {
display: inline-flex; align-items: center; gap: 6px; height: 25px; padding: 3px 7px;
border: 1px solid var(--hair2); border-radius: 7px; background: var(--panel);
color: var(--t2); font: inherit; font-size: 11px; white-space: nowrap; cursor: pointer;
}
.calendar-trigger:hover, .calendar-trigger.on { color: var(--accent); }
.calendar-trigger svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.25; }
.calendar-popover {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; width: 286px;
padding: 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.14);
}
.calendar-head { display: grid; grid-template-columns: 28px 1fr 28px; align-items: center; margin-bottom: 7px; }
.calendar-head strong { text-align: center; font-size: 12px; font-weight: 600; }
.calendar-nav {
width: 26px; height: 26px; padding: 0; border: 1px solid transparent; border-radius: 6px;
color: var(--t2); background: transparent; font-size: 18px; line-height: 1; cursor: pointer;
}
.calendar-nav:hover:not(:disabled) { border-color: var(--line); background: var(--hover); color: var(--t1); }
.calendar-nav:disabled { color: var(--mut2); cursor: default; opacity: .45; }
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; user-select: none; }
.calendar-weekday { padding: 3px 0 5px; color: var(--t3); text-align: center; font-size: 9px; text-transform: uppercase; }
.calendar-day {
position: relative; height: 31px; padding: 0; border: 0; border-radius: 6px;
background: transparent; color: var(--t1); font: inherit; font-size: 11px; cursor: pointer;
}
.calendar-day:hover:not(:disabled) { background: var(--hover); }
.calendar-day.outside { color: var(--t3); }
.calendar-day.in-range { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); color: var(--t1); border-radius: 2px; }
.calendar-day.endpoint { background: var(--accent); color: #fff; border-radius: 6px; font-weight: 600; }
.calendar-day:disabled { color: var(--mut2); opacity: .38; cursor: default; }
.btn-p { background: var(--accent); box-shadow: none; }
.btn-s { background: var(--panel); }
.panel { border-color: var(--line); box-shadow: none; }