Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions frontend/src/components/usage/PaneUsageSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { UsageByPaneReport } from '../../../../shared/types/usage';
import { formatTokens, formatUsd } from '../ui/charts/chartScales';

export function PaneUsageSummary({ byPane, trim, onTrimChange }: {
byPane: UsageByPaneReport;
trim: boolean;
onTrimChange: (trim: boolean) => void;
}) {
const panes = byPane.panes.filter(pane => pane.messageCount > 0);
const count = panes.length;
const cut = trim ? Math.floor(count * 0.1) : 0;
const retained = count - cut * 2;
const mean = (values: number[]): number => {
const sample = values.sort((a, b) => a - b).slice(cut, count - cut);
return sample.reduce((sum, value) => sum + value, 0) / retained;
};
const costIncomplete = panes.some(pane => pane.costIncomplete);
const metrics = [
{
label: 'Tokens / pane',
value: count ? formatTokens(mean(panes.map(pane => pane.inputTokens + pane.outputTokens + pane.cacheCreationTokens))) : '—',
detail: 'Input + output + cache writes; excludes cache reads',
},
{
label: 'Est. cost / pane',
value: !count ? '—' : costIncomplete ? 'n/a' : formatUsd(mean(panes.map(pane => pane.estimatedCostUsd))),
detail: costIncomplete ? 'Missing model prices in this sample' : 'All tokens at API rates, not subscription charges',
},
{
label: 'Messages / pane',
value: count ? mean(panes.map(pane => pane.messageCount)).toLocaleString(undefined, { maximumFractionDigits: 1 }) : '—',
detail: 'Recorded usage events, not human prompts',
},
];

return (
<section data-testid="pane-usage-summary" aria-label="Per-pane usage" className="rounded border border-border-primary bg-surface-secondary p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[11px] font-medium uppercase tracking-wider text-text-tertiary">Per-pane usage</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">Per-pane calculation</legend>
{[{ label: 'Average', value: false }, { label: 'Trim 10%', value: true }].map(option => (
<button
key={option.label}
type="button"
aria-pressed={trim === option.value}
onClick={() => onTrimChange(option.value)}
className={`rounded px-2 py-1 text-[11px] ${trim === option.value ? 'bg-interactive text-text-on-interactive' : 'text-text-secondary hover:bg-surface-hover'}`}
>
{option.label}
</button>
))}
</fieldset>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{metrics.map(metric => (
<div key={metric.label} className="rounded border border-border-primary px-3 py-2">
<h3 className="text-[10px] uppercase tracking-wider text-text-muted">{metric.label}</h3>
<p className="mt-0.5 text-lg font-semibold tabular-nums text-text-primary">{metric.value}</p>
<p className="text-[10px] text-text-tertiary">{metric.detail}</p>
</div>
))}
</div>
<p className="mt-2 text-[11px] text-text-tertiary">
{count ? `${count.toLocaleString()} panes with recorded usage in this period, including archived panes.` : 'No pane-attributed usage in this period.'}
{' '}Empty panes and unattributed usage are excluded.
{trim && ` Each metric drops its ${cut} highest and ${cut} lowest values; ${retained} panes remain per metric.`}
{trim && count > 0 && cut === 0 && ' At least 10 panes are needed to trim.'}
</p>
</section>
);
}
36 changes: 36 additions & 0 deletions frontend/src/components/usage/UsageDateRangeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useState } from 'react';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/Modal';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
import { USAGE_RETENTION_DAYS } from '../../../../shared/types/usage';

import { localDateString, type UsageDateRange } from './usageDateRange';

