import { fetchAirportOpsSummary, fetchAirportFlights, fetchCarrierOps, fetchAircraftPositions, fetchFlightStatus, fetchAviationNews, fetchGoogleFlights, fetchGoogleDates, type AirportOpsSummary, type FlightInstance, type CarrierOps, type PositionSample, type AviationNewsItem, type FlightDelaySeverity, type GoogleFlightItinerary, type DatePrice, } from '@/services/aviation'; import { aviationWatchlist } from '@/services/aviation/watchlist'; import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; import { t } from '@/services/i18n'; import { Panel } from './Panel'; import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; // ---- Helpers ---- const SEVERITY_COLOR: Record = { normal: 'var(--color-success, #22c55e)', minor: '#f59e0b', moderate: '#f97316', major: '#ef4444', severe: '#dc2626', // 'unknown' = no telemetry. Render neutral grey so users don't read it // as "healthy / green" (#3707). unknown: '#9ca3af', }; const STATUS_BADGE: Record = { scheduled: '#6b7280', boarding: '#3b82f6', departed: '#8b5cf6', airborne: '#22c55e', landed: '#14b8a6', arrived: '#0ea5e9', cancelled: '#ef4444', diverted: '#f59e0b', unknown: '#6b7280', }; function fmt(n: number | null | undefined): string { return n == null ? '—' : String(Math.round(n)); } function fmtTime(dt: Date | null | undefined): string { if (!dt) return '—'; return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); } function fmtMin(m: number): string { if (!m) return '—'; return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`; } function localDateStr(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } const TABS = ['ops', 'flights', 'airlines', 'tracking', 'news', 'prices'] as const; type Tab = typeof TABS[number]; const TAB_LABELS: Record = { ops: 'Ops', flights: 'Flights', airlines: 'Airlines', tracking: 'Track', news: 'News', prices: 'Prices', }; // ---- Panel class ---- export class AirlineIntelPanel extends Panel { private activeTab: Tab = 'ops'; private airports: string[]; private opsData: AirportOpsSummary[] = []; private flightsData: FlightInstance[] = []; private carriersData: CarrierOps[] = []; private trackingData: PositionSample[] = []; private trackingFlightData: FlightInstance[] = []; private trackingQuery = ''; private newsData: AviationNewsItem[] = []; private googleFlightsData: GoogleFlightItinerary[] = []; private datesData: DatePrice[] = []; private pricesMode: 'search' | 'dates' = 'search'; private pricesCabin = 'ECONOMY'; private pricesDegraded = false; private pricesError = ''; private pricesOrigin = 'IST'; private pricesDest = ''; private pricesDep = ''; private datesStart = ''; private datesEnd = ''; private datesTripDuration = 7; private datesRoundTrip = true; private loading = false; private refreshTimer: ReturnType | null = null; private liveIndicator!: HTMLElement; private tabBar!: HTMLElement; constructor() { super({ id: 'airline-intel', title: t('panels.airlineIntel'), trackActivity: true, infoTooltip: t('components.airlineIntel.infoTooltip') }); const wl = aviationWatchlist.get(); this.airports = wl.airports.slice(0, 8); const firstRoute = wl.routes[0]; if (firstRoute) { const parts = firstRoute.split('-'); if (parts[0]) this.pricesOrigin = parts[0]; if (parts[1]) this.pricesDest = parts[1]; } else { this.pricesOrigin = this.airports[0] ?? 'IST'; this.pricesDest = this.airports[1] ?? ''; } // Add refresh button to header const refreshBtn = document.createElement('button'); refreshBtn.className = 'icon-btn'; refreshBtn.title = t('common.refresh'); refreshBtn.textContent = '↻'; refreshBtn.addEventListener('click', () => this.refresh()); this.header.appendChild(refreshBtn); // Add LIVE indicator badge to the title this.liveIndicator = document.createElement('span'); this.liveIndicator.className = 'live-badge'; this.liveIndicator.textContent = '\u25CF LIVE'; this.liveIndicator.style.cssText = 'display:none;color:#22c55e;font-size:10px;font-weight:700;margin-left:8px;letter-spacing:0.5px;'; this.header.querySelector('.panel-title')?.appendChild(this.liveIndicator); // Insert tab bar between header and content this.tabBar = document.createElement('div'); this.tabBar.className = 'panel-tabs'; TABS.forEach(tab => { const btn = document.createElement('button'); btn.className = `panel-tab${tab === this.activeTab ? ' active' : ''}`; btn.textContent = TAB_LABELS[tab]; btn.dataset.tab = tab; btn.addEventListener('click', () => this.switchTab(tab as Tab)); this.tabBar.appendChild(btn); }); this.element.insertBefore(this.tabBar, this.content); // Add styling class to inherited content div this.content.classList.add('airline-intel-content'); // Event delegation on stable content element (survives innerHTML replacements) this.content.addEventListener('click', (e) => { const target = e.target as HTMLElement; const modeBtn = target.closest('[data-price-mode]') as HTMLElement | null; if (modeBtn) { this.pricesMode = modeBtn.dataset.priceMode as 'search' | 'dates'; this.pricesError = ''; this.pricesDegraded = false; this.renderTab(); return; } if (target.id === 'priceSearchBtn' || target.closest('#priceSearchBtn')) { this.handleFlightSearch(); } 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(); } }); this.runWhenConnected(() => { void this.refresh(); // Auto-refresh every 5 min — refresh() loads ops + active tab this.refreshTimer = setInterval(() => void this.refresh(), 5 * 60_000); }); } toggle(visible: boolean): void { this.element.style.display = visible ? '' : 'none'; } destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } /** 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(); } /** Toggle the LIVE indicator badge. */ setLiveMode(active: boolean): void { this.liveIndicator.style.display = active ? '' : 'none'; } private handleFlightSearch(): void { const origin = ((this.content.querySelector('#priceFromInput') as HTMLInputElement)?.value || '').toUpperCase().trim(); const dest = ((this.content.querySelector('#priceToInput') as HTMLInputElement)?.value || '').toUpperCase().trim(); const dep = (this.content.querySelector('#priceDepInput') as HTMLInputElement)?.value || ''; const cabin = (this.content.querySelector('#priceCabinSelect') as HTMLSelectElement)?.value || 'ECONOMY'; const errEl = this.content.querySelector('#priceInlineErr') as HTMLElement | null; const iataRe = /^[A-Z]{3}$/; if (!iataRe.test(origin) || !iataRe.test(dest)) { if (errEl) errEl.textContent = 'Enter valid 3-letter IATA codes'; return; } const today = localDateStr(); if (dep && dep < today) { if (errEl) errEl.textContent = 'Departure date must be today or future'; return; } if (errEl) errEl.textContent = ''; this.pricesOrigin = origin; this.pricesDest = dest; this.pricesDep = dep; this.pricesCabin = cabin; void this.loadTab('prices'); } private handleDatesSearch(): void { const origin = ((this.content.querySelector('#datesFromInput') as HTMLInputElement)?.value || '').toUpperCase().trim(); const dest = ((this.content.querySelector('#datesToInput') as HTMLInputElement)?.value || '').toUpperCase().trim(); const start = (this.content.querySelector('#datesStartInput') as HTMLInputElement)?.value || ''; const end = (this.content.querySelector('#datesEndInput') as HTMLInputElement)?.value || ''; const rt = (this.content.querySelector('#datesRoundTripCheck') as HTMLInputElement)?.checked ?? true; const dur = parseInt((this.content.querySelector('#datesTripDurInput') as HTMLInputElement)?.value || '7', 10); const cabin = (this.content.querySelector('#datesCabinSelect') as HTMLSelectElement)?.value || 'ECONOMY'; const errEl = this.content.querySelector('#datesInlineErr') as HTMLElement | null; const iataRe = /^[A-Z]{3}$/; if (!iataRe.test(origin) || !iataRe.test(dest)) { if (errEl) errEl.textContent = 'Enter valid 3-letter IATA codes'; return; } if (!start || !end) { if (errEl) errEl.textContent = 'Enter start and end dates'; return; } if (start < localDateStr()) { if (errEl) errEl.textContent = 'Start date must be today or future'; return; } if (start >= end) { if (errEl) errEl.textContent = 'Start date must be before end date'; return; } if (rt && (Number.isNaN(dur) || dur < 1)) { if (errEl) errEl.textContent = 'Trip duration must be at least 1 day'; return; } const daysDiff = (new Date(end).getTime() - new Date(start).getTime()) / 86400000; if (errEl) errEl.textContent = daysDiff > 90 ? 'Range exceeds 90 days — results may be incomplete' : ''; this.pricesOrigin = origin; this.pricesDest = dest; this.datesStart = start; this.datesEnd = end; this.datesRoundTrip = rt; this.datesTripDuration = Number.isNaN(dur) ? 7 : dur; this.pricesCabin = cabin; 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 => { b.classList.toggle('active', (b as HTMLElement).dataset.tab === tab); }); this.renderTab(); if ((tab === 'ops' && !this.opsData.length) || (tab === 'flights' && !this.flightsData.length) || (tab === 'airlines' && !this.carriersData.length) || (tab === 'tracking' && !this.trackingData.length) || (tab === 'news' && !this.newsData.length)) { void this.loadTab(tab); } // prices tab: never auto-fetch — only on explicit search button click } private async refresh(): Promise { const shouldLoadActiveTab = this.activeTab !== 'prices'; if (!this.element.isConnected) { this.runWhenConnected(() => { void this.refresh(); }); return; } if (this.activeTab !== 'ops') void this.loadOps(); if (shouldLoadActiveTab) void this.loadTab(this.activeTab); } private async loadOps(): Promise { this.opsData = await fetchAirportOpsSummary(this.airports); if (this.activeTab === 'ops') this.renderTab(); } private async loadTab(tab: Tab): Promise { this.loading = true; this.renderTab(); try { switch (tab) { case 'ops': this.opsData = await fetchAirportOpsSummary(this.airports); break; case 'flights': this.flightsData = await fetchAirportFlights(this.airports[0] ?? 'IST', 'both', 30); break; case 'airlines': this.carriersData = await fetchCarrierOps(this.airports); break; case 'tracking': 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]; this.newsData = await fetchAviationNews(entities, 24, 20); break; } case 'prices': { if (this.pricesMode === 'dates') { const r = await fetchGoogleDates({ origin: this.pricesOrigin, destination: this.pricesDest, startDate: this.datesStart, endDate: this.datesEnd, tripDuration: this.datesTripDuration, isRoundTrip: this.datesRoundTrip, cabinClass: this.pricesCabin, }); this.datesData = r.dates; this.pricesDegraded = r.degraded; this.pricesError = r.error; } else { const dep = this.pricesDep || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); const r = await fetchGoogleFlights({ origin: this.pricesOrigin, destination: this.pricesDest, departureDate: dep, cabinClass: this.pricesCabin, }); this.googleFlightsData = r.flights; this.pricesDegraded = r.degraded; this.pricesError = r.error; } break; } } } catch { /* silent */ } this.loading = false; this.renderTab(); } private renderLoading(): void { setTrustedHtml(this.content, trustedHtml(`
${t('common.loading')}
`, "legacy direct innerHTML migration")); } private renderTab(): void { if (this.loading) { this.renderLoading(); return; } switch (this.activeTab) { case 'ops': this.renderOps(); break; case 'flights': this.renderFlights(); break; case 'airlines': this.renderAirlines(); break; case 'tracking': this.renderTracking(); break; case 'news': this.renderNews(); break; case 'prices': this.renderPrices(); break; } } // ---- Ops tab ---- private renderOps(): void { if (!this.opsData.length) { setTrustedHtml(this.content, trustedHtml(`
${t('components.airlineIntel.noOpsData')}
`, "legacy direct innerHTML migration")); return; } const rows = this.opsData.map(s => `
${escapeHtml(s.iata)}
${escapeHtml(s.name || s.iata)}
${s.severity.toUpperCase()}
${s.avgDelayMinutes > 0 ? `+${s.avgDelayMinutes}m` : '—'}
${s.cancellationRate > 0 ? `${s.cancellationRate.toFixed(1)}% cxl` : ''}
${s.closureStatus ? '
CLOSED
' : ''} ${s.notamFlags.length ? `
⚠️ NOTAM
` : ''}
`).join(''); setTrustedHtml(this.content, trustedHtml(`
${rows}
`, "legacy direct innerHTML migration")); } // ---- Flights tab ---- private renderFlights(): void { if (!this.flightsData.length) { setTrustedHtml(this.content, trustedHtml(`
${t('components.airlineIntel.noFlights')}
`, "legacy direct innerHTML migration")); return; } const rows = this.flightsData.map(f => { const color = STATUS_BADGE[f.status] ?? '#6b7280'; return `
${escapeHtml(f.flightNumber)}
${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)}
${fmtTime(f.scheduledDeparture)}
${f.delayMinutes > 0 ? `+${f.delayMinutes}m` : ''}
${f.status}
`; }).join(''); setTrustedHtml(this.content, trustedHtml(`
${rows}
`, "legacy direct innerHTML migration")); } // ---- Airlines tab ---- private renderAirlines(): void { if (!this.carriersData.length) { setTrustedHtml(this.content, trustedHtml(`
${t('components.airlineIntel.noCarrierData')}
`, "legacy direct innerHTML migration")); return; } const rows = this.carriersData.slice(0, 15).map(c => `
${escapeHtml(c.carrierName || c.carrierIata)}
${c.totalFlights} flt
${c.delayPct.toFixed(1)}% delayed
${c.cancellationRate.toFixed(1)}% cxl
`).join(''); setTrustedHtml(this.content, trustedHtml(`
${rows}
`, "legacy direct innerHTML migration")); } // ---- Tracking tab ---- private renderTracking(): void { const clearBtn = this.trackingQuery ? `` : ''; const searchBar = ` `; if (this.loading) { setTrustedHtml(this.content, trustedHtml(`${searchBar}
${t('common.loading')}
`, "legacy direct innerHTML migration")); return; } // 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 `
${escapeHtml(f.flightNumber)} ${escapeHtml(f.carrier.name || f.carrier.iata)} ${f.status}
${escapeHtml(f.origin.iata)} → ${escapeHtml(f.destination.iata)}${depStr ? ` · ${depStr}` : ''}${arrStr}
${f.aircraftType ? `
${escapeHtml(f.aircraftType)}
` : ''} ${(f.gate || f.terminal) ? `
${f.gate ? `Gate ${escapeHtml(f.gate)}` : ''}${f.terminal ? `${f.gate ? ' · ' : ''}T${escapeHtml(f.terminal)}` : ''}
` : ''} ${f.delayMinutes > 0 ? `
+${f.delayMinutes}m delay
` : ''}
`; }).join(''); setTrustedHtml(this.content, trustedHtml(`${searchBar}
${rows}
`, "legacy direct innerHTML migration")); return; } // Position results (searched by callsign/ICAO24 or default global fetch) if (this.trackingData.length) { const rows = this.trackingData.slice(0, 20).map(p => `
${escapeHtml(p.callsign || p.icao24)}
${fmt(p.altitudeFt)} ft
${fmt(p.groundSpeedKts)} kts
${p.lat.toFixed(2)}, ${p.lon.toFixed(2)}
`).join(''); setTrustedHtml(this.content, trustedHtml(`${searchBar}
${rows}
`, "legacy direct innerHTML migration")); return; } const emptyMsg = this.trackingQuery ? `
No results for ${escapeHtml(this.trackingQuery)}.
` : `
${t('components.airlineIntel.noTrackingData')}
`; setTrustedHtml(this.content, trustedHtml(`${searchBar}${emptyMsg}`, "legacy direct innerHTML migration")); } // ---- News tab ---- private renderNews(): void { if (!this.newsData.length) { setTrustedHtml(this.content, trustedHtml(`
${t('components.airlineIntel.noNews')}
`, "legacy direct innerHTML migration")); return; } const items = this.newsData.map(n => `
${escapeHtml(n.title)}
${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`).join(''); setTrustedHtml(this.content, trustedHtml(`
${items}
`, "legacy direct innerHTML migration")); } // ---- Prices tab ---- private renderPrices(): void { const isSearch = this.pricesMode === 'search'; const toggle = `
`; const degradedBanner = this.pricesDegraded ? `
${escapeHtml(t('components.airlineIntel.degradedResults'))}
` : ''; if (isSearch) { const dep = this.pricesDep || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); const form = `
\u2192
`; let body: string; if (this.googleFlightsData.length) { const cards = this.googleFlightsData.map(it => { const stops = it.stops === 0 ? t('components.airlineIntel.nonstop') : `${it.stops} stop`; const legs = it.legs.map(leg => `
${escapeHtml(leg.airlineCode)} ${escapeHtml(leg.flightNumber)} ${escapeHtml(leg.departureAirport)} ${escapeHtml(leg.departureDatetime.slice(11, 16))} \u2192 ${escapeHtml(leg.arrivalAirport)} ${escapeHtml(leg.arrivalDatetime.slice(11, 16))} (${fmtMin(leg.durationMinutes)})
`).join(''); return `
${Math.round(it.price).toLocaleString()} ${fmtMin(it.durationMinutes)} ${escapeHtml(stops)}
${legs}
`; }).join(''); body = `
${cards}
`; } else if (this.pricesError) { body = `
${escapeHtml(this.pricesError)}
`; } else { body = `
${escapeHtml(t('components.airlineIntel.enterRouteAndDate'))}
`; } setTrustedHtml(this.content, trustedHtml(`${toggle}${form}${degradedBanner}${body}`, "legacy direct innerHTML migration")); } else { const form = `
\u2192
`; let body: string; if (this.datesData.length) { const sorted = [...this.datesData].sort((a, b) => a.price - b.price); const prices = sorted.map(d => d.price); const cheapThreshold = prices[Math.floor(prices.length * 0.2)] ?? Infinity; const expThreshold = prices[Math.floor(prices.length * 0.8)] ?? -Infinity; const rows = sorted.map(d => { const cls = d.price <= cheapThreshold ? 'dp-cheap' : d.price >= expThreshold ? 'dp-expensive' : ''; return `
${escapeHtml(d.date)} ${d.returnDate ? `${escapeHtml(d.returnDate)}` : ''} ${Math.round(d.price).toLocaleString()}
`; }).join(''); body = `
${rows}
`; } else if (this.pricesError) { body = `
${escapeHtml(this.pricesError)}
`; } else { body = `
${escapeHtml(t('components.airlineIntel.enterDateRange'))}
`; } setTrustedHtml(this.content, trustedHtml(`${toggle}${form}${degradedBanner}${body}`, "legacy direct innerHTML migration")); } } /* Styles moved to panels.css (PERF-012) */ }