feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
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):
- Add ReportService with PDF/CSV generation (reportlab, pandas)
- Implement Report API endpoints (POST, GET, DELETE, download)
- Add ReportRepository and schemas
- Configure storage with auto-cleanup (30 days)
- Rate limiting: 10 downloads/minute
- Professional PDF templates with charts support

Frontend (@frontend-dev):
- Integrate Recharts for data visualization
- Add CostBreakdown, TimeSeries, ComparisonBar charts
- Implement scenario comparison page with multi-select
- Add dark/light mode toggle with ThemeProvider
- Create Reports page with generation form and list
- Add new UI components: checkbox, dialog, tabs, label, skeleton
- Implement useComparison and useReports hooks

QA (@qa-engineer):
- Setup Playwright E2E testing framework
- Create 7 test spec files with 94 test cases
- Add visual regression testing with baselines
- Configure multi-browser testing (Chrome, Firefox, WebKit)
- Add mobile responsive tests
- Create test fixtures and helpers
- Setup GitHub Actions CI workflow

Documentation (@spec-architect):
- Create detailed kanban-v0.4.0.md with 27 tasks
- Update progress.md with v0.4.0 tracking
- Create v0.4.0 planning prompt

Features:
 PDF/CSV Report Generation
 Interactive Charts (Pie, Area, Bar)
 Scenario Comparison (2-4 scenarios)
 Dark/Light Mode Toggle
 E2E Test Suite (94 tests)

Dependencies added:
- Backend: reportlab, pandas, slowapi
- Frontend: recharts, date-fns, @radix-ui/react-checkbox/dialog/tabs
- Testing: @playwright/test

