feat(aviation): wire Google Flights RPCs into AirlineIntelPanel prices tab (#2506)

* feat(aviation): wire Google Flights RPCs into AirlineIntelPanel prices tab

* fix(aviation): clear error state on mode switch; add 90-day range warning

* fix(aviation): show partial results on degraded; local date validation; maxStops in cache key
This commit is contained in:
Elie Habib
2026-03-29 16:59:24 +04:00
committed by GitHub
parent db580d6fef
commit ebd778fe19
5 changed files with 687 additions and 126 deletions

View File

@@ -3,16 +3,17 @@ import {
fetchAirportFlights,
fetchCarrierOps,
fetchAircraftPositions,
fetchFlightPrices,
fetchAviationNews,
isPriceExpired,
fetchGoogleFlights,
fetchGoogleDates,
type AirportOpsSummary,
type FlightInstance,
type CarrierOps,
type PositionSample,
type PriceQuote,
type AviationNewsItem,
type FlightDelaySeverity,
type GoogleFlightItinerary,
type DatePrice,
} from '@/services/aviation';
import { aviationWatchlist } from '@/services/aviation/watchlist';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
@@ -44,14 +45,9 @@ function fmtMin(m: number): string {
if (!m) return '—';
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
}
function expCountdown(exp: Date | null, now: number): string {
if (!exp) return '';
const ms = exp.getTime() - now;
if (ms <= 0) return '<span style="color:#ef4444;font-size:10px">EXPIRED</span>';
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const color = h < 1 ? '#f97316' : '#6b7280';
return `<span style="font-size:10px;color:${color}">exp ${h > 0 ? `${h}h ` : ''}${m}m</span>`;
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;
@@ -72,12 +68,19 @@ export class AirlineIntelPanel extends Panel {
private carriersData: CarrierOps[] = [];
private trackingData: PositionSample[] = [];
private newsData: AviationNewsItem[] = [];
private pricesData: PriceQuote[] = [];
private pricesProvider = 'demo';
private googleFlightsData: GoogleFlightItinerary[] = [];
private datesData: DatePrice[] = [];
private pricesMode: 'search' | 'dates' = 'search';
private pricesCabin = 'ECONOMY';
private pricesDegraded = false;
private pricesError = '';
private pricesOrigin = 'IST';
private pricesDest = 'LHR';
private pricesDest = '';
private pricesDep = '';
private pricesCurrency = 'usd';
private datesStart = '';
private datesEnd = '';
private datesTripDuration = 7;
private datesRoundTrip = true;
private loading = false;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private liveIndicator!: HTMLElement;
@@ -89,6 +92,16 @@ export class AirlineIntelPanel extends Panel {
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';
@@ -123,12 +136,19 @@ export class AirlineIntelPanel extends Panel {
// 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.pricesOrigin = ((this.content.querySelector('#priceFromInput') as HTMLInputElement)?.value || 'IST').toUpperCase();
this.pricesDest = ((this.content.querySelector('#priceToInput') as HTMLInputElement)?.value || 'LHR').toUpperCase();
this.pricesDep = (this.content.querySelector('#priceDepInput') as HTMLInputElement)?.value || '';
this.pricesCurrency = (this.content.querySelector('#priceCurrencySelect') as HTMLSelectElement)?.value || 'usd';
void this.loadTab('prices');
this.handleFlightSearch();
}
if (target.id === 'datesSearchBtn' || target.closest('#datesSearchBtn')) {
this.handleDatesSearch();
}
});
@@ -158,6 +178,72 @@ export class AirlineIntelPanel extends Panel {
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 switchTab(tab: Tab): void {
this.activeTab = tab;
this.tabBar.querySelectorAll('.panel-tab').forEach(b => {
@@ -168,16 +254,15 @@ export class AirlineIntelPanel extends Panel {
(tab === 'flights' && !this.flightsData.length) ||
(tab === 'airlines' && !this.carriersData.length) ||
(tab === 'tracking' && !this.trackingData.length) ||
(tab === 'news' && !this.newsData.length) ||
(tab === 'prices' && !this.pricesData.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<void> {
// Skip loadOps when on the ops tab — loadTab('ops') fetches the same data
if (this.activeTab !== 'ops') void this.loadOps();
void this.loadTab(this.activeTab);
if (this.activeTab !== 'prices') void this.loadTab(this.activeTab);
}
private async loadOps(): Promise<void> {
@@ -208,13 +293,26 @@ export class AirlineIntelPanel extends Panel {
break;
}
case 'prices': {
const dep = this.pricesDep || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const result = await fetchFlightPrices({
origin: this.pricesOrigin, destination: this.pricesDest,
departureDate: dep, currency: this.pricesCurrency,
});
this.pricesData = result.quotes;
this.pricesProvider = result.provider;
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;
}
}
@@ -326,51 +424,114 @@ export class AirlineIntelPanel extends Panel {
// ---- Prices tab ----
private renderPrices(): void {
const provider = this.pricesProvider;
const providerBadge = provider === 'travelpayouts_data'
? `<span class="tp-badge">${escapeHtml(t('components.airlineIntel.cachedInsight'))} · Travelpayouts</span>`
: `<span class="demo-badge">${escapeHtml(t('components.airlineIntel.demoMode'))}</span>`;
const isSearch = this.pricesMode === 'search';
const toggle = `
<div class="price-mode-toggle">
<button class="price-mode-btn${isSearch ? ' active' : ''}" data-price-mode="search">${escapeHtml(t('components.airlineIntel.searchFlights'))}</button>
<button class="price-mode-btn${!isSearch ? ' active' : ''}" data-price-mode="dates">${escapeHtml(t('components.airlineIntel.bestDates'))}</button>
</div>`;
const searchForm = `
<div class="price-controls" style="display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;align-items:center">
<input id="priceFromInput" class="price-input" placeholder="From" maxlength="3" value="${escapeHtml(this.pricesOrigin)}" style="width:54px">
<span style="color:#6b7280">\u2192</span>
<input id="priceToInput" class="price-input" placeholder="To" maxlength="3" value="${escapeHtml(this.pricesDest)}" style="width:54px">
<input id="priceDepInput" class="price-input" type="date" value="${escapeHtml(this.pricesDep)}" style="width:128px">
<select id="priceCurrencySelect" class="price-input" style="width:58px">
<option value="usd"${this.pricesCurrency === 'usd' ? ' selected' : ''}>USD</option>
<option value="eur"${this.pricesCurrency === 'eur' ? ' selected' : ''}>EUR</option>
<option value="try"${this.pricesCurrency === 'try' ? ' selected' : ''}>TRY</option>
<option value="gbp"${this.pricesCurrency === 'gbp' ? ' selected' : ''}>GBP</option>
</select>
<button id="priceSearchBtn" class="icon-btn" style="padding:4px 10px">${t('common.search')}</button>
</div>
<div style="margin-bottom:6px">${providerBadge}<span style="font-size:10px;color:#6b7280;margin-left:6px">${t('components.airlineIntel.pricesIndicative')}</span></div>`;
const degradedBanner = this.pricesDegraded
? `<div class="gf-degraded">${escapeHtml(t('components.airlineIntel.degradedResults'))}</div>`
: '';
if (!this.pricesData.length) {
this.content.innerHTML = `${searchForm}<div class="no-data">${t('components.airlineIntel.enterRoute')}</div>`;
if (isSearch) {
const dep = this.pricesDep || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const form = `
<div class="price-controls">
<input id="priceFromInput" class="price-input" placeholder="From" maxlength="3" value="${escapeHtml(this.pricesOrigin)}" style="width:54px">
<span style="color:#6b7280">\u2192</span>
<input id="priceToInput" class="price-input" placeholder="To" maxlength="3" value="${escapeHtml(this.pricesDest)}" style="width:54px">
<input id="priceDepInput" class="price-input" type="date" value="${escapeHtml(dep)}" style="width:128px">
<select id="priceCabinSelect" class="price-input" style="width:110px">
<option value="ECONOMY"${this.pricesCabin === 'ECONOMY' ? ' selected' : ''}>Economy</option>
<option value="PREMIUM_ECONOMY"${this.pricesCabin === 'PREMIUM_ECONOMY' ? ' selected' : ''}>Premium Economy</option>
<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>
</div>
<div id="priceInlineErr" style="color:#ef4444;font-size:11px;min-height:14px"></div>`;
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 => `
<div class="gf-leg">
<span class="gf-airline">${escapeHtml(leg.airlineCode)} ${escapeHtml(leg.flightNumber)}</span>
<span>${escapeHtml(leg.departureAirport)} ${escapeHtml(leg.departureDatetime.slice(11, 16))}</span>
<span>\u2192</span>
<span>${escapeHtml(leg.arrivalAirport)} ${escapeHtml(leg.arrivalDatetime.slice(11, 16))}</span>
<span class="gf-dur">(${fmtMin(leg.durationMinutes)})</span>
</div>`).join('');
return `
<div class="gf-card">
<div class="gf-summary">
<span class="gf-price">${Math.round(it.price).toLocaleString()}</span>
<span class="gf-total-dur">${fmtMin(it.durationMinutes)}</span>
<span class="gf-stops">${escapeHtml(stops)}</span>
</div>
${legs}
</div>`;
}).join('');
body = `<div class="gf-list">${cards}</div>`;
} else if (this.pricesError) {
body = `<div class="no-data" style="color:#ef4444">${escapeHtml(this.pricesError)}</div>`;
} else {
body = `<div class="no-data">${escapeHtml(t('components.airlineIntel.enterRouteAndDate'))}</div>`;
}
this.content.innerHTML = `${toggle}${form}${degradedBanner}${body}`;
} else {
const now = Date.now();
const active = this.pricesData.filter(q => !isPriceExpired(q));
const expired = this.pricesData.filter(q => isPriceExpired(q));
const sorted = [...active, ...expired];
const form = `
<div class="price-controls">
<input id="datesFromInput" class="price-input" placeholder="From" maxlength="3" value="${escapeHtml(this.pricesOrigin)}" style="width:54px">
<span style="color:#6b7280">\u2192</span>
<input id="datesToInput" class="price-input" placeholder="To" maxlength="3" value="${escapeHtml(this.pricesDest)}" style="width:54px">
<input id="datesStartInput" class="price-input" type="date" value="${escapeHtml(this.datesStart || localDateStr())}" style="width:128px">
<input id="datesEndInput" class="price-input" type="date" value="${escapeHtml(this.datesEnd)}" style="width:128px">
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
<input id="datesRoundTripCheck" type="checkbox" ${this.datesRoundTrip ? 'checked' : ''}>${escapeHtml(t('components.airlineIntel.roundTrip'))}
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
${escapeHtml(t('components.airlineIntel.tripDays'))}:
<input id="datesTripDurInput" class="price-input" type="number" min="1" value="${this.datesTripDuration}" style="width:44px">
</label>
<select id="datesCabinSelect" class="price-input" style="width:110px">
<option value="ECONOMY"${this.pricesCabin === 'ECONOMY' ? ' selected' : ''}>Economy</option>
<option value="PREMIUM_ECONOMY"${this.pricesCabin === 'PREMIUM_ECONOMY' ? ' selected' : ''}>Premium Economy</option>
<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>
</div>
<div id="datesInlineErr" style="color:#ef4444;font-size:11px;min-height:14px"></div>`;
const rows = sorted.map(q => {
const exp = isPriceExpired(q);
const currency = q.currency || this.pricesCurrency.toUpperCase();
return `
<div class="price-row" style="${exp ? 'opacity:0.4;' : ''}">
<div class="price-carrier">${escapeHtml(q.carrierName || q.carrierIata || '\u2014')}</div>
<div class="price-route" style="flex:1">${escapeHtml(q.origin)} \u2192 ${escapeHtml(q.destination)}</div>
<div class="price-amount" style="font-weight:700;color:${exp ? '#6b7280' : 'var(--accent,#60a5fa)'}">${currency} ${Math.round(q.priceAmount)}</div>
<div class="price-dur">${fmtMin(q.durationMinutes)}</div>
<div class="price-stops">${q.stops === 0 ? 'nonstop' : `${q.stops} stop`}</div>
${expCountdown(q.expiresAt, now)}
</div>`;
}).join('');
this.content.innerHTML = `${searchForm}<div class="prices-list">${rows}</div>`;
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 `
<div class="dp-row">
<span class="dp-date">${escapeHtml(d.date)}</span>
${d.returnDate ? `<span class="dp-return">${escapeHtml(d.returnDate)}</span>` : ''}
<span class="dp-price ${cls}">${Math.round(d.price).toLocaleString()}</span>
</div>`;
}).join('');
body = `<div class="dp-list">${rows}</div>`;
} else if (this.pricesError) {
body = `<div class="no-data" style="color:#ef4444">${escapeHtml(this.pricesError)}</div>`;
} else {
body = `<div class="no-data">${escapeHtml(t('components.airlineIntel.enterDateRange'))}</div>`;
}
this.content.innerHTML = `${toggle}${form}${degradedBanner}${body}`;
}
}
/* Styles moved to panels.css (PERF-012) */

View File

@@ -807,7 +807,17 @@
"enterRoute": "Enter route and search for prices.",
"cachedInsight": "Cached insight",
"demoMode": "DEMO MODE",
"pricesIndicative": "All prices indicative"
"pricesIndicative": "All prices indicative",
"searchFlights": "Search Flights",
"bestDates": "Best Dates",
"cabinClass": "Cabin",
"enterRouteAndDate": "Enter a route and date to search",
"enterDateRange": "Enter a route and date range",
"degradedResults": "Some results may be incomplete",
"nonstop": "nonstop",
"roundTrip": "Round-trip",
"tripDays": "Trip days",
"cheapest": "Cheapest"
},
"goodThingsDigest": {
"noStories": "No stories available",

View File

@@ -9,6 +9,8 @@ import {
type PriceQuote as ProtoPriceQuote,
type AviationNewsItem as ProtoAviationNews,
type CabinClass,
type GoogleFlightResult as ProtoGoogleFlightResult,
type DatePriceEntry as ProtoDatePriceEntry,
} from '@/generated/client/worldmonitor/aviation/v1/service_client';
import { createCircuitBreaker } from '@/utils';
import { getHydratedData } from '@/services/bootstrap';
@@ -137,6 +139,41 @@ export interface AviationNewsItem {
matchedEntities: string[];
}
export interface GoogleFlightLeg {
airlineCode: string;
flightNumber: string;
departureAirport: string;
arrivalAirport: string;
departureDatetime: string; // local ISO datetime, no UTC offset
arrivalDatetime: string;
durationMinutes: number;
}
export interface GoogleFlightItinerary {
legs: GoogleFlightLeg[];
price: number;
durationMinutes: number;
stops: number;
}
export interface GoogleFlightsResult {
flights: GoogleFlightItinerary[];
degraded: boolean;
error: string;
}
export interface DatePrice {
date: string; // YYYY-MM-DD
returnDate: string; // YYYY-MM-DD or ''
price: number;
}
export interface GoogleDatesResult {
dates: DatePrice[];
degraded: boolean;
error: string;
}
// ---- Enum maps ----
const SEVERITY_MAP: Record<string, FlightDelaySeverity> = {
@@ -269,6 +306,27 @@ function toDisplayNewsItem(p: ProtoAviationNews): AviationNewsItem {
};
}
function toDisplayGoogleFlight(p: ProtoGoogleFlightResult): GoogleFlightItinerary {
return {
legs: (p.legs ?? []).map(l => ({
airlineCode: l.airlineCode ?? '',
flightNumber: l.flightNumber ?? '',
departureAirport: l.departureAirport ?? '',
arrivalAirport: l.arrivalAirport ?? '',
departureDatetime: l.departureDatetime ?? '',
arrivalDatetime: l.arrivalDatetime ?? '',
durationMinutes: l.durationMinutes ?? 0,
})),
price: p.price ?? 0,
durationMinutes: p.durationMinutes ?? 0,
stops: p.stops ?? 0,
};
}
function toDisplayDatePrice(p: ProtoDatePriceEntry): DatePrice {
return { date: p.date ?? '', returnDate: p.returnDate ?? '', price: p.price ?? 0 };
}
// ---- Client + circuit breakers ----
const client = new AviationServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
@@ -281,6 +339,10 @@ const breakerStatus = createCircuitBreaker<FlightInstance[]>({ name: 'Flight Sta
const breakerTrack = createCircuitBreaker<PositionSample[]>({ name: 'Track Aircraft', cacheTtlMs: 15 * 1000, persistCache: false });
const breakerPrices = createCircuitBreaker<{ quotes: PriceQuote[]; isDemoMode: boolean }>({ name: 'Flight Prices', cacheTtlMs: 10 * 60 * 1000, persistCache: true });
const breakerNews = createCircuitBreaker<AviationNewsItem[]>({ name: 'Aviation News', cacheTtlMs: 15 * 60 * 1000, persistCache: true });
// No client-side cache for Google Flights search (gateway is no-store, prices change rapidly)
const breakerGoogleFlights = createCircuitBreaker<GoogleFlightsResult>({ name: 'Google Flights', cacheTtlMs: 0, persistCache: false });
// 5-min client cache (server has 10-min Redis + medium gateway cache)
const breakerGoogleDates = createCircuitBreaker<GoogleDatesResult>({ name: 'Google Dates', cacheTtlMs: 5 * 60 * 1000, persistCache: false });
// ---- Public API ----
@@ -356,3 +418,40 @@ export async function fetchAviationNews(entities: string[], windowHours = 24, ma
return r.items.map(toDisplayNewsItem);
}, [], { cacheKey });
}
export async function fetchGoogleFlights(opts: {
origin: string; destination: string; departureDate: string;
returnDate?: string; cabinClass?: string; maxStops?: string;
sortBy?: string; passengers?: number;
}): Promise<GoogleFlightsResult> {
const cacheKey = `${opts.origin}:${opts.destination}:${opts.departureDate}:${opts.returnDate ?? ''}:${opts.cabinClass ?? 'ECONOMY'}:${opts.maxStops ?? ''}:${opts.sortBy ?? ''}:${opts.passengers ?? 1}`;
return breakerGoogleFlights.execute(async () => {
const r = await client.searchGoogleFlights({
origin: opts.origin, destination: opts.destination,
departureDate: opts.departureDate, returnDate: opts.returnDate ?? '',
cabinClass: opts.cabinClass ?? 'ECONOMY', maxStops: opts.maxStops ?? '',
departureWindow: '', airlines: [], sortBy: opts.sortBy ?? '',
passengers: opts.passengers ?? 1,
});
return { flights: r.flights.map(toDisplayGoogleFlight), degraded: r.degraded ?? false, error: r.error ?? '' };
}, { flights: [], degraded: true, error: 'Request failed' }, { cacheKey });
}
export async function fetchGoogleDates(opts: {
origin: string; destination: string; startDate: string; endDate: string;
tripDuration?: number; isRoundTrip?: boolean; cabinClass?: string;
maxStops?: string; passengers?: number;
}): Promise<GoogleDatesResult> {
const cacheKey = `${opts.origin}:${opts.destination}:${opts.startDate}:${opts.endDate}:${opts.tripDuration ?? 0}:${opts.isRoundTrip ?? false}:${opts.cabinClass ?? 'ECONOMY'}:${opts.maxStops ?? ''}:${opts.passengers ?? 1}`;
return breakerGoogleDates.execute(async () => {
const r = await client.searchGoogleDates({
origin: opts.origin, destination: opts.destination,
startDate: opts.startDate, endDate: opts.endDate,
tripDuration: opts.tripDuration ?? 0, isRoundTrip: opts.isRoundTrip ?? false,
cabinClass: opts.cabinClass ?? 'ECONOMY', maxStops: opts.maxStops ?? '',
departureWindow: '', airlines: [], sortByPrice: true,
passengers: opts.passengers ?? 1,
});
return { dates: r.dates.map(toDisplayDatePrice), degraded: r.degraded ?? false, error: r.error ?? '' };
}, { dates: [], degraded: true, error: 'Request failed' }, { cacheKey });
}

View File

@@ -1972,51 +1972,12 @@
}
/* ---- Prices tab ---- */
.prices-list {
.price-controls {
display: flex;
flex-direction: column;
}
.price-row {
display: grid;
grid-template-columns: auto 1fr auto auto auto auto;
gap: 6px;
flex-wrap: wrap;
padding: 8px 0;
align-items: center;
gap: 0 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-subtle);
}
.price-row:hover {
background: var(--surface-hover);
}
.price-carrier {
font-weight: 600;
color: var(--text);
font-size: 12px;
white-space: nowrap;
}
.price-route {
color: var(--text-secondary);
font-size: 11px;
}
.price-amount {
font-variant-numeric: tabular-nums;
}
.price-dur {
font-size: 11px;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
text-align: right;
}
.price-stops {
font-size: 10px;
color: var(--text-muted);
text-align: right;
}
.price-input {
@@ -2035,23 +1996,145 @@
border-color: var(--accent);
}
.tp-badge,
.demo-badge {
font-size: 9px;
.price-mode-toggle {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
margin-bottom: 6px;
width: fit-content;
}
.price-mode-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
padding: 5px 12px;
cursor: pointer;
font-family: inherit;
}
.price-mode-btn.active {
background: var(--accent);
color: var(--bg);
font-weight: 600;
padding: 2px 6px;
border-radius: 3px;
letter-spacing: 0.3px;
}
.tp-badge {
background: color-mix(in srgb, var(--accent) 10%, transparent);
.price-mode-btn:not(.active):hover {
background: var(--surface-hover);
}
/* Google Flights itinerary cards */
.gf-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.gf-card {
border: 1px solid var(--border-subtle);
border-radius: 5px;
padding: 8px;
}
.gf-summary {
display: flex;
gap: 10px;
align-items: baseline;
margin-bottom: 6px;
}
.gf-price {
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
font-size: 14px;
}
.demo-badge {
background: color-mix(in srgb, var(--semantic-elevated) 12%, transparent);
color: var(--semantic-elevated);
.gf-total-dur {
font-size: 11px;
color: var(--text-dim);
}
.gf-stops {
font-size: 10px;
color: var(--text-muted);
}
.gf-leg {
display: flex;
gap: 6px;
align-items: center;
font-size: 11px;
color: var(--text-secondary);
padding: 2px 0;
}
.gf-airline {
font-family: var(--font-mono, monospace);
color: var(--text);
min-width: 60px;
}
.gf-dur {
color: var(--text-dim);
}
.gf-degraded {
font-size: 11px;
color: #92400e;
background: color-mix(in srgb, #f59e0b 12%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
border-radius: 4px;
padding: 4px 8px;
margin-bottom: 6px;
}
/* Best Dates grid */
.dp-list {
display: flex;
flex-direction: column;
}
.dp-row {
display: flex;
gap: 10px;
align-items: center;
padding: 5px 8px;
font-size: 11px;
border-bottom: 1px solid var(--border-subtle);
}
.dp-row:hover {
background: var(--surface-hover);
}
.dp-date {
color: var(--text);
font-variant-numeric: tabular-nums;
min-width: 80px;
}
.dp-return {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
min-width: 80px;
}
.dp-price {
font-weight: 600;
font-variant-numeric: tabular-nums;
margin-left: auto;
}
.dp-cheap {
color: #22c55e;
}
.dp-expensive {
color: #f97316;
}
/* ---- News tab ---- */

View File

@@ -0,0 +1,208 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function src(relPath) {
return readFileSync(resolve(root, relPath), 'utf-8');
}
// ---------------------------------------------------------------------------
// 1. Service wrappers — fetchGoogleFlights + fetchGoogleDates
// ---------------------------------------------------------------------------
describe('aviation service — Google Flights wrappers', () => {
const aviation = src('src/services/aviation/index.ts');
it('fetchGoogleFlights is exported and uses circuit breaker with empty fallback', () => {
assert.ok(
aviation.includes('export async function fetchGoogleFlights'),
'fetchGoogleFlights must be exported',
);
assert.ok(
aviation.includes('breakerGoogleFlights.execute'),
'fetchGoogleFlights must use breakerGoogleFlights circuit breaker',
);
assert.ok(
aviation.includes('{ flights: [], degraded: true'),
'fetchGoogleFlights must return empty fallback with degraded:true on failure',
);
});
it('fetchGoogleFlights circuit breaker has cacheTtlMs: 0 (no client-side caching)', () => {
const breakerMatch = aviation.match(/breakerGoogleFlights\s*=\s*createCircuitBreaker[^)]+\)/s);
assert.ok(breakerMatch, 'breakerGoogleFlights definition not found');
assert.ok(
breakerMatch[0].includes('cacheTtlMs: 0'),
'Google Flights breaker must have cacheTtlMs: 0 (prices change rapidly)',
);
});
it('fetchGoogleDates is exported and uses circuit breaker with empty fallback', () => {
assert.ok(
aviation.includes('export async function fetchGoogleDates'),
'fetchGoogleDates must be exported',
);
assert.ok(
aviation.includes('breakerGoogleDates.execute'),
'fetchGoogleDates must use breakerGoogleDates circuit breaker',
);
assert.ok(
aviation.includes('{ dates: [], degraded: true'),
'fetchGoogleDates must return empty fallback with degraded:true on failure',
);
});
it('fetchGoogleDates circuit breaker has 5-min client cache', () => {
const breakerMatch = aviation.match(/breakerGoogleDates\s*=\s*createCircuitBreaker[^)]+\)/s);
assert.ok(breakerMatch, 'breakerGoogleDates definition not found');
assert.ok(
breakerMatch[0].includes('cacheTtlMs: 5 * 60 * 1000') ||
breakerMatch[0].includes('cacheTtlMs: 300000'),
'Google Dates breaker must have 5-minute client cache',
);
});
it('toDisplayGoogleFlight normalizer maps all leg fields from proto', () => {
assert.ok(
aviation.includes('function toDisplayGoogleFlight'),
'toDisplayGoogleFlight normalizer must exist',
);
const normIdx = aviation.indexOf('function toDisplayGoogleFlight');
const normSection = aviation.slice(normIdx, normIdx + 600);
for (const field of ['airlineCode', 'flightNumber', 'departureAirport', 'arrivalAirport', 'departureDatetime', 'arrivalDatetime', 'durationMinutes']) {
assert.ok(normSection.includes(field), `toDisplayGoogleFlight must map field: ${field}`);
}
});
it('toDisplayDatePrice normalizer maps date, returnDate, price', () => {
assert.ok(
aviation.includes('function toDisplayDatePrice'),
'toDisplayDatePrice normalizer must exist',
);
const normIdx = aviation.indexOf('function toDisplayDatePrice');
const normSection = aviation.slice(normIdx, normIdx + 300);
for (const field of ['date', 'returnDate', 'price']) {
assert.ok(normSection.includes(field), `toDisplayDatePrice must map field: ${field}`);
}
});
it('fetchGoogleDates passes isRoundTrip and tripDuration to the request', () => {
const fnIdx = aviation.indexOf('export async function fetchGoogleDates');
const fnSection = aviation.slice(fnIdx, fnIdx + 800);
assert.ok(
fnSection.includes('isRoundTrip') || fnSection.includes('is_round_trip'),
'fetchGoogleDates must pass isRoundTrip param',
);
assert.ok(
fnSection.includes('tripDuration') || fnSection.includes('trip_duration'),
'fetchGoogleDates must pass tripDuration param',
);
});
it('fetchFlightPrices and isPriceExpired are still exported (used by AviationCommandBar)', () => {
assert.ok(
aviation.includes('export async function fetchFlightPrices'),
'fetchFlightPrices must remain exported — AviationCommandBar still uses it',
);
assert.ok(
aviation.includes('export function isPriceExpired'),
'isPriceExpired must remain exported — AviationCommandBar still uses it',
);
});
});
// ---------------------------------------------------------------------------
// 2. AirlineIntelPanel — no auto-fetch on prices tab switch
// ---------------------------------------------------------------------------
describe('AirlineIntelPanel — prices tab never auto-fetches', () => {
const panel = src('src/components/AirlineIntelPanel.ts');
it('switchTab auto-load block does not include prices', () => {
const switchFn = panel.match(/private switchTab[^}]+\}/s);
assert.ok(switchFn, 'switchTab method not found');
const body = switchFn[0];
assert.ok(
!body.includes("tab === 'prices'"),
"switchTab must NOT auto-load prices tab — prices only fetches on explicit search button click",
);
});
it('refresh() skips loading the prices tab', () => {
const refreshFn = panel.match(/private async refresh[^}]+\}/s);
assert.ok(refreshFn, 'refresh() method not found');
const body = refreshFn[0];
assert.ok(
body.includes("'prices'"),
"refresh() must guard against loading the prices tab",
);
assert.ok(
body.includes("!== 'prices'"),
"refresh() must skip prices tab with !== 'prices' guard",
);
});
it('prices tab state uses googleFlightsData and datesData, not pricesData', () => {
assert.ok(
panel.includes('googleFlightsData'),
'AirlineIntelPanel must use googleFlightsData state field',
);
assert.ok(
panel.includes('datesData'),
'AirlineIntelPanel must use datesData state field',
);
assert.ok(
!panel.includes('pricesData'),
'AirlineIntelPanel must NOT use old pricesData field',
);
});
it('loadTab prices branches on pricesMode', () => {
const loadTabFn = panel.match(/private async loadTab[^}]+switch[^}]+\}/s);
assert.ok(loadTabFn, 'loadTab method not found');
assert.ok(
panel.includes("pricesMode === 'dates'") || panel.includes("this.pricesMode === 'dates'"),
"loadTab prices case must branch on pricesMode",
);
});
it('pricesOrigin pre-filled from watchlist routes, not hardcoded IST/LHR', () => {
assert.ok(
panel.includes("wl.routes") && panel.includes("split('-')"),
'pricesOrigin must be pre-filled from wl.routes split',
);
assert.ok(
panel.includes('airports[0]') && panel.includes("?? 'IST'"),
'IST must only appear as final fallback, not hardcoded default',
);
});
it('mode toggle renders [data-price-mode] buttons', () => {
assert.ok(
panel.includes('data-price-mode="search"'),
'renderPrices must include data-price-mode="search" toggle button',
);
assert.ok(
panel.includes('data-price-mode="dates"'),
'renderPrices must include data-price-mode="dates" toggle button',
);
});
it('all server-derived strings pass through escapeHtml', () => {
assert.ok(
panel.includes('escapeHtml(this.pricesError)'),
'pricesError must be passed through escapeHtml',
);
assert.ok(
panel.includes('escapeHtml(leg.airlineCode)') && panel.includes('escapeHtml(leg.flightNumber)'),
'leg fields must be passed through escapeHtml',
);
assert.ok(
panel.includes('escapeHtml(d.date)') && panel.includes('escapeHtml(d.returnDate)'),
'date fields must be passed through escapeHtml',
);
});
});