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

@@ -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>
);
}

View 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">
&#x2715;
</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>
);
}