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:
2026-02-07 19:42:52 +01:00
parent 74a292ea93
commit 0f0f1cbf38
13 changed files with 1688 additions and 325 deletions

View File

@@ -80,8 +80,17 @@ interface VIP {
id: string;
flightNumber: string;
arrivalAirport: string;
departureAirport: string;
scheduledArrival: string | null;
estimatedArrival: string | null;
actualArrival: string | null;
arrivalDelay: number | null;
departureDelay: number | null;
arrivalTerminal: string | null;
arrivalGate: string | null;
arrivalBaggage: string | null;
status: string | null;
airlineName: string | null;
}>;
}
@@ -184,6 +193,14 @@ export function CommandCenter() {
},
});
const { data: flights } = useQuery<VIP['flights']>({
queryKey: ['flights'],
queryFn: async () => {
const { data } = await api.get('/flights');
return data;
},
});
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
@@ -312,7 +329,10 @@ export function CommandCenter() {
return start <= fifteenMinutes;
});
// Upcoming arrivals (next 4 hours)
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
const getFlightArrivalTime = (flight: VIP['flights'][0]) =>
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
const upcomingArrivals = vips
.filter((vip) => {
if (vip.expectedArrival) {
@@ -320,16 +340,17 @@ export function CommandCenter() {
return arrival > now && arrival <= fourHoursLater;
}
return vip.flights.some((flight) => {
if (flight.scheduledArrival) {
const arrival = new Date(flight.scheduledArrival);
const arrTime = getFlightArrivalTime(flight);
if (arrTime && flight.status?.toLowerCase() !== 'cancelled') {
const arrival = new Date(arrTime);
return arrival > now && arrival <= fourHoursLater;
}
return false;
});
})
.sort((a, b) => {
const aTime = a.expectedArrival || a.flights[0]?.scheduledArrival || '';
const bTime = b.expectedArrival || b.flights[0]?.scheduledArrival || '';
const aTime = a.expectedArrival || getFlightArrivalTime(a.flights[0]) || '';
const bTime = b.expectedArrival || getFlightArrivalTime(b.flights[0]) || '';
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
@@ -414,6 +435,45 @@ export function CommandCenter() {
});
}
// Flight alerts: cancelled, diverted, or significantly delayed
if (flights) {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(todayStart);
todayEnd.setDate(todayEnd.getDate() + 1);
flights.forEach((flight: any) => {
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
if (!arrivalTime) return;
const arrDate = new Date(arrivalTime);
if (arrDate < todayStart || arrDate > todayEnd) return;
const status = flight.status?.toLowerCase();
const vipName = flight.vip?.name || 'Unknown VIP';
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
if (status === 'cancelled') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT CANCELLED`,
link: '/flights',
});
} else if (status === 'diverted') {
alerts.push({
type: 'critical',
message: `${flight.flightNumber} (${vipName}): FLIGHT DIVERTED`,
link: '/flights',
});
} else if (delay > 30) {
alerts.push({
type: 'warning',
message: `${flight.flightNumber} (${vipName}): Delayed ${delay} minutes`,
link: '/flights',
});
}
});
}
// Get time until event
function getTimeUntil(dateStr: string) {
const eventTime = new Date(dateStr);
@@ -827,9 +887,9 @@ export function CommandCenter() {
</div>
{/* Bottom Row: Resources */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{/* VIP Arrivals */}
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden col-span-2">
<div className="bg-purple-600 px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Plane className="h-4 w-4 text-white" />
@@ -841,7 +901,7 @@ export function CommandCenter() {
ref={arrivalsScrollRef}
onWheel={handleUserInteraction}
onTouchMove={handleUserInteraction}
className="max-h-[140px] overflow-y-auto scrollbar-hide"
className="max-h-[180px] overflow-y-auto scrollbar-hide"
>
{upcomingArrivals.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
@@ -850,16 +910,79 @@ export function CommandCenter() {
) : (
<div className="divide-y divide-border">
{upcomingArrivals.map((vip) => {
const arrival = vip.expectedArrival || vip.flights[0]?.scheduledArrival;
const flight = vip.flights[0];
const flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
const flightStatus = flight?.status?.toLowerCase();
const isCancelled = flightStatus === 'cancelled';
const isActive = flightStatus === 'active';
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
// Color-code: green (on time / landed), amber (delayed), red (cancelled), purple (in flight)
const timeColor = isCancelled
? 'text-red-600 dark:text-red-400'
: isLanded
? 'text-emerald-600 dark:text-emerald-400'
: delay > 15
? 'text-amber-600 dark:text-amber-400'
: isActive
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400';
const borderColor = isCancelled
? 'border-l-red-500'
: delay > 30
? 'border-l-amber-500'
: isActive
? 'border-l-purple-500'
: isLanded
? 'border-l-emerald-500'
: 'border-l-blue-500';
return (
<div key={vip.id} className="px-3 py-2">
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{flight && <p className="text-[10px] text-muted-foreground">{flight.flightNumber}</p>}
<div className="flex items-center gap-1.5">
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
{delay > 15 && (
<span className="flex items-center gap-0.5 px-1 py-0 text-[10px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<AlertTriangle className="w-2.5 h-2.5" />
+{delay}m
</span>
)}
{isCancelled && (
<span className="px-1 py-0 text-[10px] rounded bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
CANCELLED
</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
{flight && (
<>
<span className="font-medium">{flight.flightNumber}</span>
<span>{flight.departureAirport} {flight.arrivalAirport}</span>
</>
)}
</div>
{flight && (flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
<div className="flex gap-2 text-[10px] 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 className="text-right flex-shrink-0">
<p className={`text-xs font-bold ${timeColor}`}>
{isCancelled ? '---' : isLanded ? 'Landed' : arrival ? getTimeUntil(arrival) : '--'}
</p>
{arrival && !isCancelled && !isLanded && (
<p className="text-[10px] text-muted-foreground">
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
</p>
)}
</div>
<p className="text-xs font-bold text-purple-600 dark:text-purple-400">{getTimeUntil(arrival!)}</p>
</div>
</div>
);