feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
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):
- 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

View File

@@ -0,0 +1,268 @@
import { useState } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { ArrowLeft, Download, FileText } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useComparisonCache } from '@/hooks/useComparison';
import { ComparisonBarChart, GroupedComparisonChart } from '@/components/charts';
import { formatCurrency, formatNumber } from '@/components/charts/ChartContainer';
import { Skeleton } from '@/components/ui/skeleton';
interface LocationState {
scenarioIds: string[];
}
interface MetricRow {
key: string;
name: string;
isCurrency: boolean;
values: number[];
}
export function Compare() {
const location = useLocation();
const { scenarioIds } = (location.state as LocationState) || { scenarioIds: [] };
const [selectedMetric, setSelectedMetric] = useState<string>('total_cost');
const { data, isLoading, error } = useComparisonCache(scenarioIds);
if (!scenarioIds || scenarioIds.length < 2) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] space-y-4">
<p className="text-muted-foreground">Select 2-4 scenarios to compare</p>
<Link to="/scenarios">
<Button>Go to Scenarios</Button>
</Link>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-[400px]" />
<Skeleton className="h-[400px]" />
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] space-y-4">
<p className="text-destructive">Failed to load comparison</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
);
}
const scenarios = data?.scenarios || [];
// Prepare metric rows for table
const metricRows: MetricRow[] = [
{ key: 'total_cost', name: 'Total Cost', isCurrency: true, values: [] },
{ key: 'total_requests', name: 'Total Requests', isCurrency: false, values: [] },
{ key: 'sqs_blocks', name: 'SQS Blocks', isCurrency: false, values: [] },
{ key: 'lambda_invocations', name: 'Lambda Invocations', isCurrency: false, values: [] },
{ key: 'llm_tokens', name: 'LLM Tokens', isCurrency: false, values: [] },
{ key: 'pii_violations', name: 'PII Violations', isCurrency: false, values: [] },
];
metricRows.forEach((row) => {
row.values = scenarios.map((s) => {
const metric = s.summary[row.key as keyof typeof s.summary];
return typeof metric === 'number' ? metric : 0;
});
});
// Calculate deltas for each metric
const getDelta = (metric: MetricRow, index: number) => {
if (index === 0) return null;
const baseline = metric.values[0];
const current = metric.values[index];
const diff = current - baseline;
const percentage = baseline !== 0 ? (diff / baseline) * 100 : 0;
return { diff, percentage };
};
// Color coding: green for better, red for worse, gray for neutral
const getDeltaColor = (metric: MetricRow, delta: { diff: number; percentage: number }) => {
if (metric.key === 'total_cost' || metric.key === 'pii_violations') {
// Lower is better
return delta.diff < 0 ? 'text-green-500' : delta.diff > 0 ? 'text-red-500' : 'text-gray-500';
}
// Higher is better
return delta.diff > 0 ? 'text-green-500' : delta.diff < 0 ? 'text-red-500' : 'text-gray-500';
};
const metricOptions = [
{ key: 'total_cost', name: 'Total Cost', isCurrency: true },
{ key: 'total_requests', name: 'Total Requests', isCurrency: false },
{ key: 'sqs_blocks', name: 'SQS Blocks', isCurrency: false },
{ key: 'lambda_invocations', name: 'Lambda Invocations', isCurrency: false },
{ key: 'llm_tokens', name: 'LLM Tokens', isCurrency: false },
];
const currentMetric = metricOptions.find((m) => m.key === selectedMetric);
// Prepare data for bar chart
const chartScenarios = scenarios.map((s) => ({
scenario: s.scenario,
metrics: metricRows.map((m) => ({
key: m.key,
name: m.name,
value: s.summary[m.key as keyof typeof s.summary] as number || 0,
})),
}));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/scenarios">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">Scenario Comparison</h1>
<p className="text-muted-foreground">
Comparing {scenarios.length} scenarios
</p>
</div>
</div>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export Comparison
</Button>
</div>
{/* Scenario Cards */}
<div className={`grid gap-4 ${
scenarios.length <= 2 ? 'md:grid-cols-2' :
scenarios.length === 3 ? 'md:grid-cols-3' :
'md:grid-cols-4'
}`}>
{scenarios.map((s) => (
<Card key={s.scenario.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base truncate">{s.scenario.name}</CardTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{s.scenario.region}</span>
<Badge variant={s.scenario.status === 'running' ? 'default' : 'secondary'}>
{s.scenario.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(s.summary.total_cost_usd)}</p>
<p className="text-xs text-muted-foreground">
{formatNumber(s.summary.total_requests)} requests
</p>
</CardContent>
</Card>
))}
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Bar Chart */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Comparison Chart</CardTitle>
<select
value={selectedMetric}
onChange={(e) => setSelectedMetric(e.target.value)}
className="text-sm border rounded-md px-2 py-1 bg-background"
>
{metricOptions.map((opt) => (
<option key={opt.key} value={opt.key}>
{opt.name}
</option>
))}
</select>
</div>
</CardHeader>
<CardContent>
<ComparisonBarChart
scenarios={chartScenarios}
metricKey={selectedMetric}
title=""
description={currentMetric?.name}
isCurrency={currentMetric?.isCurrency}
/>
</CardContent>
</Card>
{/* Grouped Chart */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Multi-Metric Overview</CardTitle>
</CardHeader>
<CardContent>
<GroupedComparisonChart
scenarios={chartScenarios}
metricKeys={metricOptions.slice(0, 3)}
title=""
/>
</CardContent>
</Card>
</div>
{/* Comparison Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Detailed Comparison
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Metric</th>
{scenarios.map((s, i) => (
<th key={s.scenario.id} className="text-right py-3 px-4 font-medium">
{i === 0 && <span className="text-xs text-muted-foreground block">Baseline</span>}
<span className="truncate max-w-[150px] block">{s.scenario.name}</span>
</th>
))}
</tr>
</thead>
<tbody>
{metricRows.map((metric) => (
<tr key={metric.key} className="border-b last:border-0 hover:bg-muted/50">
<td className="py-3 px-4 font-medium">{metric.name}</td>
{metric.values.map((value, index) => {
const delta = getDelta(metric, index);
return (
<td key={index} className="py-3 px-4 text-right">
<div className="font-mono">
{metric.isCurrency ? formatCurrency(value) : formatNumber(value)}
</div>
{delta && (
<div className={`text-xs ${getDeltaColor(metric, delta)}`}>
{delta.percentage > 0 ? '+' : ''}
{delta.percentage.toFixed(1)}%
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}