feat: complete v0.4.0 implementation - Reports, Charts, Comparison, Dark Mode
Some checks failed
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled

Backend (@backend-dev):
- ReportService with PDF/CSV generation (reportlab, pandas)
- Report API endpoints (POST, GET, DELETE, download with rate limiting)
- Professional PDF templates with branding and tables
- Storage management with auto-cleanup

Frontend (@frontend-dev):
- Recharts integration: CostBreakdown, TimeSeries, ComparisonBar
- Scenario comparison: multi-select, compare page with side-by-side layout
- Reports UI: generation form, list with status badges, download
- Dark/Light mode: ThemeProvider, toggle, CSS variables
- Responsive design for all components

QA (@qa-engineer):
- E2E testing setup with Playwright
- 100 test cases across 7 spec files
- Visual regression baselines
- CI/CD workflow configuration
- ES modules fixes

Documentation:
- Add todo.md with testing checklist and future roadmap
- Update kickoff prompt for v0.4.0

27 tasks completed, 100% v0.4.0 delivery

Closes: v0.4.0 milestone
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 17:46:47 +02:00
parent 69c25229ca
commit 94db0804d1
19 changed files with 1217 additions and 106 deletions

View File

@@ -38,6 +38,28 @@ interface ChartDataPoint {
color: string;
}
// Tooltip component defined outside main component
interface BarTooltipProps {
active?: boolean;
payload?: Array<{ payload: ChartDataPoint }>;
formatter?: (value: number) => string;
}
function BarTooltip({ active, payload, formatter }: BarTooltipProps) {
if (active && payload && payload.length && formatter) {
const item = payload[0].payload;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground">{item.name}</p>
<p className="text-sm text-muted-foreground">
{formatter(item.value)}
</p>
</div>
);
}
return null;
}
export function ComparisonBarChart({
scenarios,
metricKey,
@@ -58,24 +80,6 @@ export function ComparisonBarChart({
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const CustomTooltip = ({ active, payload }: {
active?: boolean;
payload?: Array<{ name: string; value: number; payload: ChartDataPoint }>;
}) => {
if (active && payload && payload.length) {
const item = payload[0].payload;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground">{item.name}</p>
<p className="text-sm text-muted-foreground">
{formatter(item.value)}
</p>
</div>
);
}
return null;
};
const getBarColor = (value: number) => {
// For cost metrics, lower is better (green), higher is worse (red)
// For other metrics, higher is better
@@ -129,7 +133,7 @@ export function ComparisonBarChart({
axisLine={false}
interval={0}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<BarTooltip formatter={formatter} />} />
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}

View File

@@ -31,6 +31,30 @@ function getServiceColor(service: string): string {
return SERVICE_COLORS[normalized] || SERVICE_COLORS.default;
}
// Tooltip component defined outside main component
interface CostTooltipProps {
active?: boolean;
payload?: Array<{ payload: CostBreakdownType }>;
}
function CostTooltip({ active, payload }: CostTooltipProps) {
if (active && payload && payload.length) {
const item = payload[0].payload;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground">{item.service}</p>
<p className="text-sm text-muted-foreground">
Cost: {formatCurrency(item.cost_usd)}
</p>
<p className="text-sm text-muted-foreground">
Percentage: {item.percentage.toFixed(1)}%
</p>
</div>
);
}
return null;
}
export function CostBreakdownChart({
data,
title = 'Cost Breakdown',
@@ -54,51 +78,6 @@ export function CostBreakdownChart({
const totalCost = filteredData.reduce((sum, item) => sum + item.cost_usd, 0);
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: CostBreakdownType }> }) => {
if (active && payload && payload.length) {
const item = payload[0].payload;
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground">{item.service}</p>
<p className="text-sm text-muted-foreground">
Cost: {formatCurrency(item.cost_usd)}
</p>
<p className="text-sm text-muted-foreground">
Percentage: {item.percentage.toFixed(1)}%
</p>
</div>
);
}
return null;
};
const CustomLegend = () => {
return (
<div className="flex flex-wrap justify-center gap-4 mt-4">
{data.map((item) => {
const isHidden = hiddenServices.has(item.service);
return (
<button
key={item.service}
onClick={() => toggleService(item.service)}
className={`flex items-center gap-2 text-sm transition-opacity hover:opacity-80 ${
isHidden ? 'opacity-40' : 'opacity-100'
}`}
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: getServiceColor(item.service) }}
/>
<span className="text-muted-foreground">
{item.service} ({item.percentage.toFixed(1)}%)
</span>
</button>
);
})}
</div>
);
};
return (
<Card className="w-full">
<CardHeader className="pb-2">
@@ -133,11 +112,32 @@ export function CostBreakdownChart({
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CostTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<CustomLegend />
<div className="flex flex-wrap justify-center gap-4 mt-4">
{data.map((item) => {
const isHidden = hiddenServices.has(item.service);
return (
<button
key={item.service}
onClick={() => toggleService(item.service)}
className={`flex items-center gap-2 text-sm transition-opacity hover:opacity-80 ${
isHidden ? 'opacity-40' : 'opacity-100'
}`}
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: getServiceColor(item.service) }}
/>
<span className="text-muted-foreground">
{item.service} ({item.percentage.toFixed(1)}%)
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
);

View File

@@ -33,6 +33,48 @@ interface TimeSeriesChartProps {
chartType?: 'line' | 'area';
}
// Format timestamp for display
function formatXAxisLabel(timestamp: string): string {
try {
const date = new Date(timestamp);
return format(date, 'MMM dd HH:mm');
} catch {
return timestamp;
}
}
// Tooltip component defined outside main component
interface TimeTooltipProps {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
yAxisFormatter?: (value: number) => string;
}
function TimeTooltip({ active, payload, label, yAxisFormatter }: TimeTooltipProps) {
if (active && payload && payload.length && yAxisFormatter) {
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground mb-2">
{label ? formatXAxisLabel(label) : ''}
</p>
<div className="space-y-1">
{payload.map((entry: { name: string; value: number; color: string }) => (
<p key={entry.name} className="text-sm text-muted-foreground flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
{entry.name}: {yAxisFormatter(entry.value)}
</p>
))}
</div>
</div>
);
}
return null;
}
export function TimeSeriesChart({
data,
series,
@@ -41,42 +83,7 @@ export function TimeSeriesChart({
yAxisFormatter = formatNumber,
chartType = 'area',
}: TimeSeriesChartProps) {
const formatXAxis = (timestamp: string) => {
try {
const date = new Date(timestamp);
return format(date, 'MMM dd HH:mm');
} catch {
return timestamp;
}
};
const CustomTooltip = ({ active, payload, label }: {
active?: boolean;
payload?: Array<{ name: string; value: number; color: string }>;
label?: string;
}) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-popover p-3 shadow-md">
<p className="font-medium text-popover-foreground mb-2">
{label ? formatXAxis(label) : ''}
</p>
<div className="space-y-1">
{payload.map((entry) => (
<p key={entry.name} className="text-sm text-muted-foreground flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
{entry.name}: {yAxisFormatter(entry.value)}
</p>
))}
</div>
</div>
);
}
return null;
};
const formatXAxis = (timestamp: string) => formatXAxisLabel(timestamp);
const ChartComponent = chartType === 'area' ? AreaChart : LineChart;
@@ -132,7 +139,7 @@ export function TimeSeriesChart({
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<TimeTooltip yAxisFormatter={yAxisFormatter} />} />
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="circle"