diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index ea14e868..4f77e2d6 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -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'] }, diff --git a/app/electron/main.ts b/app/electron/main.ts index 34e62b20..5c4b1cbf 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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']), diff --git a/app/electron/preload.ts b/app/electron/preload.ts index b02d5cb6..ed210a53 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -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(channel: string, ...args: unknown[]): Promise { const res = (await ipcRenderer.invoke(channel, ...args)) as Envelope if (res.ok) return res.value @@ -16,12 +18,12 @@ async function invoke(channel: string, ...args: unknown[]): Promise { // 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'), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index ca6bf2a2..52a815ca 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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>(), - getSpendFlow: vi.fn<(period: string, provider: string) => Promise>(), + getOverview: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), 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() + + 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') + }) }) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 5ac17b36..45ac3c77 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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 = { overview: 'Overview', @@ -34,19 +34,20 @@ const PERIOD_LABELS: Record = { } 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
('overview') const [period, setPeriod] = useState('30days') - const [provider, setProvider] = useState<(typeof PROVIDER_OPTIONS)[number]>('all') + const [provider, setProvider] = useState('all') + const [detectedProviders, setDetectedProviders] = useState([]) + const [customRange, setCustomRange] = useState(null) const [refreshToken, setRefreshToken] = useState(0) const [now, setNow] = useState(() => Date.now()) - const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) + const overview = usePolled( + () => 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 ( @@ -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} />
{section === 'overview' ? ( ) : section === 'spend' ? ( - + ) : section === 'optimize' ? ( - + ) : section === 'models' ? ( - + ) : ( )} diff --git a/app/renderer/components/ProviderPop.tsx b/app/renderer/components/ProviderPop.tsx index 789d8f57..7b376ccc 100644 --- a/app/renderer/components/ProviderPop.tsx +++ b/app/renderer/components/ProviderPop.tsx @@ -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(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 ( -
- {label} +
+
setOpen(current => !current)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(current => !current) + } + }} + > + {label} +
+ {open && ( +
+ {options.map(option => ( +
{ + onSelect(option.value) + setOpen(false) + }} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelect(option.value) + setOpen(false) + } + }} + > + {option.label} +
+ ))} +
+ )}
) } diff --git a/app/renderer/components/RangeCalendar.tsx b/app/renderer/components/RangeCalendar.tsx new file mode 100644 index 00000000..2fa21f81 --- /dev/null +++ b/app/renderer/components/RangeCalendar.tsx @@ -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(value) + const dragAnchor = useRef(null) + const clickAnchor = useRef(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 ( +
+
+ + {month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} + +
+
+ {WEEKDAYS.map(day => {day})} + {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 ( + + ) + })} +
+
+ ) +} diff --git a/app/renderer/components/TopBar.tsx b/app/renderer/components/TopBar.tsx index 5a4c9db6..2e6e7f94 100644 --- a/app/renderer/components/TopBar.tsx +++ b/app/renderer/components/TopBar.tsx @@ -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 (
{title}
{scope !== undefined && {scope}}
- - + + + +
+ ) +} + +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(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 ( +
+ + {open && ( +
+ { + onSelect(range) + setOpen(false) + }} + /> +
+ )}
) } diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 341c5cff..c81d6b3e 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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 + getOverview(period: Period, provider: string, range?: DateRange): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string - getModels(period: Period, provider: string, byTask: boolean): Promise - getYield(period: Period): Promise - getSpendFlow(period: Period, provider: string): Promise + getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise + getYield(period: Period, range?: DateRange): Promise + getSpendFlow(period: Period, provider: string, range?: DateRange): Promise getDevices(period: Period): Promise getDevicesScan(): Promise getShareStatus(): Promise diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index 675afa2a..4f75568a 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -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

{children}

} -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('model') const byTask = lens === 'task' const report = usePolled( - () => 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) { diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 6aa825c4..c5a095ce 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -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

{children}

} -export function Optimize({ period, provider }: { period: Period; provider: string }) { - const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) - return +export function Optimize({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { + const overview = usePolled( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + return } export function OptimizeContent({ period, + range = null, overview, refreshToken = 0, }: { period: Period + range?: DateRange | null overview: Polled refreshToken?: number }) { - const yieldReport = usePolled(() => codeburn.getYield(period), [period, refreshToken]) + const yieldReport = usePolled( + () => range ? codeburn.getYield(period, range) : codeburn.getYield(period), + [period, range?.from, range?.to, refreshToken], + ) const [tab, setTab] = useState('waste') if (!overview.data) { diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index dd65f024..cad5fec6 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -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

{children}

} -export function Spend({ period, provider }: { period: Period; provider: string }) { - const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) - return +export function Spend({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { + const overview = usePolled( + () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), + [period, provider, range?.from, range?.to], + ) + return } export function SpendContent({ period, provider, + range = null, overview, refreshToken = 0, }: { period: Period provider: string + range?: DateRange | null overview: Polled refreshToken?: number }) { - const flow = usePolled(() => codeburn.getSpendFlow(period, provider), [period, provider, refreshToken]) + const flow = usePolled( + () => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider), + [period, provider, range?.from, range?.to, refreshToken], + ) const [lens, setLens] = useState('projects') if (!overview.data) { @@ -58,7 +66,7 @@ export function SpendContent({ <> setLens(value as Lens)} style={{ alignSelf: 'flex-start' }} /> {lens === 'projects' ? ( - + ) : ( )} @@ -70,12 +78,16 @@ function ProjectsLens({ data, flow, period, + range, }: { data: MenubarPayload flow: ReturnType> 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 ( diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 158968de..c4cec3c1 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -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; }