Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion server/worldmonitor/aviation/v1/get-flight-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export async function getFlightStatus(
_ctx: ServerContext,
req: GetFlightStatusRequest,
): Promise<GetFlightStatusResponse> {
const flightNumber = req.flightNumber?.toUpperCase().replace(/\s/g, '') || '';
// Normalize: strip leading zeros from numeric suffix (EK03 → EK3, BA002 → BA2)
const flightNumber = (req.flightNumber?.toUpperCase().replace(/\s/g, '') || '')
.replace(/^([A-Z]{2,3})0+(\d+)$/, '$1$2');
const date = req.date || new Date().toISOString().slice(0, 10);
const origin = req.origin?.toUpperCase() || '';
const cacheKey = `aviation:status:${flightNumber}:${date}:${origin}:v1`;
Expand Down
113 changes: 98 additions & 15 deletions src/components/AirlineIntelPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fetchAirportFlights,
fetchCarrierOps,
fetchAircraftPositions,
fetchFlightStatus,
fetchAviationNews,
fetchGoogleFlights,
fetchGoogleDates,
Expand Down Expand Up @@ -54,8 +55,8 @@ const TABS = ['ops', 'flights', 'airlines', 'tracking', 'news', 'prices'] as con
type Tab = typeof TABS[number];

const TAB_LABELS: Record<Tab, string> = {
ops: '🛫 Ops', flights: '✈️ Flights', airlines: '🏢 Airlines',
tracking: '📡 Track', news: '📰 News', prices: '💸 Prices',
ops: 'Ops', flights: 'Flights', airlines: 'Airlines',
tracking: 'Track', news: 'News', prices: 'Prices',
};

// ---- Panel class ----
Expand All @@ -67,6 +68,8 @@ export class AirlineIntelPanel extends Panel {
private flightsData: FlightInstance[] = [];
private carriersData: CarrierOps[] = [];
private trackingData: PositionSample[] = [];
private trackingFlightData: FlightInstance[] = [];
private trackingQuery = '';
private newsData: AviationNewsItem[] = [];
private googleFlightsData: GoogleFlightItinerary[] = [];
private datesData: DatePrice[] = [];
Expand Down Expand Up @@ -150,6 +153,21 @@ export class AirlineIntelPanel extends Panel {
if (target.id === 'datesSearchBtn' || target.closest('#datesSearchBtn')) {
this.handleDatesSearch();
}
if (target.id === 'trackSearchBtn' || target.closest('#trackSearchBtn')) {
this.handleTrackSearch();
}
if (target.id === 'trackClearBtn' || target.closest('#trackClearBtn')) {
this.trackingQuery = '';
this.trackingFlightData = [];
this.trackingData = [];
void this.loadTab('tracking');
}
});

this.content.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).id === 'trackQueryInput') {
this.handleTrackSearch();
}
});

