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,145 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
},
});
// Types
export interface Scan {
id: number;
origin: string;
country: string;
start_date: string;
end_date: string;
status: 'pending' | 'running' | 'completed' | 'failed';
created_at: string;
updated_at: string;
total_routes: number;
routes_scanned: number;
total_flights: number;
error_message?: string;
seat_class: string;
adults: number;
}
export interface Route {
id: number;
scan_id: number;
destination: string;
destination_name: string;
destination_city?: string;
flight_count: number;
airlines: string[];
min_price?: number;
max_price?: number;
avg_price?: number;
created_at: string;
}
export interface Flight {
id: number;
scan_id: number;
destination: string;
date: string;
airline?: string;
departure_time?: string;
arrival_time?: string;
price?: number;
stops: number;
}
export interface Airport {
iata: string;
name: string;
city: string;
country: string;
}
export interface LogEntry {
timestamp: string;
level: string;
message: string;
module?: string;
function?: string;
line?: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface CreateScanRequest {
origin: string;
country?: string; // Optional: provide either country or destinations
destinations?: string[]; // Optional: provide either country or destinations
start_date?: string;
end_date?: string;
window_months?: number;
seat_class?: 'economy' | 'premium' | 'business' | 'first';
adults?: number;
}
export interface CreateScanResponse {
status: string;
id: number;
scan: Scan;
}
// API functions
export const scanApi = {
list: (page = 1, limit = 20, status?: string) => {
const params: any = { page, limit };
if (status) params.status = status;
return api.get<PaginatedResponse<Scan>>('/scans', { params });
},
get: (id: number) => {
return api.get<Scan>(`/scans/${id}`);
},
create: (data: CreateScanRequest) => {
return api.post<CreateScanResponse>('/scans', data);
},
getRoutes: (id: number, page = 1, limit = 20) => {
return api.get<PaginatedResponse<Route>>(`/scans/${id}/routes`, {
params: { page, limit }
});
},
getFlights: (id: number, destination?: string, page = 1, limit = 50) => {
const params: Record<string, unknown> = { page, limit };
if (destination) params.destination = destination;
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
},
};
export const airportApi = {
search: (query: string, page = 1, limit = 20) => {
return api.get<PaginatedResponse<Airport>>('/airports', {
params: { q: query, page, limit }
});
},
};
export const logApi = {
list: (page = 1, limit = 50, level?: string, search?: string) => {
const params: any = { page, limit };
if (level) params.level = level;
if (search) params.search = search;
return api.get<PaginatedResponse<LogEntry>>('/logs', { params });
},
};
export default api;