feat: add smart flight tracking with AviationStack API + visual progress
- Add 20+ flight fields (terminal, gate, delays, estimated times, etc.) - Smart polling cron with budget-aware priority queue (100 req/month) - Tracking phases: FAR_OUT → PRE_DEPARTURE → ACTIVE → LANDED - Visual FlightProgressBar with animated airplane between airports - FlightCard with status dots, delay badges, expandable details - FlightList rewrite: card-based, grouped by status, search/filter - Dashboard: enriched flight status widget with compact progress bars - CommandCenter: flight alerts + enriched arrivals with gate/terminal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
210
frontend/src/components/FlightCard.tsx
Normal file
210
frontend/src/components/FlightCard.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plane,
|
||||
RefreshCw,
|
||||
Edit3,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Flight } from '@/types';
|
||||
import { FlightProgressBar } from './FlightProgressBar';
|
||||
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||
|
||||
interface FlightCardProps {
|
||||
flight: Flight;
|
||||
onEdit?: (flight: Flight) => void;
|
||||
onDelete?: (flight: Flight) => void;
|
||||
}
|
||||
|
||||
function getStatusDotColor(flight: Flight): string {
|
||||
const status = flight.status?.toLowerCase();
|
||||
const delay = flight.arrivalDelay || flight.departureDelay || 0;
|
||||
|
||||
if (status === 'cancelled') return 'bg-red-500';
|
||||
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
|
||||
if (status === 'landed') return 'bg-emerald-500';
|
||||
if (status === 'active') return delay > 15 ? 'bg-amber-500 animate-pulse' : 'bg-purple-500 animate-pulse';
|
||||
if (delay > 30) return 'bg-orange-500';
|
||||
if (delay > 15) return 'bg-amber-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
function getAlertBanner(flight: Flight): { message: string; color: string } | null {
|
||||
const status = flight.status?.toLowerCase();
|
||||
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
|
||||
|
||||
if (status === 'cancelled') return { message: 'FLIGHT CANCELLED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
|
||||
if (status === 'diverted') return { message: 'FLIGHT DIVERTED', color: 'bg-orange-500/10 border-orange-500/30 text-orange-700 dark:text-orange-400' };
|
||||
if (status === 'incident') return { message: 'INCIDENT REPORTED', color: 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400' };
|
||||
if (delay > 60) return { message: `DELAYED ${delay} MINUTES`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
|
||||
if (delay > 30) return { message: `Delayed ${delay} min`, color: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string | null): string {
|
||||
if (!isoString) return 'Never';
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const refreshMutation = useRefreshFlight();
|
||||
const alert = getAlertBanner(flight);
|
||||
const dotColor = getStatusDotColor(flight);
|
||||
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||
{/* Alert banner */}
|
||||
{alert && (
|
||||
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{alert.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Status dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||
|
||||
{/* Flight number + airline */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
||||
{flight.airlineName && (
|
||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VIP name */}
|
||||
{flight.vip && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="font-medium text-foreground/80">{flight.vip.name}</span>
|
||||
{flight.vip.partySize > 1 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Users className="w-3 h-3" />
|
||||
+{flight.vip.partySize - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => refreshMutation.mutate(flight.id)}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
title="Refresh from API"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(flight)}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Edit flight"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(flight)}
|
||||
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500"
|
||||
title="Delete flight"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4">
|
||||
<FlightProgressBar flight={flight} />
|
||||
</div>
|
||||
|
||||
{/* Footer - expandable details */}
|
||||
<div className="px-4 pb-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Updated {formatRelativeTime(flight.lastPolledAt)}
|
||||
</span>
|
||||
{flight.pollCount > 0 && (
|
||||
<span>{flight.pollCount} poll{flight.pollCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||
{flight.trackingPhase.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
|
||||
{/* Detailed times */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
|
||||
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
|
||||
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
|
||||
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
|
||||
)}
|
||||
{flight.departureTerminal && <div>Terminal: {flight.departureTerminal}</div>}
|
||||
{flight.departureGate && <div>Gate: {flight.departureGate}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||
<div className="space-y-0.5 text-muted-foreground">
|
||||
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
|
||||
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
|
||||
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
|
||||
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
|
||||
)}
|
||||
{flight.arrivalTerminal && <div>Terminal: {flight.arrivalTerminal}</div>}
|
||||
{flight.arrivalGate && <div>Gate: {flight.arrivalGate}</div>}
|
||||
{flight.arrivalBaggage && <div>Baggage: {flight.arrivalBaggage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aircraft info */}
|
||||
{flight.aircraftType && (
|
||||
<div className="text-muted-foreground">
|
||||
Aircraft: {flight.aircraftType}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
frontend/src/components/FlightProgressBar.tsx
Normal file
244
frontend/src/components/FlightProgressBar.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { Plane } from 'lucide-react';
|
||||
import { Flight } from '@/types';
|
||||
|
||||
interface FlightProgressBarProps {
|
||||
flight: Flight;
|
||||
compact?: boolean; // For mini version in dashboard/command center
|
||||
}
|
||||
|
||||
function calculateProgress(flight: Flight): number {
|
||||
const status = flight.status?.toLowerCase();
|
||||
|
||||
// Terminal states
|
||||
if (status === 'landed' || flight.actualArrival) return 100;
|
||||
if (status === 'cancelled' || status === 'diverted' || status === 'incident') return 0;
|
||||
|
||||
// Not departed yet
|
||||
if (status === 'scheduled' || (!flight.actualDeparture && !status?.includes('active'))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// In flight - calculate based on time elapsed
|
||||
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
||||
|
||||
if (!departureTime || !arrivalTime) return status === 'active' ? 50 : 0;
|
||||
|
||||
const now = Date.now();
|
||||
const dep = new Date(departureTime).getTime();
|
||||
const arr = new Date(arrivalTime).getTime();
|
||||
|
||||
if (now <= dep) return 0;
|
||||
if (now >= arr) return 95; // Past ETA but not confirmed landed
|
||||
|
||||
const totalDuration = arr - dep;
|
||||
const elapsed = now - dep;
|
||||
return Math.min(95, Math.max(5, Math.round((elapsed / totalDuration) * 100)));
|
||||
}
|
||||
|
||||
function getTrackColor(flight: Flight): string {
|
||||
const status = flight.status?.toLowerCase();
|
||||
const delay = flight.arrivalDelay || flight.departureDelay || 0;
|
||||
|
||||
if (status === 'cancelled') return 'bg-red-500';
|
||||
if (status === 'diverted' || status === 'incident') return 'bg-red-500';
|
||||
if (status === 'landed') return delay > 15 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
if (status === 'active') return delay > 15 ? 'bg-amber-500' : 'bg-purple-500';
|
||||
if (delay > 30) return 'bg-orange-500';
|
||||
if (delay > 15) return 'bg-amber-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
function getTrackBgColor(flight: Flight): string {
|
||||
const status = flight.status?.toLowerCase();
|
||||
if (status === 'cancelled') return 'bg-red-500/20';
|
||||
if (status === 'diverted' || status === 'incident') return 'bg-red-500/20';
|
||||
if (status === 'landed') return 'bg-emerald-500/20';
|
||||
if (status === 'active') return 'bg-purple-500/20';
|
||||
return 'bg-muted';
|
||||
}
|
||||
|
||||
function formatTime(isoString: string | null): string {
|
||||
if (!isoString) return '--:--';
|
||||
return new Date(isoString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||
const status = flight.status?.toLowerCase();
|
||||
const isActive = status === 'active';
|
||||
const isLanded = status === 'landed' || !!flight.actualArrival;
|
||||
const isCancelled = status === 'cancelled' || status === 'diverted' || status === 'incident';
|
||||
|
||||
// Update progress periodically for active flights
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setProgress(calculateProgress(flight));
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(calculateProgress(flight));
|
||||
const interval = setInterval(() => {
|
||||
setProgress(calculateProgress(flight));
|
||||
}, 30000); // Update every 30s for active flights
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [flight, isActive]);
|
||||
|
||||
const trackColor = useMemo(() => getTrackColor(flight), [flight]);
|
||||
const trackBgColor = useMemo(() => getTrackBgColor(flight), [flight]);
|
||||
|
||||
const departureTime = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||
const arrivalTime = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||
const hasDelay = (flight.departureDelay || 0) > 0 || (flight.arrivalDelay || 0) > 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-bold text-foreground">{flight.departureAirport}</span>
|
||||
<div className="flex-1 relative h-1.5 rounded-full overflow-hidden">
|
||||
<div className={`absolute inset-0 ${trackBgColor} rounded-full`} />
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${trackColor} rounded-full transition-all duration-1000`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
{isActive && (
|
||||
<Plane
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 text-purple-500 transition-all duration-1000"
|
||||
style={{ left: `calc(${progress}% - 6px)` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-bold text-foreground">{flight.arrivalAirport}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full py-2">
|
||||
{/* Airport codes and progress track */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Departure airport */}
|
||||
<div className="text-center min-w-[48px]">
|
||||
<div className="text-lg font-bold text-foreground">{flight.departureAirport}</div>
|
||||
</div>
|
||||
|
||||
{/* Progress track */}
|
||||
<div className="flex-1 relative">
|
||||
{/* Track background */}
|
||||
<div className={`h-2 rounded-full ${trackBgColor} relative overflow-visible`}>
|
||||
{/* Filled progress */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-full ${trackColor} transition-all duration-1000 ease-in-out`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
|
||||
{/* Departure dot */}
|
||||
<div className={`absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress > 0 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||
|
||||
{/* Arrival dot */}
|
||||
<div className={`absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-3 rounded-full border-2 border-background ${progress >= 100 ? trackColor : 'bg-muted-foreground/40'}`} />
|
||||
|
||||
{/* Airplane icon */}
|
||||
{!isCancelled && (
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 transition-all duration-1000 ease-in-out z-10"
|
||||
style={{ left: `${Math.max(2, Math.min(98, progress))}%`, transform: `translateX(-50%) translateY(-50%)` }}
|
||||
>
|
||||
<div className={`${isActive ? 'animate-bounce-subtle' : ''}`}>
|
||||
<Plane
|
||||
className={`w-5 h-5 ${
|
||||
isLanded ? 'text-emerald-500' :
|
||||
isActive ? 'text-purple-500' :
|
||||
'text-muted-foreground'
|
||||
} drop-shadow-sm`}
|
||||
style={{ transform: 'rotate(0deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancelled X */}
|
||||
{isCancelled && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-red-500 font-bold text-sm">
|
||||
✕
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrival airport */}
|
||||
<div className="text-center min-w-[48px]">
|
||||
<div className="text-lg font-bold text-foreground">{flight.arrivalAirport}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time and detail row */}
|
||||
<div className="flex justify-between mt-2 text-xs">
|
||||
{/* Departure details */}
|
||||
<div className="text-left">
|
||||
{departureTime && (
|
||||
<div className="flex items-center gap-1">
|
||||
{hasDelay && flight.scheduledDeparture && flight.scheduledDeparture !== departureTime ? (
|
||||
<>
|
||||
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledDeparture)}</span>
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(departureTime)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{formatTime(departureTime)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(flight.departureTerminal || flight.departureGate) && (
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{flight.departureTerminal && <span>T{flight.departureTerminal}</span>}
|
||||
{flight.departureTerminal && flight.departureGate && <span> </span>}
|
||||
{flight.departureGate && <span>Gate {flight.departureGate}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: flight duration or status */}
|
||||
<div className="text-center text-muted-foreground">
|
||||
{isActive && flight.aircraftType && (
|
||||
<span>{flight.aircraftType}</span>
|
||||
)}
|
||||
{isLanded && <span className="text-emerald-600 dark:text-emerald-400 font-medium">Landed</span>}
|
||||
{isCancelled && <span className="text-red-600 dark:text-red-400 font-medium capitalize">{status}</span>}
|
||||
</div>
|
||||
|
||||
{/* Arrival details */}
|
||||
<div className="text-right">
|
||||
{arrivalTime && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasDelay && flight.scheduledArrival && flight.scheduledArrival !== arrivalTime ? (
|
||||
<>
|
||||
<span className="line-through text-muted-foreground">{formatTime(flight.scheduledArrival)}</span>
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">{formatTime(arrivalTime)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{isLanded ? '' : 'ETA '}{formatTime(arrivalTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
||||
{flight.arrivalGate && <span> Gate {flight.arrivalGate}</span>}
|
||||
{flight.arrivalBaggage && <span> Bag {flight.arrivalBaggage}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user