export function UsageDateRangeDialog({ initialRange, onApply, onClose }: {
initialRange: UsageDateRange;
onApply: (range: UsageDateRange) => void;
onClose: () => void;
}) {
const [start, setStart] = useState(initialRange.start);
const [end, setEnd] = useState(initialRange.end);
const today = localDateString(new Date());
const valid = Boolean(start && end && start <= end && end <= today);

return (
<Modal isOpen onClose={onClose} size="sm">
<form onSubmit={event => { event.preventDefault(); if (valid) onApply({ start, end }); }}>
<ModalHeader title="Custom usage range" />
<ModalBody className="space-y-3">
<Input label="Start date" type="date" value={start} max={end || today} required fullWidth onChange={event => setStart(event.target.value)} />
<Input label="End date" type="date" value={end} min={start} max={today} required fullWidth onChange={event => setEnd(event.target.value)} />
<p className="text-xs text-text-tertiary">Includes both dates in your local time zone. Only indexed history is available; usage records are retained for {USAGE_RETENTION_DAYS} days.</p>
{start && end && start > end && <p role="alert" className="text-xs text-status-error">End date must be on or after start date.</p>}
</ModalBody>
<ModalFooter>
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!valid}>Apply range</Button>
</ModalFooter>
</form>
</Modal>
);
}
80 changes: 61 additions & 19 deletions frontend/src/components/usage/UsageView.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BarChart3, Check, Download, Loader2, RefreshCw, Share2 } from 'lucide-react';
import { BarChart3, CalendarDays, Check, Download, Loader2, RefreshCw, Share2 } from 'lucide-react';
import { toPng } from 'html-to-image';
import { API } from '../../utils/api';
import { useHotkey } from '../../hooks/useHotkey';
import { useCommittedRef } from '../../hooks/useCommittedRef';
import { AreaChart } from '../ui/charts/AreaChart';
import { BarChart } from '../ui/charts/BarChart';
import { DonutChart } from '../ui/charts/DonutChart';
import { formatTokens, formatUsd } from '../ui/charts/chartScales';
import { LimitBar, LimitStatusBanners, CreditsLine } from './ProviderLimits';
import { LeaderboardTab } from './LeaderboardTab';
import { PaneUsageSummary } from './PaneUsageSummary';
import { UsageDateRangeDialog } from './UsageDateRangeDialog';
import { localDateString, usageDateBounds, type UsageDateRange } from './usageDateRange';
import {
DEFAULT_USAGE_RANGE_DAYS,
type UsageByPane,
Expand Down Expand Up @@ -150,6 +154,10 @@ export function UsageView() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rangeDays, setRangeDays] = useState<number>(DEFAULT_USAGE_RANGE_DAYS);
const [customRange, setCustomRange] = useState<UsageDateRange | null>(null);
const [showDateRange, setShowDateRange] = useState(false);
const [trimPaneUsage, setTrimPaneUsage] = useState(false);
const requestId = useRef(0);
const [provider, setProvider] = useState<UsageProvider | 'all'>('all');
const [downloadStatus, setDownloadStatus] = useState<'idle' | 'capturing' | 'done'>('idle');
const [shareStatus, setShareStatus] = useState<'idle' | 'capturing' | 'done'>('idle');
Expand All @@ -162,29 +170,34 @@ export function UsageView() {
const [hiddenSeries, setHiddenSeries] = useState<string[]>([]);

const load = useCallback(async (mode: 'initial' | 'refresh') => {
const id = ++requestId.current;
if (mode === 'refresh') setRefreshing(true);
else setLoading(true);
try {
const toMs = Date.now();
const response = await API.usage.getReport({
fromMs: toMs - rangeDays * DAY_MS,
toMs,
...(customRange ? usageDateBounds(customRange) : { fromMs: toMs - rangeDays * DAY_MS, toMs }),
providers: provider === 'all' ? undefined : [provider],
});
if (id !== requestId.current) return;
if (!response.success || !response.data) {
throw new Error(response.error || 'Failed to load usage');
}
setReport(response.data);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load usage');
if (id === requestId.current) setError(err instanceof Error ? err.message : 'Failed to load usage');
} finally {
setLoading(false);
setRefreshing(false);
if (id === requestId.current) {
setLoading(false);
setRefreshing(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-loading-flag-reset-outside-finally (warning)

This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.

Fix → A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.

Docs

}
}
}, [rangeDays, provider]);
}, [rangeDays, customRange, provider]);

useEffect(() => {
void load('initial');
return () => { requestId.current += 1; };
}, [load]);

// While the index is still building, keep refreshing so numbers fill in.
Expand All @@ -195,17 +208,20 @@ export function UsageView() {
return () => window.clearInterval(timer);
}, [scanning, load]);

const currentLoad = useCommittedRef(load);
const handleRescan = useCallback(async () => {
setRefreshing(true);
try {
await API.usage.rescan();
await load('refresh');
await currentLoad.current('refresh');
} catch {
setRefreshing(false);
}
}, [load]);
}, [currentLoad]);

