Add flight comparator web app with full scan pipeline

Full-stack flight price scanner built on fast-flights v3 (SOCS cookie bypass):

Backend (FastAPI + SQLite):
- REST API with rate limiting, Pydantic v2 validation, paginated responses
- Scan pipeline: resolves airports, queries every day in the window, saves
  individual flights + aggregate route stats to SQLite
- Background async scan processor with real-time progress tracking
- Airport search endpoint backed by OpenFlights dataset
- Daily scan window (all dates, not monthly samples)

Frontend (React 19 + TypeScript + Tailwind CSS v4):
- Dashboard with live scan status and recent scans
- Create scan form: country mode or specific airports (searchable dropdown)
- Scan detail page with expandable route rows showing individual flights
  (date, airline, departure, arrival, price) loaded on demand
- AirportSearch component with debounced live search and multi-select

Database:
- scans → routes → flights schema with FK cascade and auto-update triggers
- Migrations for schema evolution (relaxed country constraint)

Tests:
- 74 tests: unit + integration, isolated per-test SQLite DB
- Confirmed flight fixtures in tests/confirmed_flights.json (50 real flights,
  BDS→FMM Ryanair + BDS→DUS Eurowings, scraped Feb 2026)
- Integration tests parametrized from confirmed routes

Docker:
- Multi-stage builds, Compose orchestration, Nginx reverse proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:11:51 +01:00
parent aea7590874
commit 6421f83ca7
67 changed files with 37173 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { scanApi } from '../api';
import type { Scan } from '../api';
export default function Dashboard() {
const [scans, setScans] = useState<Scan[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
total: 0,
pending: 0,
running: 0,
completed: 0,
failed: 0,
});
useEffect(() => {
loadScans();
}, []);
const loadScans = async () => {
try {
setLoading(true);
const response = await scanApi.list(1, 10);
const scanList = response.data.data;
setScans(scanList);
// Calculate stats
setStats({
total: response.data.pagination.total,
pending: scanList.filter(s => s.status === 'pending').length,
running: scanList.filter(s => s.status === 'running').length,
completed: scanList.filter(s => s.status === 'completed').length,
failed: scanList.filter(s => s.status === 'failed').length,
});
} catch (error) {
console.error('Failed to load scans:', error);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
case 'running':
return 'bg-blue-100 text-blue-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'failed':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
<Link
to="/scans"
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium"
>
+ New Scan
</Link>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Total Scans</div>
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Pending</div>
<div className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Running</div>
<div className="text-3xl font-bold text-blue-600 mt-2">{stats.running}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Completed</div>
<div className="text-3xl font-bold text-green-600 mt-2">{stats.completed}</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="text-sm font-medium text-gray-500">Failed</div>
<div className="text-3xl font-bold text-red-600 mt-2">{stats.failed}</div>
</div>
</div>
{/* Recent Scans */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Recent Scans</h3>
</div>
<div className="divide-y divide-gray-200">
{scans.length === 0 ? (
<div className="px-6 py-12 text-center text-gray-500">
No scans yet. Create your first scan to get started!
</div>
) : (
scans.map((scan) => (
<Link
key={scan.id}
to={`/scans/${scan.id}`}
className="block px-6 py-4 hover:bg-gray-50 cursor-pointer"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<span className="font-medium text-gray-900">
{scan.origin} {scan.country}
</span>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(
scan.status
)}`}
>
{scan.status}
</span>
</div>
<div className="mt-1 text-sm text-gray-500">
{scan.start_date} to {scan.end_date} {scan.adults} adult(s) {scan.seat_class}
</div>
{scan.total_routes > 0 && (
<div className="mt-1 text-sm text-gray-500">
{scan.total_routes} routes {scan.total_flights} flights found
</div>
)}
</div>
<div className="text-sm text-gray-500">
{formatDate(scan.created_at)}
</div>
</div>
</Link>
))
)}
</div>
</div>
</div>
);
}