-
Notifications
You must be signed in to change notification settings - Fork 7.3k
feat(aviation): track-tab search, richer flight cards, Google Flights in command bar #2516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { | |||||
| fetchAirportFlights, | ||||||
| fetchCarrierOps, | ||||||
| fetchAircraftPositions, | ||||||
| fetchFlightStatus, | ||||||
| fetchAviationNews, | ||||||
| fetchGoogleFlights, | ||||||
| fetchGoogleDates, | ||||||
|
|
@@ -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 ---- | ||||||
|
|
@@ -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[] = []; | ||||||
|
|
@@ -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(); | ||||||
|
|
@@ -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(); | ||||||
| } | ||||||
|
|
@@ -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 => { | ||||||
|
|
@@ -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]; | ||||||
|
|
@@ -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; | ||||||
| } | ||||||
| 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> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The same applies in <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 ---- | ||||||
|
|
@@ -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>`; | ||||||
|
|
||||||
|
|
@@ -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>`; | ||||||
|
|
||||||
|
|
||||||
| 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 ---- | ||
|
|
@@ -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 { | ||
|
|
@@ -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 }; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The TravelPayouts fallback is only reached when 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]!; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
renderTab()already returns early with the barerenderLoading()spinner wheneverthis.loading === true, so theif (this.loading)branch insiderenderTracking()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():Decide on one approach — either each tab owns its loading rendering (remove the guard from
renderTab()), or the tracking-tab branch needs to callrenderTracking()even whenloading === true.