mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
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:
parent
3ebeb38d67
commit
d0ce20e49c
13 changed files with 480 additions and 73 deletions
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
121
app/renderer/components/RangeCalendar.tsx
Normal file
121
app/renderer/components/RangeCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue