From 43c68b2075eff4880c257b27a929cdc6e8ff40a5 Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Fri, 11 Sep 2026 17:04:22 -0700 Subject: [PATCH] Add per-pane usage averages and custom date ranges --- .../src/components/usage/PaneUsageSummary.tsx | 72 +++++++++ .../components/usage/UsageDateRangeDialog.tsx | 36 +++++ frontend/src/components/usage/UsageView.tsx | 80 +++++++--- .../src/components/usage/usageDateRange.ts | 16 ++ main/src/services/usage/README.md | 23 +++ screenshots/usage/custom-date-range.png | Bin 0 -> 116356 bytes screenshots/usage/pane-averages.png | Bin 0 -> 99716 bytes tests/usage-and-limits.spec.ts | 146 ++++++++++++++++++ 8 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/usage/PaneUsageSummary.tsx create mode 100644 frontend/src/components/usage/UsageDateRangeDialog.tsx create mode 100644 frontend/src/components/usage/usageDateRange.ts create mode 100644 screenshots/usage/custom-date-range.png create mode 100644 screenshots/usage/pane-averages.png diff --git a/frontend/src/components/usage/PaneUsageSummary.tsx b/frontend/src/components/usage/PaneUsageSummary.tsx new file mode 100644 index 000000000..585b6d463 --- /dev/null +++ b/frontend/src/components/usage/PaneUsageSummary.tsx @@ -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 ( +
+
+

Per-pane usage

+
+ Per-pane calculation + {[{ label: 'Average', value: false }, { label: 'Trim 10%', value: true }].map(option => ( + + ))} +
+
+
+ {metrics.map(metric => ( +
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ ))} +
+

+ {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.'} +

+
+ ); +} diff --git a/frontend/src/components/usage/UsageDateRangeDialog.tsx b/frontend/src/components/usage/UsageDateRangeDialog.tsx new file mode 100644 index 000000000..0a714e79e --- /dev/null +++ b/frontend/src/components/usage/UsageDateRangeDialog.tsx @@ -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 ( + +
{ event.preventDefault(); if (valid) onApply({ start, end }); }}> + + + setStart(event.target.value)} /> + setEnd(event.target.value)} /> +

Includes both dates in your local time zone. Only indexed history is available; usage records are retained for {USAGE_RETENTION_DAYS} days.

+ {start && end && start > end &&

End date must be on or after start date.

} +
+ + + + + +
+ ); +} diff --git a/frontend/src/components/usage/UsageView.tsx b/frontend/src/components/usage/UsageView.tsx index 87725a390..0263be25b 100644 --- a/frontend/src/components/usage/UsageView.tsx +++ b/frontend/src/components/usage/UsageView.tsx @@ -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, @@ -150,6 +154,10 @@ export function UsageView() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [rangeDays, setRangeDays] = useState(DEFAULT_USAGE_RANGE_DAYS); + const [customRange, setCustomRange] = useState(null); + const [showDateRange, setShowDateRange] = useState(false); + const [trimPaneUsage, setTrimPaneUsage] = useState(false); + const requestId = useRef(0); const [provider, setProvider] = useState('all'); const [downloadStatus, setDownloadStatus] = useState<'idle' | 'capturing' | 'done'>('idle'); const [shareStatus, setShareStatus] = useState<'idle' | 'capturing' | 'done'>('idle'); @@ -162,29 +170,34 @@ export function UsageView() { const [hiddenSeries, setHiddenSeries] = useState([]); 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); + } } - }, [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. @@ -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`; const captureImage = useCallback(async (): Promise => { if (!contentRef.current) return null; @@ -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(); @@ -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(); @@ -255,7 +271,7 @@ export function UsageView() { } catch { setShareStatus('idle'); } - }, [captureImage, shareStatus, rangeLabel]); + }, [captureImage, shareStatus, rangeLabel, loading, error, report]); useHotkey({ id: 'usage-download', @@ -462,10 +478,10 @@ export function UsageView() { +