const rangeLabel = RANGE_OPTIONS.find(o => o.days === rangeDays)?.label ?? `${rangeDays}d`;
const rangeLabel = customRange
? `${customRange.start} to ${customRange.end}`
: RANGE_OPTIONS.find(o => o.days === rangeDays)?.label ?? `${rangeDays}d`;
Comment on lines +222 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Announce the custom interval in the chart label

When a custom range is active, rangeDays retains the previous preset, and the AreaChart at line 658 still announces Token usage over the last ${rangeDays} days. A screen-reader user selecting a custom historical interval therefore hears a false range even though this new rangeLabel contains the correct dates. Use the custom label in the chart's accessible name when customRange is set.

Useful? React with 👍 / 👎.


const captureImage = useCallback(async (): Promise<string | null> => {
if (!contentRef.current) return null;
Expand All @@ -224,7 +240,7 @@ export function UsageView() {
}, []);

const handleDownload = useCallback(async () => {
if (downloadStatus !== 'idle') return;
if (downloadStatus !== 'idle' || loading || error || !report) return;
setDownloadStatus('capturing');
try {
const data = await captureImage();
Expand All @@ -236,10 +252,10 @@ export function UsageView() {
} catch {
setDownloadStatus('idle');
}
}, [captureImage, downloadStatus, rangeLabel]);
}, [captureImage, downloadStatus, rangeLabel, loading, error, report]);

const handleShare = useCallback(async () => {
if (shareStatus !== 'idle') return;
if (shareStatus !== 'idle' || loading || error || !report) return;
setShareStatus('capturing');
try {
const data = await captureImage();
Expand All @@ -255,7 +271,7 @@ export function UsageView() {
} catch {
setShareStatus('idle');
}
}, [captureImage, shareStatus, rangeLabel]);
}, [captureImage, shareStatus, rangeLabel, loading, error, report]);

