diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx new file mode 100644 index 0000000..84f3256 --- /dev/null +++ b/app/renderer/App.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' + +import { Hint } from './components/Hint' +import { Panel } from './components/Panel' +import { Sidebar, type Section } from './components/Sidebar' +import { Stat } from './components/Stat' +import { TopBar } from './components/TopBar' +import { Window } from './components/Window' +import { usePolled } from './hooks/usePolled' +import { codeburn } from './lib/ipc' +import type { MenubarPayload, Period } from './lib/types' + +const SECTION_TITLES: Record = { + overview: 'Overview', + spend: 'Spend', + optimize: 'Optimize', + models: 'Models', + plans: 'Plans', + settings: 'Settings', +} + +const PERIOD_LABELS: Record = { + today: 'Today', + week: 'Last 7 days', + month: 'This month', + '30days': 'Last 30 days', + all: 'All time', +} + +const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] + +function isPeriod(value: string): value is Period { + return (STANDARD_PERIODS as string[]).includes(value) +} + +function fmtUsd(n: number): string { + return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +export function App() { + const [section, setSection] = useState
('overview') + const [period, setPeriod] = useState('30days') + const provider = 'all' + + // Polled at the app level: proves the spawn→IPC→render path and feeds the + // sidebar status line. Section-specific fetching lands in T2–T7. + const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) + + const onPeriodChange = (value: string) => { + // 6M / Custom (date ranges) are not wired until T8; ignore for now so the + // highlight never lies about what was fetched. + if (isPeriod(value)) setPeriod(value) + } + + const scope = `${PERIOD_LABELS[period]} · All providers` + + return ( + + } /> +
+ +
+ {section === 'overview' ? ( + + ) : ( + + )} +
+ +
+
+ ) +} + +function StatusLine({ polled }: { polled: ReturnType> }) { + if (polled.data) { + return ( + <> + {polled.data.current.label} {fmtUsd(polled.data.current.cost)} + + ) + } + if (polled.error?.kind === 'not-found') return <>CLI not found + if (polled.loading) return <>scanning… + return <>— +} + +/** + * Task 0 smoke view: renders the raw `current.cost` from getOverview to prove + * the end-to-end path. The real Overview (stat cards, capsule chart, sessions) + * is Task 2. CLI-missing shows an honest first-run state, never a crash. + */ +function OverviewPlaceholder({ polled }: { polled: ReturnType> }) { + if (polled.error?.kind === 'not-found') { + return ( + +

+ CodeBurn Desktop reads your usage by running the codeburn command, + but it isn't on your PATH yet. +

+

+ Install it with npm i -g codeburn, then reopen this window. +

+
+ ) + } + + if (polled.error) { + return ( + +

{polled.error.message}

+
+ ) + } + + if (!polled.data) { + return ( + +

Scanning sessions…

+
+ ) + } + + const current = polled.data.current + return ( +
+ + +
+ ) +} + +function SectionPlaceholder({ title }: { title: string }) { + return ( + +

+ {title} lands in a later task. The shell, data bridge, and design system are in place. +

+
+ ) +} diff --git a/app/renderer/components/Hint.tsx b/app/renderer/components/Hint.tsx new file mode 100644 index 0000000..28bc049 --- /dev/null +++ b/app/renderer/components/Hint.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react' + +export type HintItem = { k?: string; label: ReactNode } + +/** The `.hint` footer strip: keycap hints on the left, optional right-aligned note. */ +export function Hint({ items, right }: { items: HintItem[]; right?: ReactNode }) { + return ( +
+ {items.map((item, i) => ( + + {item.k && {item.k}} + {item.label} + + ))} + {right !== undefined && {right}} +
+ ) +} diff --git a/app/renderer/components/Panel.tsx b/app/renderer/components/Panel.tsx new file mode 100644 index 0000000..f000d8c --- /dev/null +++ b/app/renderer/components/Panel.tsx @@ -0,0 +1,30 @@ +import type { CSSProperties, ReactNode } from 'react' + +/** Dash0-style card: optional `.phead` title strip + `.pbody` content. */ +export function Panel({ + title, + right, + className, + bodyStyle, + children, +}: { + title?: ReactNode + right?: ReactNode + className?: string + bodyStyle?: CSSProperties + children?: ReactNode +}) { + return ( +
+ {title !== undefined && ( +
+ {title} + {right !== undefined && {right}} +
+ )} +
+ {children} +
+
+ ) +} diff --git a/app/renderer/components/ProviderPop.tsx b/app/renderer/components/ProviderPop.tsx new file mode 100644 index 0000000..789d8f5 --- /dev/null +++ b/app/renderer/components/ProviderPop.tsx @@ -0,0 +1,8 @@ +/** The `.pop` provider selector. Mutation (opening the menu) lands in T8. */ +export function ProviderPop({ label, onClick }: { label: string; onClick?: () => void }) { + return ( +
+ {label} +
+ ) +} diff --git a/app/renderer/components/SegTabs.tsx b/app/renderer/components/SegTabs.tsx new file mode 100644 index 0000000..affbf4e --- /dev/null +++ b/app/renderer/components/SegTabs.tsx @@ -0,0 +1,33 @@ +export type SegOption = { value: string; label: string } + +/** The `.seg` segmented control used for period and lens switching. */ +export function SegTabs({ + options, + value, + onChange, + style, +}: { + options: SegOption[] + value: string + onChange: (value: string) => void + style?: React.CSSProperties +}) { + return ( +
+ {options.map(opt => ( + onChange(opt.value)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') onChange(opt.value) + }} + > + {opt.label} + + ))} +
+ ) +} diff --git a/app/renderer/components/Sidebar.test.tsx b/app/renderer/components/Sidebar.test.tsx new file mode 100644 index 0000000..794ca13 --- /dev/null +++ b/app/renderer/components/Sidebar.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +import { Sidebar } from './Sidebar' + +describe('Sidebar', () => { + it('renders all six nav items', () => { + render( {}} />) + expect(screen.getAllByRole('button')).toHaveLength(6) + for (const label of ['Overview', 'Spend', 'Optimize', 'Models', 'Plans', 'Settings']) { + expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument() + } + }) + + it('calls onNavigate with the section id when a nav item is clicked', () => { + const onNavigate = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Spend/ })) + expect(onNavigate).toHaveBeenCalledWith('spend') + }) + + it('marks the active item with the "on" class', () => { + render( {}} />) + expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on') + expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on') + }) +}) diff --git a/app/renderer/components/Sidebar.tsx b/app/renderer/components/Sidebar.tsx new file mode 100644 index 0000000..48c92e2 --- /dev/null +++ b/app/renderer/components/Sidebar.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' + +export type Section = 'overview' | 'spend' | 'optimize' | 'models' | 'plans' | 'settings' + +export const NAV_ITEMS: Array<{ id: Section; label: string; key: string }> = [ + { id: 'overview', label: 'Overview', key: '⌘1' }, + { id: 'spend', label: 'Spend', key: '⌘2' }, + { id: 'optimize', label: 'Optimize', key: '⌘3' }, + { id: 'models', label: 'Models', key: '⌘4' }, + { id: 'plans', label: 'Plans', key: '⌘5' }, + { id: 'settings', label: 'Settings', key: '⌘,' }, +] + +export function Sidebar({ + active, + onNavigate, + status, +}: { + active: Section + onNavigate: (section: Section) => void + status?: ReactNode +}) { + return ( + + ) +} diff --git a/app/renderer/components/Stat.tsx b/app/renderer/components/Stat.tsx new file mode 100644 index 0000000..687ee49 --- /dev/null +++ b/app/renderer/components/Stat.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' + +export type StatTone = 'info' | 'bad' | 'hot' | 'ok' + +/** A `.panel.stat` metric card: label strip + big value + tinted delta line. */ +export function Stat({ + label, + value, + delta, + tone, +}: { + label: ReactNode + value: ReactNode + delta?: ReactNode + tone?: StatTone +}) { + return ( +
+
{label}
+
+
{value}
+ {delta !== undefined &&
{delta}
} +
+
+ ) +} diff --git a/app/renderer/components/TopBar.tsx b/app/renderer/components/TopBar.tsx new file mode 100644 index 0000000..b8b0628 --- /dev/null +++ b/app/renderer/components/TopBar.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' + +import { ProviderPop } from './ProviderPop' +import { SegTabs, type SegOption } from './SegTabs' + +/** Full period vocabulary per the wireframe. 6M/Custom (date ranges) land in T8. */ +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' }, +] + +/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */ +export function TopBar({ + title, + scope, + period, + onPeriodChange, + providerLabel, + onProviderClick, +}: { + title: ReactNode + scope?: ReactNode + period: string + onPeriodChange: (value: string) => void + providerLabel: string + onProviderClick?: () => void +}) { + return ( +
+
{title}
+ {scope !== undefined && {scope}} +
+ + +
+ ) +} diff --git a/app/renderer/components/Window.tsx b/app/renderer/components/Window.tsx new file mode 100644 index 0000000..65373c3 --- /dev/null +++ b/app/renderer/components/Window.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' + +/** The `.win` shell: sidebar + content area live inside as children. */ +export function Window({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts new file mode 100644 index 0000000..a30611a --- /dev/null +++ b/app/renderer/hooks/usePolled.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react' + +import { normalizeCliError } from '../lib/ipc' +import type { CliError } from '../lib/types' + +export type Polled = { + data: T | null + error: CliError | null + loading: boolean + /** Re-run the fetcher immediately (period/provider change, manual refresh). */ + refresh: () => void +} + +/** + * Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then + * re-polls every `intervalMs`. Errors are normalized to the CliError shape so + * sections can branch on `error.kind`. Last-good data is retained on error. + */ +export function usePolled(fetcher: () => Promise, deps: unknown[], intervalMs = 30_000): Polled { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + const load = useCallback(() => { + let cancelled = false + setLoading(true) + fetcher() + .then(result => { + if (cancelled) return + setData(result) + setError(null) + }) + .catch(err => { + if (!cancelled) setError(normalizeCliError(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + // deps are intentionally the caller-provided dependency list. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) + + useEffect(() => { + const cancel = load() + const id = setInterval(() => load(), intervalMs) + return () => { + cancel() + clearInterval(id) + } + }, [load, intervalMs]) + + const refresh = useCallback(() => { + load() + }, [load]) + + return { data, error, loading, refresh } +} diff --git a/app/renderer/index.html b/app/renderer/index.html new file mode 100644 index 0000000..91c69f9 --- /dev/null +++ b/app/renderer/index.html @@ -0,0 +1,34 @@ + + + + + + + CodeBurn + + + +
+ + + diff --git a/app/renderer/main.tsx b/app/renderer/main.tsx new file mode 100644 index 0000000..6d4d64d --- /dev/null +++ b/app/renderer/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { App } from './App' +import './styles/indigo.css' + +const root = document.getElementById('root') +if (!root) throw new Error('#root not found') + +createRoot(root).render( + + + , +)