mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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) */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 ---- */
|
||||
|
||||
208
tests/aviation-prices.test.mjs
Normal file
208
tests/aviation-prices.test.mjs
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user