useHotkey({
id: 'usage-download',
Expand Down Expand Up @@ -462,10 +478,10 @@ export function UsageView() {
<button
key={option.days}
type="button"
aria-pressed={rangeDays === option.days}
onClick={() => setRangeDays(option.days)}
aria-pressed={!customRange && rangeDays === option.days}
onClick={() => { setCustomRange(null); setRangeDays(option.days); }}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
rangeDays === option.days
!customRange && rangeDays === option.days
? 'bg-interactive text-text-on-interactive'
: 'text-text-secondary hover:bg-surface-hover'
}`}
Expand All @@ -475,12 +491,24 @@ export function UsageView() {
))}
</fieldset>

<button
type="button"
aria-label="Choose custom date range"
aria-pressed={customRange !== null}
title="Choose custom date range"
onClick={() => setShowDateRange(true)}
className={`flex items-center gap-1 rounded px-2 py-1 text-[11px] ${customRange ? 'bg-interactive text-text-on-interactive' : 'text-text-secondary hover:bg-surface-hover'}`}
>
<CalendarDays className="h-3.5 w-3.5" aria-hidden="true" />
{customRange ? rangeLabel : 'Custom'}
</button>

<span className="h-4 w-px bg-border-primary" aria-hidden="true" />

<button
type="button"
onClick={() => { void handleDownload(); }}
disabled={downloadStatus === 'capturing' || !report}
disabled={downloadStatus === 'capturing' || !report || loading || !!error}
aria-label="Download usage as image"
title="Download usage as image"
className="rounded p-1 transition-colors hover:bg-surface-hover disabled:opacity-50"
Expand All @@ -493,7 +521,7 @@ export function UsageView() {
<button
type="button"
onClick={() => { void handleShare(); }}
disabled={shareStatus === 'capturing' || !report}
disabled={shareStatus === 'capturing' || !report || loading || !!error}
aria-label="Share usage image"
title="Share usage image"
className="rounded p-1 transition-colors hover:bg-surface-hover disabled:opacity-50"
Expand All @@ -516,6 +544,17 @@ export function UsageView() {
)}
</header>

{showDateRange && (
<UsageDateRangeDialog
initialRange={customRange ?? {
start: localDateString(new Date(Date.now() - rangeDays * DAY_MS)),
end: localDateString(new Date()),
Comment on lines +549 to +551

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed inclusive ranges with one fewer calendar day

When no custom range exists, opening the dialog after selecting 30d seeds today - 30 days through today, which is 31 inclusive calendar dates; after 24h it similarly expands to two full dates. Applying the untouched defaults therefore requests more history than the selected preset suggests. Calculate the start as rangeDays - 1 calendar days before today (using calendar arithmetic so DST does not shift the date).

Useful? React with 👍 / 👎.

}}
onClose={() => setShowDateRange(false)}
onApply={range => { setCustomRange(range); setShowDateRange(false); }}
/>
)}

{activeTab === 'usage' && report?.index.scanning && (
<div className="flex flex-shrink-0 items-center gap-2 border-b border-border-primary bg-surface-tertiary px-4 py-1 text-[11px] text-text-tertiary">
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
Expand Down Expand Up @@ -550,6 +589,7 @@ export function UsageView() {
</div>
) : report ? (
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<p className="text-xs text-text-tertiary">{customRange ? rangeLabel : `Last ${rangeLabel}`} · {provider === 'all' ? 'All providers' : PROVIDER_OPTIONS.find(option => option.value === provider)?.label}</p>
{/* Summary — all from logs */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatCard
Expand Down Expand Up @@ -603,6 +643,8 @@ export function UsageView() {
</div>
)}

<PaneUsageSummary byPane={report.byPane} trim={trimPaneUsage} onTrimChange={setTrimPaneUsage} />

<div className="grid gap-4 lg:grid-cols-3">
{/* Time series */}
<section className="rounded border border-border-primary bg-surface-secondary p-3 lg:col-span-2">
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/usage/usageDateRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface UsageDateRange {
start: string;
end: string;
}

export function localDateString(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}

export function usageDateBounds(range: UsageDateRange) {
const start = new Date(`${range.start}T00:00:00`);
const end = new Date(`${range.end}T00:00:00`);
end.setDate(end.getDate() + 1);
return { fromMs: start.getTime(), toMs: end.getTime() - 1 };
Comment on lines +10 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align daily buckets with the custom range timezone

When a custom interval longer than two days is selected outside UTC, these local-midnight bounds are passed into a report whose automatic daily series still floors timestamps to UTC-day boundaries (main/src/services/usage/usageAggregator.ts:323,360), while the renderer formats those boundaries as local dates (UsageView.tsx:294). For example, March 8–10 in America/Los_Angeles can produce buckets labeled March 7–10 and split a selected local day between bars, so the time chart does not represent the requested calendar days. The custom-range path needs timezone-aligned bucketing or local regrouping of hourly data.

Useful? React with 👍 / 👎.

}

23 changes: 23 additions & 0 deletions main/src/services/usage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,26 @@ pnpm --filter main exec vitest run src/services/usage
Do not reproduce descriptor exhaustion against a user's real transcript trees.
For resource measurements, generate a disposable tree, constrain only child
processes, and delete only fixtures created by that run.

## Dashboard ranges and per-pane summaries

Usage & limits supports rolling 24h/7d/30d/90d presets and custom inclusive
calendar dates in the viewer's local time zone. Applying dates uses the existing
report query; it does not rescan transcripts. Historical reports contain only
indexed data, subject to the 180-day event retention window. Provider limits
continue to show current provider readings, regardless of the report range.

Per-pane usage defaults to an ordinary average across panes with recorded
usage in the selected period and provider filter, including archived panes.
Empty panes and unattributed events are excluded. Tokens per pane counts input,
output and cache-creation tokens, excluding cache reads. Cost includes all token
categories at estimated API rates, not subscription charges; any missing price
in the eligible sample makes the cost summary unavailable. Messages counts
recorded usage events, not human prompts.

The optional Trim 10% mode independently sorts each metric and removes
`floor(paneCount * 0.1)` values from each end before averaging. Fewer than ten
panes means no trimming. The UI shows the original and retained sample counts.
These summaries describe consumption during the selected period, not lifetime
task costs or completed work. They are derived from the existing report without
additional database queries. Leaderboard calculations are unchanged.
Binary file added screenshots/usage/custom-date-range.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/usage/pane-averages.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading