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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user