void this.refresh();
Expand All @@ -169,6 +187,7 @@ export class AirlineIntelPanel extends Panel {

/** Called by the map when new aircraft positions arrive. */
updateLivePositions(positions: PositionSample[]): void {
if (this.trackingQuery) return; // preserve filtered search results
this.trackingData = positions;
if (this.activeTab === 'tracking') this.renderTab();
}
Expand Down Expand Up @@ -244,6 +263,14 @@ export class AirlineIntelPanel extends Panel {
void this.loadTab('prices');
}

private handleTrackSearch(): void {
const q = ((this.content.querySelector('#trackQueryInput') as HTMLInputElement)?.value || '').trim().toUpperCase();
this.trackingQuery = q;
this.trackingFlightData = [];
this.trackingData = [];
void this.loadTab('tracking');
}

private switchTab(tab: Tab): void {
this.activeTab = tab;
this.tabBar.querySelectorAll('.panel-tab').forEach(b => {
Expand Down Expand Up @@ -285,7 +312,17 @@ export class AirlineIntelPanel extends Panel {
this.carriersData = await fetchCarrierOps(this.airports);
break;
case 'tracking':
this.trackingData = await fetchAircraftPositions({});
if (this.trackingQuery) {
if (/^[A-Z]{2}\d{1,4}$/.test(this.trackingQuery)) {
this.trackingFlightData = await fetchFlightStatus(this.trackingQuery);
} else if (/^[0-9A-F]{6}$/i.test(this.trackingQuery)) {
this.trackingData = await fetchAircraftPositions({ icao24: this.trackingQuery.toLowerCase() });
} else {
this.trackingData = await fetchAircraftPositions({ callsign: this.trackingQuery });
}
} else {
this.trackingData = await fetchAircraftPositions({});
}
break;
case 'news': {
const entities = [...this.airports, ...aviationWatchlist.get().airlines];
Expand Down Expand Up @@ -394,18 +431,64 @@ export class AirlineIntelPanel extends Panel {

// ---- Tracking tab ----
private renderTracking(): void {
if (!this.trackingData.length) {
this.content.innerHTML = `<div class="no-data">${t('components.airlineIntel.noTrackingData')}</div>`;
const clearBtn = this.trackingQuery
? `<button id="trackClearBtn" class="icon-btn" style="padding:4px 8px;color:#9ca3af" title="Back to live feed">×</button>`
: '';
const searchBar = `
<div class="track-search" style="display:flex;gap:6px;padding:8px 0 6px">
<input id="trackQueryInput" class="price-input" placeholder="Flight (EK3) or callsign (UAE3)" value="${escapeHtml(this.trackingQuery)}" style="flex:1;min-width:0">
${clearBtn}<button id="trackSearchBtn" class="icon-btn" style="padding:4px 10px">Track</button>
</div>`;

if (this.loading) {
this.content.innerHTML = `${searchBar}<div class="panel-loading">${t('common.loading')}</div>`;
return;
}
Comment on lines +443 to 446
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dead loading guard — search bar disappears during fetch

renderTab() already returns early with the bare renderLoading() spinner whenever this.loading === true, so the if (this.loading) branch inside renderTracking() is unreachable. The search bar never appears alongside the loading indicator as intended, and the user sees a plain spinner with no input while their query is executing.

The root cause is in renderTab():

private renderTab(): void {
    if (this.loading && this.activeTab !== 'tracking') { this.renderLoading(); return; }
    // OR: remove the early return entirely and let each renderX() manage its own loading state
    ...
}

Decide on one approach — either each tab owns its loading rendering (remove the guard from renderTab()), or the tracking-tab branch needs to call renderTracking() even when loading === true.

const rows = this.trackingData.slice(0, 20).map(p => `
<div class="track-row">
<div class="track-cs">${escapeHtml(p.callsign || p.icao24)}</div>
<div class="track-alt">${fmt(p.altitudeFt)} ft</div>
<div class="track-spd">${fmt(p.groundSpeedKts)} kts</div>
<div class="track-pos">${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}</div>
</div>`).join('');
this.content.innerHTML = `<div class="tracking-list">${rows}</div>`;

// Flight status results (searched by IATA flight number)
if (this.trackingFlightData.length) {
const rows = this.trackingFlightData.map(f => {
const depStr = f.estimatedDeparture
? `Dep ${fmtTime(f.estimatedDeparture)}`
: '';
const arrStr = f.estimatedArrival
? ` · Arr ${fmtTime(f.estimatedArrival)}`
: '';
const color = STATUS_BADGE[f.status] ?? '#6b7280';
return `
<div class="track-flight-card" style="padding:8px 0;border-bottom:1px solid var(--border)">
<div style="display:flex;gap:8px;align-items:baseline">
<strong>${escapeHtml(f.flightNumber)}</strong>
<span style="color:#9ca3af;font-size:11px">${escapeHtml(f.carrier.name || f.carrier.iata)}</span>
<span style="color:${color};font-size:11px;margin-left:auto">${f.status}</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 f.status inserted into HTML without escaping

f.status is rendered directly into HTML here and in AviationCommandBar.ts line 105. While the server currently normalises this to a fixed enum string, the client-side render trusts an API-origin value. If the upstream value ever contains unexpected characters, this would be an XSS vector.

Suggested change
<span style="color:${color};font-size:11px;margin-left:auto">${f.status}</span>
<span style="color:${color};font-size:11px;margin-left:auto">${escapeHtml(f.status)}</span>

The same applies in AviationCommandBar.ts line 105:

<div>${escapeHtml(f.origin.iata)}  ${escapeHtml(f.destination.iata)} · ${escapeHtml(f.status)}${depStr ? ` · ${depStr}` : ''}${arrStr}</div>

</div>
<div style="font-size:12px;color:var(--text-dim)">${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)}${depStr ? ` · ${depStr}` : ''}${arrStr}</div>
${f.aircraftType ? `<div style="font-size:11px;color:#6b7280">${escapeHtml(f.aircraftType)}</div>` : ''}
${(f.gate || f.terminal) ? `<div style="font-size:11px;color:#6b7280">${f.gate ? `Gate ${escapeHtml(f.gate)}` : ''}${f.terminal ? `${f.gate ? ' · ' : ''}T${escapeHtml(f.terminal)}` : ''}</div>` : ''}
${f.delayMinutes > 0 ? `<div style="color:#f97316;font-size:12px">+${f.delayMinutes}m delay</div>` : ''}
</div>`;
}).join('');
this.content.innerHTML = `${searchBar}<div>${rows}</div>`;
return;
}

// Position results (searched by callsign/ICAO24 or default global fetch)
if (this.trackingData.length) {
const rows = this.trackingData.slice(0, 20).map(p => `
<div class="track-row">
<div class="track-cs">${escapeHtml(p.callsign || p.icao24)}</div>
<div class="track-alt">${fmt(p.altitudeFt)} ft</div>
<div class="track-spd">${fmt(p.groundSpeedKts)} kts</div>
<div class="track-pos">${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}</div>
</div>`).join('');
this.content.innerHTML = `${searchBar}<div class="tracking-list">${rows}</div>`;
return;
}

const emptyMsg = this.trackingQuery
? `<div class="no-data">No results for <strong>${escapeHtml(this.trackingQuery)}</strong>.</div>`
: `<div class="no-data">${t('components.airlineIntel.noTrackingData')}</div>`;
this.content.innerHTML = `${searchBar}${emptyMsg}`;
}

// ---- News tab ----
Expand Down Expand Up @@ -449,7 +532,7 @@ export class AirlineIntelPanel extends Panel {
<option value="BUSINESS"${this.pricesCabin === 'BUSINESS' ? ' selected' : ''}>Business</option>
<option value="FIRST"${this.pricesCabin === 'FIRST' ? ' selected' : ''}>First</option>
</select>
<button id="priceSearchBtn" class="icon-btn" style="padding:4px 10px">${t('common.search')}</button>
<button id="priceSearchBtn" class="icon-btn" style="padding:4px 10px">${t('header.search')}</button>
</div>
<div id="priceInlineErr" style="color:#ef4444;font-size:11px;min-height:14px"></div>`;

Expand Down Expand Up @@ -505,7 +588,7 @@ export class AirlineIntelPanel extends Panel {
<option value="BUSINESS"${this.pricesCabin === 'BUSINESS' ? ' selected' : ''}>Business</option>
<option value="FIRST"${this.pricesCabin === 'FIRST' ? ' selected' : ''}>First</option>
</select>
<button id="datesSearchBtn" class="icon-btn" style="padding:4px 10px">${t('common.search')}</button>
<button id="datesSearchBtn" class="icon-btn" style="padding:4px 10px">${t('header.search')}</button>
</div>
<div id="datesInlineErr" style="color:#ef4444;font-size:11px;min-height:14px"></div>`;

Expand Down
57 changes: 45 additions & 12 deletions src/components/AviationCommandBar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchFlightStatus, fetchAirportOpsSummary, fetchFlightPrices, fetchAviationNews } from '@/services/aviation';
import { fetchFlightStatus, fetchAirportOpsSummary, fetchFlightPrices, fetchAviationNews, fetchGoogleFlights } from '@/services/aviation';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';

// ---- Intent types ----
Expand All @@ -11,6 +11,13 @@ type Intent =
| { type: 'TRACK'; callsign?: string; icao24?: string }
| { type: 'UNKNOWN'; raw: string };

function fmtDur(m: number): string {
if (!m) return '';
const h = Math.floor(m / 60);
const min = m % 60;
return min > 0 ? `${h}h ${min}m` : `${h}h`;
}

// ---- Intent parser ----

function parseIntent(raw: string): Intent {
Expand All @@ -23,21 +30,21 @@ function parseIntent(raw: string): Intent {
if (airports.length) return { type: 'OPS', airports };
}

// FLIGHT <IATA-FLIGHT>
if (/^(FLIGHT|FLT|STATUS)\s+[A-Z]{2}\d{1,4}/.test(q)) {
const match = q.match(/[A-Z]{2}\d{1,4}/);
// FLIGHT <IATA-FLIGHT> or <IATA-FLIGHT> [STATUS|FLIGHT|FLT]
if (/^(FLIGHT|FLT)\s+[A-Z]{2,3}\d{1,4}/.test(q) || /[A-Z]{2,3}\d{1,4}\s+(STATUS|FLIGHT|FLT)/.test(q) || /^STATUS\s+[A-Z]{2,3}\d{1,4}/.test(q)) {
const match = q.match(/[A-Z]{2,3}\d{1,4}/);
if (match) {
const origin = words.find(w => /^[A-Z]{3}$/.test(w) && w !== match[0]);
return { type: 'FLIGHT_STATUS', flightNumber: match[0], origin };
}
}

// PRICE / PRICES <ORG> <DST>
if (/^PRICE[S]?\s+[A-Z]{3}\s+[A-Z]{3}/.test(q)) {
const airports = words.slice(1).filter(w => /^[A-Z]{3}$/.test(w));
if (airports.length >= 2) {
// PRICE / PRICES <ORG> <DST> or <ORG> TO <DST> PRICE[S]
if (words.some(w => /^PRICE[S]?$/.test(w))) {
const priceAirports = words.filter(w => /^[A-Z]{3}$/.test(w) && w !== 'TO');
if (priceAirports.length >= 2) {
const date = words.find(w => /^\d{4}-\d{2}-\d{2}$/.test(w));
return { type: 'PRICE_WATCH', origin: airports[0]!, destination: airports[1]!, date };
return { type: 'PRICE_WATCH', origin: priceAirports[0]!, destination: priceAirports[1]!, date };
}
}

Expand Down Expand Up @@ -79,19 +86,45 @@ async function executeIntent(intent: Intent): Promise<CommandResult> {
const flights = await fetchFlightStatus(intent.flightNumber, undefined, intent.origin);
if (!flights.length) return { html: `<div class="cmd-empty">No results for ${escapeHtml(intent.flightNumber)}.</div>` };
const f = flights[0]!;
const timeStr = f.estimatedDeparture
const depStr = f.estimatedDeparture
? `Dep ${f.estimatedDeparture.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}`
: '';
const arrStr = f.estimatedArrival
? ` · Arr ${f.estimatedArrival.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}`
: '';
const carrierLabel = f.carrier.name || f.carrier.iata;
const gateLine = (f.gate || f.terminal)
? `<div style="color:#9ca3af;font-size:11px">Gate ${escapeHtml(f.gate || '—')}${f.terminal ? ` · Terminal ${escapeHtml(f.terminal)}` : ''}</div>`
: '';
const acLine = f.aircraftType
? `<div style="color:#9ca3af;font-size:11px">${escapeHtml(f.aircraftType)}</div>`
: '';
return {
html: `<div class="cmd-section">
<strong>✈️ ${escapeHtml(f.flightNumber)}</strong>
<div>${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)} · ${f.status} · ${timeStr}</div>
<strong>✈️ ${escapeHtml(f.flightNumber)}</strong>${carrierLabel ? ` <span style="color:#9ca3af">(${escapeHtml(carrierLabel)})</span>` : ''}
<div>${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)} · ${f.status}${depStr ? ` · ${depStr}` : ''}${arrStr}</div>
${acLine}${gateLine}
${f.delayMinutes > 0 ? `<div style="color:#f97316">+${f.delayMinutes}m delay</div>` : ''}
</div>` };
}

if (intent.type === 'PRICE_WATCH') {
const date = intent.date ?? new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
// Try Google Flights first (real data)
const gfResult = await fetchGoogleFlights({ origin: intent.origin, destination: intent.destination, departureDate: date });
if (gfResult.flights.length) {
const best = gfResult.flights[0]!;
const leg = best.legs[0];
const carrier = leg ? `${leg.airlineCode} ${leg.flightNumber}` : '';
const stops = best.stops === 0 ? 'nonstop' : `${best.stops} stop`;
return {
html: `<div class="cmd-section">
<strong>💸 ${escapeHtml(intent.origin)} → ${escapeHtml(intent.destination)}</strong>
<div>Best: <strong style="color:#60a5fa">$${Math.round(best.price).toLocaleString()}</strong> · ${escapeHtml(carrier)} · ${stops} · ${escapeHtml(fmtDur(best.durationMinutes))}</div>
${gfResult.degraded ? '<div style="color:#f59e0b;font-size:11px">Partial results</div>' : ''}
</div>` };
}
// Fallback to TravelPayouts / demo
Comment on lines +113 to +127
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 fetchGoogleFlights exception bypasses TravelPayouts fallback

The TravelPayouts fallback is only reached when gfResult.flights.length === 0. If fetchGoogleFlights throws (network error, 5xx, API key issue), the exception propagates out of executeIntent and is caught by run()'s outer try/catch, displaying a generic error — the TravelPayouts/demo path is never attempted.

Wrap the Google Flights call so a thrown exception is treated the same as an empty result:

let gfResult: Awaited<ReturnType<typeof fetchGoogleFlights>> | null = null;
try {
    gfResult = await fetchGoogleFlights({ origin: intent.origin, destination: intent.destination, departureDate: date });
} catch { /* fall through to TravelPayouts */ }

if (gfResult?.flights.length) {
    // … render Google Flights card
}
// Fallback to TravelPayouts / demo
const { quotes, isDemoMode } = await fetchFlightPrices();

const { quotes, isDemoMode } = await fetchFlightPrices({ origin: intent.origin, destination: intent.destination, departureDate: date });
if (!quotes.length) return { html: '<div class="cmd-empty">No prices found.</div>' };
const best = quotes[0]!;
Expand Down
3 changes: 3 additions & 0 deletions src/styles/panels.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
background: var(--bg);
scrollbar-width: none;
-ms-overflow-style: none;
/* Fade hint on right edge when tabs overflow */
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}

/* When .panel-content contains tabs (at any depth), remove top padding
Expand Down
Loading