27 tasks completed, 100% v0.4.0 implementation
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 16:11:47 +02:00
parent 311a576f40
commit a5fc85897b
63 changed files with 9218 additions and 246 deletions
@@ -0,0 +1,87 @@
import type { ReactNode } from 'react';
import {
ResponsiveContainer,
type ResponsiveContainerProps,
} from 'recharts';
import { cn } from '@/lib/utils';
interface ChartContainerProps extends Omit<ResponsiveContainerProps, 'children'> {
children: ReactNode;
className?: string;
title?: string;
description?: string;
}
export function ChartContainer({
children,
className,
title,
description,
...props
}: ChartContainerProps) {
return (
<div className={cn('w-full', className)}>
{(title || description) && (
<div className="mb-4">
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
)}
<div className="w-full overflow-hidden rounded-lg border bg-card p-4">
<ResponsiveContainer {...props}>
{children}
</ResponsiveContainer>
</div>
</div>
);
}
// Chart colors matching Tailwind/shadcn theme
export const CHART_COLORS = {
primary: 'hsl(var(--primary))',
secondary: 'hsl(var(--secondary))',
accent: 'hsl(var(--accent))',
muted: 'hsl(var(--muted))',
destructive: 'hsl(var(--destructive))',
// Service-specific colors
sqs: '#FF9900', // AWS Orange
lambda: '#F97316', // Orange-500
bedrock: '#8B5CF6', // Violet-500
// Additional chart colors
blue: '#3B82F6',
green: '#10B981',
yellow: '#F59E0B',
red: '#EF4444',
purple: '#8B5CF6',
pink: '#EC4899',
cyan: '#06B6D4',
};
// Chart color palette for multiple series
export const CHART_PALETTE = [
CHART_COLORS.sqs,
CHART_COLORS.lambda,
CHART_COLORS.bedrock,
CHART_COLORS.blue,
CHART_COLORS.green,
CHART_COLORS.purple,
CHART_COLORS.pink,
CHART_COLORS.cyan,
];
// Format currency for tooltips
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(value);
}
// Format number for tooltips
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
@@ -0,0 +1,249 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer';
import type { Scenario } from '@/types/api';
interface ComparisonMetric {
key: string;
name: string;
value: number;
}
interface ScenarioComparison {
scenario: Scenario;
metrics: ComparisonMetric[];
}
interface ComparisonBarChartProps {
scenarios: ScenarioComparison[];
metricKey: string;
title?: string;
description?: string;
isCurrency?: boolean;
}
interface ChartDataPoint {
name: string;
value: number;
color: string;
}
export function ComparisonBarChart({
scenarios,
metricKey,
title = 'Scenario Comparison',
description,
isCurrency = false,
}: ComparisonBarChartProps) {
const chartData: ChartDataPoint[] = scenarios.map((s, index) => ({
name: s.scenario.name,
value: s.metrics.find((m) => m.key === metricKey)?.value || 0,
color: CHART_PALETTE[index % CHART_PALETTE.length],
}));
const formatter = isCurrency ? formatCurrency : formatNumber;
// Find min/max for color coding
const values = chartData.map((d) => d.value);
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
if (metricKey.includes('cost')) {
if (value === minValue) return '#10B981'; // Green for lowest cost
if (value === maxValue) return '#EF4444'; // Red for highest cost
} else {
if (value === maxValue) return '#10B981'; // Green for highest value
if (value === minValue) return '#EF4444'; // Red for lowest value
}
return '#F59E0B'; // Yellow for middle values
};
return (
<Card className="w-full">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 60 }}
layout="vertical"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
horizontal={false}
/>
<XAxis
type="number"
tickFormatter={formatter}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={120}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
interval={0}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
animationDuration={800}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getBarColor(entry.value)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-4 mt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="h-3 w-3 rounded-full bg-green-500" />
Best
</span>
<span className="flex items-center gap-1">
<span className="h-3 w-3 rounded-full bg-yellow-500" />
Average
</span>
<span className="flex items-center gap-1">
<span className="h-3 w-3 rounded-full bg-red-500" />
Worst
</span>
</div>
</CardContent>
</Card>
);
}
// Horizontal grouped bar chart for multi-metric comparison
export function GroupedComparisonChart({
scenarios,
metricKeys,
title = 'Multi-Metric Comparison',
description,
}: {
scenarios: ScenarioComparison[];
metricKeys: Array<{ key: string; name: string; isCurrency?: boolean }>;
title?: string;
description?: string;
}) {
// Transform data for grouped bar chart
const chartData = scenarios.map((s) => {
const dataPoint: Record<string, string | number> = {
name: s.scenario.name,
};
metricKeys.forEach((mk) => {
const metric = s.metrics.find((m) => m.key === mk.key);
dataPoint[mk.key] = metric?.value || 0;
});
return dataPoint;
});
return (
<Card className="w-full">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
itemStyle={{ color: 'hsl(var(--popover-foreground))' }}
/>
<Legend wrapperStyle={{ paddingTop: '20px' }} />
{metricKeys.map((mk, index) => (
<Bar
key={mk.key}
dataKey={mk.key}
name={mk.name}
fill={CHART_PALETTE[index % CHART_PALETTE.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,144 @@
import { useState } from 'react';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { CostBreakdown as CostBreakdownType } from '@/types/api';
import { CHART_COLORS, formatCurrency } from './ChartContainer';
interface CostBreakdownChartProps {
data: CostBreakdownType[];
title?: string;
description?: string;
}
// Map services to colors
const SERVICE_COLORS: Record<string, string> = {
sqs: CHART_COLORS.sqs,
lambda: CHART_COLORS.lambda,
bedrock: CHART_COLORS.bedrock,
s3: CHART_COLORS.blue,
cloudwatch: CHART_COLORS.green,
default: CHART_COLORS.secondary,
};
function getServiceColor(service: string): string {
const normalized = service.toLowerCase().replace(/[^a-z]/g, '');
return SERVICE_COLORS[normalized] || SERVICE_COLORS.default;
}
export function CostBreakdownChart({
data,
title = 'Cost Breakdown',
description = 'Cost distribution by service',
}: CostBreakdownChartProps) {
const [hiddenServices, setHiddenServices] = useState<Set<string>>(new Set());
const filteredData = data.filter((item) => !hiddenServices.has(item.service));
const toggleService = (service: string) => {
setHiddenServices((prev) => {
const next = new Set(prev);
if (next.has(service)) {
next.delete(service);
} else {
next.add(service);
}
return next;
});
};
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">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<p className="text-2xl font-bold mt-2">{formatCurrency(totalCost)}</p>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={filteredData}
cx="50%"
cy="45%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="cost_usd"
nameKey="service"
animationBegin={0}
animationDuration={800}
>
{filteredData.map((entry) => (
<Cell
key={`cell-${entry.service}`}
fill={getServiceColor(entry.service)}
stroke="hsl(var(--card))"
strokeWidth={2}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<CustomLegend />
</CardContent>
</Card>
);
}
@@ -0,0 +1,227 @@
import {
AreaChart,
Area,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { format } from 'date-fns';
import { formatCurrency, formatNumber } from './ChartContainer';
interface TimeSeriesDataPoint {
timestamp: string;
[key: string]: string | number;
}
interface TimeSeriesChartProps {
data: TimeSeriesDataPoint[];
series: Array<{
key: string;
name: string;
color: string;
type?: 'line' | 'area';
}>;
title?: string;
description?: string;
yAxisFormatter?: (value: number) => string;
chartType?: 'line' | 'area';
}
export function TimeSeriesChart({
data,
series,
title = 'Metrics Over Time',
description,
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 ChartComponent = chartType === 'area' ? AreaChart : LineChart;
return (
<Card className="w-full">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</CardHeader>
<CardContent>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<ChartComponent
data={data}
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
>
<defs>
{series.map((s) => (
<linearGradient
key={s.key}
id={`gradient-${s.key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={s.color} stopOpacity={0.3} />
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
dataKey="timestamp"
tickFormatter={formatXAxis}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={30}
/>
<YAxis
tickFormatter={yAxisFormatter}
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="circle"
/>
{series.map((s) =>
chartType === 'area' ? (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color}
fill={`url(#gradient-${s.key})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
) : (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={s.color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
)
)}
</ChartComponent>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
// Pre-configured chart for cost metrics
export function CostTimeSeriesChart({
data,
title = 'Cost Over Time',
description = 'Cumulative costs by service',
}: {
data: TimeSeriesDataPoint[];
title?: string;
description?: string;
}) {
const series = [
{ key: 'sqs_cost', name: 'SQS', color: '#FF9900', type: 'area' as const },
{ key: 'lambda_cost', name: 'Lambda', color: '#F97316', type: 'area' as const },
{ key: 'bedrock_cost', name: 'Bedrock', color: '#8B5CF6', type: 'area' as const },
];
return (
<TimeSeriesChart
data={data}
series={series}
title={title}
description={description}
yAxisFormatter={formatCurrency}
chartType="area"
/>
);
}
// Pre-configured chart for request metrics
export function RequestTimeSeriesChart({
data,
title = 'Requests Over Time',
description = 'Request volume trends',
}: {
data: TimeSeriesDataPoint[];
title?: string;
description?: string;
}) {
const series = [
{ key: 'requests', name: 'Requests', color: '#3B82F6', type: 'line' as const },
{ key: 'errors', name: 'Errors', color: '#EF4444', type: 'line' as const },
];
return (
<TimeSeriesChart
data={data}
series={series}
title={title}
description={description}
yAxisFormatter={formatNumber}
chartType="line"
/>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { ChartContainer, CHART_COLORS, CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer';
export { CostBreakdownChart } from './CostBreakdown';
export { TimeSeriesChart, CostTimeSeriesChart, RequestTimeSeriesChart } from './TimeSeries';
export { ComparisonBarChart, GroupedComparisonChart } from './ComparisonBar';