- {logoUrl && !logoError ? (
+ {logoUrl ? (
diff --git a/src/components/ui/map-attribution.tsx b/src/components/ui/map-attribution.tsx
index 42e8f7f..d28f0bb 100644
--- a/src/components/ui/map-attribution.tsx
+++ b/src/components/ui/map-attribution.tsx
@@ -18,8 +18,17 @@ export function MapAttribution({ styleId }: MapAttributionProps) {
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
+ // Expand by default on larger screens (after mount to avoid hydration mismatch)
useEffect(() => {
- setExpanded(window.innerWidth >= SM_BREAKPOINT);
+ const mq = window.matchMedia(`(min-width: ${SM_BREAKPOINT}px)`);
+ const sync = () => setExpanded(mq.matches);
+ const raf = window.requestAnimationFrame(sync);
+
+ mq.addEventListener("change", sync);
+ return () => {
+ window.cancelAnimationFrame(raf);
+ mq.removeEventListener("change", sync);
+ };
}, []);
// Close on outside click for small screens
diff --git a/src/components/ui/status-bar.tsx b/src/components/ui/status-bar.tsx
index a72ebab..4e45e2f 100644
--- a/src/components/ui/status-bar.tsx
+++ b/src/components/ui/status-bar.tsx
@@ -1,7 +1,7 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
-import { Compass, Dices, Plane, Radio, ShieldAlert } from "lucide-react";
+import { Dices, Plane, Radio, ShieldAlert } from "lucide-react";
type StatusBarProps = {
flightCount: number;
@@ -138,7 +138,14 @@ export function StatusBar({
className="text-[11px] font-medium tracking-wide transition-colors"
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
>
-
+
();
+
+// Global backoff for /tracks 429s.
+let globalNextAllowedAt = 0;
+let globalBackoffMs = 5 * 60_000;
+const GLOBAL_BACKOFF_MAX_MS = 24 * 60 * 60_000;
+const GLOBAL_BACKOFF_KEY = "aeris:opensky:tracksGlobalNextAllowedAt";
+const GLOBAL_BACKOFF_MS_KEY = "aeris:opensky:tracksGlobalBackoffMs";
+const SELECTION_DEBOUNCE_MS = 350;
+
+function loadGlobalBackoff(): void {
+ if (typeof window === "undefined") return;
+ try {
+ const nextAllowedRaw = sessionStorage.getItem(GLOBAL_BACKOFF_KEY);
+ const nextAllowed = nextAllowedRaw ? Number.parseInt(nextAllowedRaw, 10) : 0;
+ if (Number.isFinite(nextAllowed) && nextAllowed > 0) {
+ globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowed);
+ }
+
+ const backoffRaw = sessionStorage.getItem(GLOBAL_BACKOFF_MS_KEY);
+ const backoff = backoffRaw ? Number.parseInt(backoffRaw, 10) : 0;
+ if (Number.isFinite(backoff) && backoff > 0) {
+ globalBackoffMs = Math.min(GLOBAL_BACKOFF_MAX_MS, Math.max(60_000, backoff));
+ }
+ } catch {
+ // ignore
+ }
+}
+
+function persistGlobalBackoff(): void {
+ if (typeof window === "undefined") return;
+ try {
+ sessionStorage.setItem(GLOBAL_BACKOFF_KEY, String(globalNextAllowedAt));
+ sessionStorage.setItem(GLOBAL_BACKOFF_MS_KEY, String(globalBackoffMs));
+ } catch {
+ // ignore
+ }
+}
+
+function cacheTtlMs(track: FlightTrack | null): number {
+ return track ? TRACK_CACHE_TTL_MS_EFFECTIVE : NEGATIVE_CACHE_TTL_MS_EFFECTIVE;
+}
+
+export function useFlightTrack(
+ icao24: string | null,
+ options?: {
+ refreshMs?: number;
+ enabled?: boolean;
+ },
+): { track: FlightTrack | null; loading: boolean; fetchedAtMs: number } {
+ const refreshMs = options?.refreshMs ?? DEFAULT_REFRESH_MS;
+ const enabled = options?.enabled ?? true;
+
+ const [track, setTrack] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [fetchedAtMs, setFetchedAtMs] = useState(0);
+
+ const requestIdRef = useRef(0);
+ const activeKeyRef = useRef(null);
+
+ useEffect(() => {
+ loadGlobalBackoff();
+
+ if (!icao24) {
+ setTrack(null);
+ setLoading(false);
+ setFetchedAtMs(0);
+ activeKeyRef.current = null;
+ return;
+ }
+
+ const key = icao24.trim().toLowerCase();
+ const isKeyChange = activeKeyRef.current !== key;
+ activeKeyRef.current = key;
+
+ const cached = trackCache.get(key);
+ const hasCachedTrack = cached?.track != null;
+
+ // Stale-while-revalidate: keep cached track visible.
+ if (hasCachedTrack) {
+ setTrack(cached!.track);
+ setFetchedAtMs(cached!.fetchedAt);
+ } else if (isKeyChange) {
+ setTrack(null);
+ setFetchedAtMs(0);
+ }
+
+ if (!enabled) {
+ setLoading(false);
+ return;
+ }
+
+ let alive = true;
+ const controller = new AbortController();
+
+ async function load() {
+ const now = Date.now();
+
+ if (now < globalNextAllowedAt) {
+ return;
+ }
+
+ const existing = trackCache.get(key);
+ if (existing && now < existing.nextAllowedAt) {
+ return;
+ }
+
+ if (existing && now - existing.fetchedAt <= cacheTtlMs(existing.track)) {
+ return;
+ }
+
+ const requestId = ++requestIdRef.current;
+ setLoading(true);
+ try {
+ const result = await fetchTrackByIcao24(key, 0, controller.signal);
+ if (!alive || requestId !== requestIdRef.current) return;
+
+ const fetchedAt = Date.now();
+ const retryAfterSeconds =
+ typeof result.retryAfterSeconds === "number" &&
+ Number.isFinite(result.retryAfterSeconds)
+ ? result.retryAfterSeconds
+ : null;
+
+ const rateLimitedBackoffMs =
+ retryAfterSeconds && retryAfterSeconds > 0
+ ? Math.max(1, retryAfterSeconds) * 1000
+ : globalBackoffMs;
+
+ const nextAllowedAt = result.rateLimited
+ ? fetchedAt + rateLimitedBackoffMs
+ : fetchedAt;
+
+ if (result.rateLimited) {
+ globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowedAt);
+ globalBackoffMs = Math.min(
+ GLOBAL_BACKOFF_MAX_MS,
+ Math.max(60_000, Math.floor(globalBackoffMs * 1.6)),
+ );
+ persistGlobalBackoff();
+ }
+
+ const existing = trackCache.get(key)?.track ?? null;
+ const nextTrack = result.track ?? existing;
+
+ trackCache.set(key, {
+ fetchedAt,
+ nextAllowedAt,
+ track: nextTrack,
+ });
+
+ setFetchedAtMs(fetchedAt);
+
+ setTrack(nextTrack);
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") {
+ return;
+ }
+ if (process.env.NODE_ENV !== "production") {
+ console.error("useFlightTrack: failed to fetch track", err);
+ }
+
+ return;
+ } finally {
+ if (alive && requestId === requestIdRef.current) {
+ setLoading(false);
+ }
+ }
+ }
+
+ const debounceMs = isKeyChange ? SELECTION_DEBOUNCE_MS : 0;
+ const loadTimer = window.setTimeout(() => {
+ void load();
+ }, debounceMs);
+
+ let interval: number | null = null;
+ if (refreshMs > 0) {
+ interval = window.setInterval(() => {
+ void load();
+ }, refreshMs);
+ }
+
+ return () => {
+ alive = false;
+ controller.abort();
+ window.clearTimeout(loadTimer);
+ if (interval !== null) window.clearInterval(interval);
+ setLoading(false);
+ };
+ }, [icao24, refreshMs, enabled]);
+
+ return { track, loading, fetchedAtMs };
+}
diff --git a/src/hooks/use-flights.ts b/src/hooks/use-flights.ts
index 61f0d86..e55df76 100644
--- a/src/hooks/use-flights.ts
+++ b/src/hooks/use-flights.ts
@@ -110,6 +110,10 @@ export function useFlights(
const scheduleNext = useCallback(
(target: City, delayMs: number) => {
clearSchedule();
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") {
+ return;
+ }
+
timerRef.current = setTimeout(() => {
fetchDataRef.current(target);
}, delayMs);
@@ -220,7 +224,12 @@ export function useFlights(
const activeCity = city;
function onVisibilityChange() {
- if (document.visibilityState !== "visible") return;
+ if (document.visibilityState !== "visible") {
+ // Fully pause polling while hidden.
+ clearSchedule();
+ abortRef.current?.abort();
+ return;
+ }
const elapsed = Date.now() - lastFetchRef.current;
diff --git a/src/hooks/use-trail-history.ts b/src/hooks/use-trail-history.ts
index e424198..4ebafad 100644
--- a/src/hooks/use-trail-history.ts
+++ b/src/hooks/use-trail-history.ts
@@ -15,6 +15,7 @@ export type TrailEntry = {
path: Position[];
altitudes: Array;
baroAltitude: number | null;
+ fullHistory?: boolean;
};
const MAX_POINTS = 40;
diff --git a/src/lib/cities.ts b/src/lib/cities.ts
index fa16e4f..0b62ab0 100644
--- a/src/lib/cities.ts
+++ b/src/lib/cities.ts
@@ -14,7 +14,7 @@ export const CITIES: City[] = [
country: "US",
iata: "JFK",
coordinates: [-73.7781, 40.6413],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "lax",
@@ -22,7 +22,7 @@ export const CITIES: City[] = [
country: "US",
iata: "LAX",
coordinates: [-118.4085, 33.9416],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "lhr",
@@ -30,7 +30,7 @@ export const CITIES: City[] = [
country: "GB",
iata: "LHR",
coordinates: [-0.4614, 51.47],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "dxb",
@@ -38,7 +38,7 @@ export const CITIES: City[] = [
country: "AE",
iata: "DXB",
coordinates: [55.3644, 25.2532],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "nrt",
@@ -46,7 +46,7 @@ export const CITIES: City[] = [
country: "JP",
iata: "NRT",
coordinates: [140.3929, 35.772],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "sin",
@@ -54,7 +54,7 @@ export const CITIES: City[] = [
country: "SG",
iata: "SIN",
coordinates: [103.9915, 1.3644],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "cdg",
@@ -62,7 +62,7 @@ export const CITIES: City[] = [
country: "FR",
iata: "CDG",
coordinates: [2.5479, 49.0097],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "sfo",
@@ -70,7 +70,7 @@ export const CITIES: City[] = [
country: "US",
iata: "SFO",
coordinates: [-122.379, 37.6213],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "ord",
@@ -78,7 +78,7 @@ export const CITIES: City[] = [
country: "US",
iata: "ORD",
coordinates: [-87.9073, 41.9742],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "fra",
@@ -86,7 +86,7 @@ export const CITIES: City[] = [
country: "DE",
iata: "FRA",
coordinates: [8.5622, 50.0379],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "bom",
@@ -94,7 +94,7 @@ export const CITIES: City[] = [
country: "IN",
iata: "BOM",
coordinates: [72.8679, 19.0896],
- radius: 2.5,
+ radius: 2.49,
},
{
id: "mia",
@@ -102,6 +102,6 @@ export const CITIES: City[] = [
country: "US",
iata: "MIA",
coordinates: [-80.2906, 25.7959],
- radius: 2.5,
+ radius: 2.49,
},
];
diff --git a/src/lib/geo.ts b/src/lib/geo.ts
new file mode 100644
index 0000000..fcf1e72
--- /dev/null
+++ b/src/lib/geo.ts
@@ -0,0 +1,23 @@
+export function snapLngToReference(lng: number, refLng: number): number {
+ if (!Number.isFinite(lng) || !Number.isFinite(refLng)) return lng;
+ let x = lng;
+ while (x - refLng > 180) x -= 360;
+ while (x - refLng < -180) x += 360;
+ return x;
+}
+
+export function unwrapLngPath(
+ path: Array<[lng: number, lat: number]>,
+): Array<[lng: number, lat: number]> {
+ if (path.length < 2) return path.slice();
+ const [firstLng, firstLat] = path[0];
+ const out: Array<[number, number]> = [[firstLng, firstLat]];
+ let refLng = firstLng;
+ for (let i = 1; i < path.length; i++) {
+ const [lng, lat] = path[i];
+ const nextLng = snapLngToReference(lng, refLng);
+ out.push([nextLng, lat]);
+ refLng = nextLng;
+ }
+ return out;
+}
diff --git a/src/lib/logo-cache.ts b/src/lib/logo-cache.ts
new file mode 100644
index 0000000..0a059a0
--- /dev/null
+++ b/src/lib/logo-cache.ts
@@ -0,0 +1,41 @@
+export const loadedAirlineLogoUrls = new Set();
+
+const FAILED_TTL_MS = 10 * 60_000;
+const MAX_FAILED_ENTRIES = 500;
+const failedAirlineLogoTimestamps = new Map();
+
+export function wasAirlineLogoRecentlyFailed(url: string): boolean {
+ if (!url) return false;
+ const ts = failedAirlineLogoTimestamps.get(url);
+ if (ts === undefined) return false;
+ if (Date.now() - ts > FAILED_TTL_MS) {
+ failedAirlineLogoTimestamps.delete(url);
+ return false;
+ }
+ return true;
+}
+
+export function markAirlineLogoFailed(url: string): void {
+ if (!url) return;
+ const now = Date.now();
+ failedAirlineLogoTimestamps.set(url, now);
+
+ // Opportunistically prune expired entries so the cache doesn't skew toward old URLs.
+ for (const [key, ts] of failedAirlineLogoTimestamps) {
+ if (now - ts > FAILED_TTL_MS) {
+ failedAirlineLogoTimestamps.delete(key);
+ }
+ }
+
+ if (failedAirlineLogoTimestamps.size <= MAX_FAILED_ENTRIES) return;
+
+ let oldestUrl: string | null = null;
+ let oldestTs = Number.POSITIVE_INFINITY;
+ for (const [key, ts] of failedAirlineLogoTimestamps) {
+ if (ts < oldestTs) {
+ oldestTs = ts;
+ oldestUrl = key;
+ }
+ }
+ if (oldestUrl) failedAirlineLogoTimestamps.delete(oldestUrl);
+}
diff --git a/src/lib/opensky.ts b/src/lib/opensky.ts
index 84a161d..5475a24 100644
--- a/src/lib/opensky.ts
+++ b/src/lib/opensky.ts
@@ -3,7 +3,12 @@
const OPENSKY_API = "https://opensky-network.org/api";
const FETCH_TIMEOUT_MS = 15_000;
const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
-const CALLSIGN_CACHE_TTL_MS = 30_000;
+// Callsign lookup scans global /states/all (4 credits); cache longer to reduce spikes.
+const CALLSIGN_CACHE_TTL_MS = 2 * 60_000;
+const CALLSIGN_CACHE_MAX_ENTRIES = 200;
+
+// Keep bbox queries inside OpenSky's 0–25 sq-deg (1 credit) tier.
+const MAX_1_CREDIT_RADIUS_DEG = 2.49;
export type FlightState = {
icao24: string;
@@ -207,7 +212,16 @@ export function bboxFromCenter(
lat: number,
radiusDeg: number,
): [lamin: number, lamax: number, lomin: number, lomax: number] {
- return [lat - radiusDeg, lat + radiusDeg, lng - radiusDeg, lng + radiusDeg];
+ // If callers pass a bogus radius, fall back to a safe 1-credit value.
+ const safeRadiusRaw =
+ Number.isFinite(radiusDeg) && radiusDeg > 0 ? radiusDeg : MAX_1_CREDIT_RADIUS_DEG;
+ const safeRadius = Math.min(safeRadiusRaw, MAX_1_CREDIT_RADIUS_DEG);
+ return [
+ lat - safeRadius,
+ lat + safeRadius,
+ lng - safeRadius,
+ lng + safeRadius,
+ ];
}
/**
@@ -361,6 +375,10 @@ export async function fetchFlightByCallsign(
timestamp: Date.now(),
result,
});
+ if (callsignLookupCache.size > CALLSIGN_CACHE_MAX_ENTRIES) {
+ const oldestKey = callsignLookupCache.keys().next().value as string | undefined;
+ if (oldestKey) callsignLookupCache.delete(oldestKey);
+ }
return result;
} catch (err) {
@@ -457,3 +475,258 @@ export async function fetchFlightsByRoute(
retryAfterSeconds,
};
}
+
+export type TrackWaypoint = {
+ time: number;
+ latitude: number | null;
+ longitude: number | null;
+ baroAltitude: number | null;
+ trueTrack: number | null;
+ onGround: boolean;
+};
+
+export type FlightTrack = {
+ icao24: string;
+ startTime: number;
+ endTime: number;
+ callsign: string | null;
+ path: TrackWaypoint[];
+};
+
+export type TrackFetchResult = {
+ track: FlightTrack | null;
+ rateLimited: boolean;
+ creditsRemaining: number | null;
+ retryAfterSeconds: number | null;
+};
+
+type OpenSkyTrackResponse = {
+ icao24?: unknown;
+ startTime?: unknown;
+ endTime?: unknown;
+ callsign?: unknown;
+ // Defensive: accept a misspelled field name if present.
+ calllsign?: unknown;
+ path?: unknown;
+};
+
+function parseTrackWaypoint(raw: unknown): TrackWaypoint | null {
+ if (!Array.isArray(raw) || raw.length < 6) return null;
+
+ const time = typeof raw[0] === "number" && Number.isFinite(raw[0]) ? raw[0] : null;
+ const latitude = typeof raw[1] === "number" && Number.isFinite(raw[1]) ? raw[1] : null;
+ const longitude = typeof raw[2] === "number" && Number.isFinite(raw[2]) ? raw[2] : null;
+ const baroAltitude = typeof raw[3] === "number" && Number.isFinite(raw[3]) ? raw[3] : null;
+ const trueTrack = typeof raw[4] === "number" && Number.isFinite(raw[4]) ? raw[4] : null;
+ const onGround = raw[5] === true;
+
+ if (time === null) return null;
+ return { time, latitude, longitude, baroAltitude, trueTrack, onGround };
+}
+
+function parseFlightTrack(
+ icao24: string,
+ payload: unknown,
+): FlightTrack | null {
+ if (typeof payload !== "object" || payload === null) return null;
+ const data = payload as OpenSkyTrackResponse;
+
+ const startTime =
+ typeof data.startTime === "number" && Number.isFinite(data.startTime)
+ ? data.startTime
+ : 0;
+ const endTime =
+ typeof data.endTime === "number" && Number.isFinite(data.endTime)
+ ? data.endTime
+ : 0;
+
+ const callsignRaw =
+ typeof data.callsign === "string"
+ ? data.callsign
+ : typeof data.calllsign === "string"
+ ? data.calllsign
+ : null;
+ const callsign = callsignRaw ? callsignRaw.trim() || null : null;
+
+ const rawPath = Array.isArray(data.path) ? data.path : [];
+ const parsed = rawPath
+ .map(parseTrackWaypoint)
+ .filter((p): p is TrackWaypoint => p !== null)
+ .filter((p) => p.latitude !== null && p.longitude !== null);
+
+ // Be defensive: some responses can be out-of-order.
+ parsed.sort((a, b) => a.time - b.time);
+
+ // Remove consecutive duplicates (helps avoid long straight chords when data is jittery).
+ const path: TrackWaypoint[] = [];
+ let lastLng: number | null = null;
+ let lastLat: number | null = null;
+ for (const p of parsed) {
+ if (lastLng !== null && lastLat !== null) {
+ if (p.longitude === lastLng && p.latitude === lastLat) continue;
+ }
+ path.push(p);
+ lastLng = p.longitude;
+ lastLat = p.latitude;
+ }
+
+ if (path.length < 2) return null;
+
+ return {
+ icao24,
+ startTime,
+ endTime,
+ callsign,
+ path,
+ };
+}
+
+/**
+ * Fetch a flight track (trajectory) for an aircraft.
+ *
+ * Uses the experimental OpenSky tracks endpoint. For live flights, pass time=0
+ * which returns the current (ongoing) track if available.
+ */
+export async function fetchTrackByIcao24(
+ icao24: string,
+ time: number = 0,
+ signal?: AbortSignal,
+): Promise {
+ const normalizedIcao24 = icao24.trim().toLowerCase();
+ if (!ICAO24_REGEX.test(normalizedIcao24)) {
+ return {
+ track: null,
+ rateLimited: false,
+ creditsRemaining: null,
+ retryAfterSeconds: null,
+ };
+ }
+
+ const safeTime = Number.isFinite(time) ? Math.max(0, Math.floor(time)) : 0;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+ const onExternalAbort = () => {
+ if (!controller.signal.aborted) controller.abort();
+ };
+ signal?.addEventListener("abort", onExternalAbort, { once: true });
+
+ if (signal?.aborted) {
+ onExternalAbort();
+ }
+
+ try {
+ async function fetchWithTime(
+ t: number,
+ ): Promise<{ result: TrackFetchResult; notFound: boolean }> {
+ const urlAll = `${OPENSKY_API}/tracks/all?icao24=${encodeURIComponent(normalizedIcao24)}&time=${t}`;
+ const urlFallback = `${OPENSKY_API}/tracks?icao24=${encodeURIComponent(normalizedIcao24)}&time=${t}`;
+
+ async function attempt(url: string): Promise<{ result: TrackFetchResult; status: number }> {
+ const res = await fetch(url, {
+ cache: "no-store",
+ signal: controller.signal,
+ });
+
+ const rateLimitInfo = parseRateLimitInfo(res);
+
+ if (res.status === 429) {
+ return {
+ status: res.status,
+ result: {
+ track: null,
+ rateLimited: true,
+ creditsRemaining: rateLimitInfo.creditsRemaining,
+ retryAfterSeconds: rateLimitInfo.retryAfterSeconds,
+ },
+ };
+ }
+
+ if (res.status === 404 || res.status === 401 || res.status === 403) {
+ return {
+ status: res.status,
+ result: {
+ track: null,
+ rateLimited: false,
+ creditsRemaining: rateLimitInfo.creditsRemaining,
+ retryAfterSeconds: null,
+ },
+ };
+ }
+
+ if (!res.ok) {
+ return {
+ status: res.status,
+ result: {
+ track: null,
+ rateLimited: false,
+ creditsRemaining: rateLimitInfo.creditsRemaining,
+ retryAfterSeconds: null,
+ },
+ };
+ }
+
+ const payload = (await res.json()) as unknown;
+ return {
+ status: res.status,
+ result: {
+ track: parseFlightTrack(normalizedIcao24, payload),
+ rateLimited: false,
+ creditsRemaining: rateLimitInfo.creditsRemaining,
+ retryAfterSeconds: null,
+ },
+ };
+ }
+
+ const primary = await attempt(urlAll);
+ if (primary.result.track || primary.result.rateLimited) {
+ return { result: primary.result, notFound: false };
+ }
+
+ // Some OpenSky deployments/documentation use `/tracks` instead of `/tracks/all`.
+ // Fall back only when the primary endpoint is missing (404), not on auth failures.
+ if (primary.status === 404) {
+ const fallback = await attempt(urlFallback);
+ // Only treat as “not found” if both endpoints return 404.
+ const notFound = fallback.status === 404;
+ return { result: fallback.result, notFound };
+ }
+
+ return { result: primary.result, notFound: false };
+ }
+
+ const primary = await fetchWithTime(safeTime);
+ if (primary.result.track || primary.result.rateLimited || safeTime !== 0) {
+ return primary.result;
+ }
+
+ // Per OpenSky docs: `time` can be any time between the start and end of a known flight.
+ // `time=0` only returns a live track if OpenSky considers a flight ongoing. If that lookup
+ // fails with a not-found response, retry once with the current timestamp.
+ if (!primary.notFound) {
+ return primary.result;
+ }
+
+ const nowSec = Math.floor(Date.now() / 1000);
+ if (nowSec > 0) {
+ const retry = await fetchWithTime(nowSec);
+ return retry.result;
+ }
+
+ return primary.result;
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") {
+ // Abort is expected on effect cleanup or request timeouts. Treat it as a
+ // normal cancellation and return an empty result.
+ }
+ return {
+ track: null,
+ rateLimited: false,
+ creditsRemaining: null,
+ retryAfterSeconds: null,
+ };
+ } finally {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onExternalAbort);
+ }
+}