Skip to content

Commit 18528f0

Browse files
feat(dashboard): budget progress bars and spend summary (#3183)
* feat(dashboard): add budget progress bars and spend summary to CostPage Implements PR 1 of issue #3125 — Budget Alerts & Cost Forecasts. New components: - BudgetProgressBar: visual spend vs cap with color thresholds (green <50%, amber 50-80%, red >80%, handles unlimited) - SpendSummary: today/monthly spend + projected monthly total using linear regression from daily trends CostPage enhancements: - Reads budget settings from localStorage (aegis:settings) - Shows daily and monthly BudgetProgressBar when alerts enabled - Shows SpendSummary with forecast data - Falls back to existing budget banner when alerts disabled Tests: 15 new Vitest tests (all passing, tsc clean, build clean) Refs: #3125 * feat(dashboard): add ForecastChart, BudgetAlertBanner, and SessionCostCard Completes issue #3125 — Budget Alerts & Cost Forecasts (single PR). New components: - ForecastChart: line chart with actual vs projected spend, optional budget cap reference line, linear regression projection - BudgetAlertBanner: global banner in Layout, polls /v1/analytics/costs every 5min, shows warning (80%) and critical (100%) alerts - SessionCostCard: per-session cost card with token breakdown and total cost display Integration: - ForecastChart added to CostPage (when budget alerts enabled) - BudgetAlertBanner added to Layout (global, dismissible for 30min) - SessionCostCard ready for SessionDetailPage integration Tests: 15 new tests (4 suites), all passing Total: 1287 tests pass, tsc clean, build clean (1.32s) Known limitation: Budget settings stored in localStorage only — not synced across devices. Server persistence deferred to follow-up. Refs: #3125 * fix(dashboard): address Argus review — remove duplicate tests, fix a11y, refactor IIFE Addresses all 4 review items from Argus on PR #3183: 1. Remove duplicate test files — keep __tests__/ convention only, removed sibling SpendSummary.test.tsx and BudgetProgressBar.test.tsx 2. Fix progressbar accessibility — moved role='progressbar' from inner fill div to outer track wrapper with min-h-[44px] touch target 3. Extract IIFE to BudgetOverview subcomponent — cleaner JSX render, easier to test and reason about 4. Add localStorage value coercion — Number() + Boolean() fallbacks to handle malformed stored data gracefully 1265 tests pass, tsc clean, build clean (1.34s). Refs: #3183 (review) * fix(dashboard): address remaining 6 Argus review findings All 7 findings now resolved: 1. ✅ Duplicate test files (fixed in previous commit) 2. ✅ localStorage Number() coercion — uses Number() || 0 and Boolean() 3. ✅ Touch target mismatch — separated min-h-[44px] wrapper from visual h-3 bar, progressbar role on track (full range) 4. ✅ localStorage key mismatch — unified to 'aegis:settings' everywhere 5. ✅ Timezone UTC/local mismatch — replaced getUTCDate() with getDate() 6. ✅ sessionStorage during render — moved to useEffect on mount 7. ✅ linearRegression div-by-zero — added denominator guard 1265 tests pass, tsc clean, build clean (1.29s). Refs: #3183 (review) * chore: trigger CI refresh for review re-check * fix(dashboard): extract shared getBudgetSettings — single source of truth Argus found getBudgetSettings() duplicated in CostPage and BudgetAlertBanner with different implementations. Now extracted to utils/budgetSettings.ts: - Single getBudgetSettings() with Number()/Boolean() coercion - Single STORAGE_KEY constant ('aegis:settings') - Both CostPage and BudgetAlertBanner import from shared module - No more duplicate logic divergence risk 1265 tests pass, tsc clean, build clean. Refs: #3183 (review) --------- Co-authored-by: aegis-gh-agent[bot] <272581873+aegis-gh-agent[bot]@users.noreply.github.com>
1 parent 34386b4 commit 18528f0

12 files changed

Lines changed: 1153 additions & 20 deletions
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* ForecastChart.tsx — Cost forecast line chart with projected trend.
3+
*
4+
* Shows actual daily spend and a linear regression projection line.
5+
* Part of issue #3125: Budget Alerts & Cost Forecasts.
6+
*/
7+
8+
import {
9+
LineChart,
10+
Line,
11+
XAxis,
12+
YAxis,
13+
Tooltip,
14+
CartesianGrid,
15+
ReferenceLine,
16+
Legend,
17+
} from 'recharts';
18+
import { formatCurrency } from '../../utils/formatNumber';
19+
import { formatDateShort } from '../../utils/formatDate';
20+
import { ChartFrame } from '../shared/ChartFrame';
21+
22+
export interface ForecastChartProps {
23+
/** Daily cost trends from analytics API. */
24+
dailyTrends: Array<{
25+
date: string;
26+
estimatedCostUsd: number;
27+
sessions: number;
28+
}>;
29+
/** Optional monthly budget cap for reference line. Zero = no line. */
30+
monthlyCap?: number;
31+
}
32+
33+
interface ChartPoint {
34+
date: string;
35+
actual: number | null;
36+
projected: number | null;
37+
}
38+
39+
function linearRegression(points: Array<{ x: number; y: number }>): { slope: number; intercept: number } {
40+
const n = points.length;
41+
if (n < 2) return { slope: 0, intercept: n === 1 ? points[0].y : 0 };
42+
43+
const sumX = points.reduce((s, p) => s + p.x, 0);
44+
const sumY = points.reduce((s, p) => s + p.y, 0);
45+
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
46+
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
47+
48+
const denominator = n * sumXX - sumX * sumX;
49+
if (denominator === 0) return { slope: 0, intercept: sumY / n };
50+
const slope = (n * sumXY - sumX * sumY) / denominator;
51+
const intercept = (sumY - slope * sumX) / n;
52+
return { slope, intercept };
53+
}
54+
55+
function buildChartData(dailyTrends: ForecastChartProps['dailyTrends']): ChartPoint[] {
56+
if (dailyTrends.length === 0) return [];
57+
58+
const today = new Date();
59+
const year = today.getFullYear();
60+
const month = today.getMonth();
61+
const daysInMonth = new Date(year, month + 1, 0).getDate();
62+
63+
// Build regression from actual data points
64+
const regressionPoints = dailyTrends.map((d, i) => ({
65+
x: i,
66+
y: d.estimatedCostUsd,
67+
}));
68+
const { slope, intercept } = linearRegression(regressionPoints);
69+
70+
// Generate chart data: actual days + projected remaining days
71+
const chartData: ChartPoint[] = [];
72+
73+
// Actual data points
74+
dailyTrends.forEach((d) => {
75+
chartData.push({
76+
date: d.date,
77+
actual: d.estimatedCostUsd,
78+
projected: null,
79+
});
80+
});
81+
82+
// Projected data: start from last actual point, extend to end of month
83+
if (dailyTrends.length >= 2) {
84+
const lastIdx = dailyTrends.length - 1;
85+
const lastDate = new Date(dailyTrends[lastIdx].date + 'T00:00:00Z');
86+
const lastDay = lastDate.getDate();
87+
88+
// Add the last actual point as projection start for continuity
89+
chartData[lastIdx].projected = dailyTrends[lastIdx].estimatedCostUsd;
90+
91+
for (let day = lastDay + 1; day <= daysInMonth; day++) {
92+
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
93+
const projectedIdx = lastIdx + (day - lastDay);
94+
const projectedValue = Math.max(0, slope * projectedIdx + intercept);
95+
chartData.push({
96+
date: dateStr,
97+
actual: null,
98+
projected: projectedValue,
99+
});
100+
}
101+
}
102+
103+
return chartData;
104+
}
105+
106+
function CustomTooltip({ active, payload, label }: {
107+
active?: boolean;
108+
payload?: Array<{ name: string; value: number; color?: string; dataKey?: string }>;
109+
label?: string;
110+
}) {
111+
if (!active || !payload || payload.length === 0) return null;
112+
113+
return (
114+
<div className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3 shadow-xl">
115+
<p className="mb-2 text-xs font-medium text-[var(--color-text-primary)]">
116+
{label ? formatDateShort(label) : ''}
117+
</p>
118+
{payload.map((entry, index) => (
119+
<div key={index} className="flex items-center justify-between gap-4 text-xs">
120+
<span className="text-[var(--color-text-muted)]">{entry.name}:</span>
121+
<span className="font-mono font-medium text-[var(--color-text-primary)]">
122+
{formatCurrency(entry.value)}
123+
</span>
124+
</div>
125+
))}
126+
</div>
127+
);
128+
}
129+
130+
export function ForecastChart({ dailyTrends, monthlyCap = 0 }: ForecastChartProps) {
131+
const chartData = buildChartData(dailyTrends);
132+
const hasData = chartData.length > 0;
133+
134+
if (!hasData) {
135+
return (
136+
<div
137+
className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5"
138+
aria-label="Cost forecast chart"
139+
>
140+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
141+
Cost Forecast
142+
</h3>
143+
<p className="text-sm text-[var(--color-text-muted)]">
144+
Not enough data for forecasting. Collect at least 2 days of cost data.
145+
</p>
146+
</div>
147+
);
148+
}
149+
150+
// Calculate projected monthly total for display
151+
const lastProjected = chartData.filter(d => d.projected !== null);
152+
const projectedMonthlyTotal = lastProjected.length > 0
153+
? lastProjected.reduce((sum, d) => sum + (d.projected ?? 0), 0) +
154+
chartData.filter(d => d.actual !== null && d.projected === null).reduce((sum, d) => sum + (d.actual ?? 0), 0)
155+
: 0;
156+
157+
return (
158+
<section
159+
className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5"
160+
aria-label="Cost forecast chart"
161+
>
162+
<div className="mb-4 flex items-center justify-between">
163+
<h3 className="text-lg font-medium text-[var(--color-text-primary)]">
164+
Cost Forecast
165+
</h3>
166+
{projectedMonthlyTotal > 0 && (
167+
<p className="text-sm text-[var(--color-text-muted)]">
168+
Projected total: <span className="font-mono font-bold text-[var(--color-text-primary)]">
169+
{formatCurrency(projectedMonthlyTotal)}
170+
</span>
171+
</p>
172+
)}
173+
</div>
174+
175+
<ChartFrame className="h-72 min-w-0" label="Cost forecast loading">
176+
{({ width, height }) => (
177+
<LineChart width={width} height={height} data={chartData}>
178+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-void-lighter)" />
179+
<XAxis
180+
dataKey="date"
181+
tickFormatter={formatDateShort}
182+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
183+
stroke="var(--color-void-lighter)"
184+
/>
185+
<YAxis
186+
tickFormatter={(value) => `$${value.toFixed(2)}`}
187+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
188+
stroke="var(--color-void-lighter)"
189+
/>
190+
<Tooltip content={<CustomTooltip />} />
191+
<Legend
192+
wrapperStyle={{ color: 'var(--color-text-muted)', fontSize: 12 }}
193+
/>
194+
<Line
195+
type="monotone"
196+
dataKey="actual"
197+
name="Actual Spend"
198+
stroke="var(--color-accent-cyan)"
199+
strokeWidth={2}
200+
dot={{ r: 3, fill: 'var(--color-accent-cyan)' }}
201+
connectNulls={false}
202+
/>
203+
<Line
204+
type="monotone"
205+
dataKey="projected"
206+
name="Projected"
207+
stroke="var(--color-accent-cyan)"
208+
strokeWidth={2}
209+
strokeDasharray="8 4"
210+
dot={false}
211+
connectNulls={false}
212+
/>
213+
{monthlyCap > 0 && (
214+
<ReferenceLine
215+
y={monthlyCap}
216+
stroke="var(--color-danger)"
217+
strokeDasharray="4 4"
218+
label={{
219+
value: `Cap: ${formatCurrency(monthlyCap)}`,
220+
position: 'right',
221+
fill: 'var(--color-danger)',
222+
fontSize: 11,
223+
}}
224+
/>
225+
)}
226+
</LineChart>
227+
)}
228+
</ChartFrame>
229+
</section>
230+
);
231+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* SpendSummary.tsx — Summary of spending metrics with forecast.
3+
*
4+
* Shows today's spend, monthly spend, and projected monthly total.
5+
* Part of issue #3125: Budget Alerts & Cost Forecasts.
6+
*/
7+
8+
import { formatCurrency } from '../../utils/formatNumber';
9+
10+
export interface SpendSummaryProps {
11+
/** Daily cost trends from analytics API. */
12+
dailyTrends: Array<{
13+
date: string;
14+
estimatedCostUsd: number;
15+
sessions: number;
16+
}>;
17+
}
18+
19+
function getToday(): string {
20+
const d = new Date();
21+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
22+
}
23+
24+
function getCurrentMonthPrefix(): string {
25+
const d = new Date();
26+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
27+
}
28+
29+
function getDaysInMonth(): number {
30+
const d = new Date();
31+
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
32+
}
33+
34+
function getCurrentDay(): number {
35+
return new Date().getDate();
36+
}
37+
38+
export function SpendSummary({ dailyTrends }: SpendSummaryProps) {
39+
const today = getToday();
40+
const monthPrefix = getCurrentMonthPrefix();
41+
const daysInMonth = getDaysInMonth();
42+
const currentDay = getCurrentDay();
43+
44+
// Today's spend
45+
const todayEntry = dailyTrends.find((d) => d.date === today);
46+
const todaySpend = todayEntry?.estimatedCostUsd ?? 0;
47+
48+
// Monthly spend (sum of all entries in current month)
49+
const monthEntries = dailyTrends.filter((d) => d.date.startsWith(monthPrefix));
50+
const monthSpend = monthEntries.reduce((sum, d) => sum + d.estimatedCostUsd, 0);
51+
52+
// Projected monthly total
53+
// Use average daily spend from available data points, extrapolate to full month
54+
let projectedTotal = monthSpend;
55+
if (monthEntries.length > 0 && currentDay < daysInMonth) {
56+
const avgDailySpend = monthSpend / monthEntries.length;
57+
const remainingDays = daysInMonth - currentDay;
58+
projectedTotal = monthSpend + (avgDailySpend * remainingDays);
59+
}
60+
61+
const hasData = dailyTrends.length > 0;
62+
63+
if (!hasData) {
64+
return (
65+
<div
66+
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
67+
aria-label="Spending summary"
68+
>
69+
{['Today', 'This Month', 'Projected'].map((label) => (
70+
<div
71+
key={label}
72+
className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-4"
73+
>
74+
<p className="text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
75+
{label}
76+
</p>
77+
<p className="mt-1 text-lg font-semibold text-[var(--color-text-muted)]">
78+
No data
79+
</p>
80+
</div>
81+
))}
82+
</div>
83+
);
84+
}
85+
86+
const stats = [
87+
{ label: 'Today', value: formatCurrency(todaySpend), sub: `${todayEntry?.sessions ?? 0} sessions` },
88+
{ label: 'This Month', value: formatCurrency(monthSpend), sub: `${monthEntries.reduce((s, d) => s + d.sessions, 0)} sessions` },
89+
{ label: 'Projected Total', value: formatCurrency(projectedTotal), sub: `based on ${monthEntries.length} day${monthEntries.length !== 1 ? 's' : ''} of data` },
90+
];
91+
92+
return (
93+
<div
94+
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
95+
aria-label="Spending summary"
96+
>
97+
{stats.map((stat) => (
98+
<div
99+
key={stat.label}
100+
className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-4"
101+
>
102+
<p className="text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
103+
{stat.label}
104+
</p>
105+
<p className="mt-1 text-lg font-semibold text-[var(--color-text-primary)]">
106+
{stat.value}
107+
</p>
108+
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
109+
{stat.sub}
110+
</p>
111+
</div>
112+
))}
113+
</div>
114+
);
115+
}

0 commit comments

Comments
 (0)