diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx index 0baa0a46..d8c93f31 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -26,6 +26,7 @@ import { useAiCityForecast, useCityMarketScan, } from "@/components/dashboard/scan-terminal/use-ai-city-card-data"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getModelView } from "@/lib/model-utils"; import { getTodayPaceView } from "@/lib/pace-utils"; @@ -242,10 +243,11 @@ export function AiPinnedCityCard({ "--"; const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null; const isHkoObservation = isHkoObservationCity(detail); + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentTemp = (isHkoObservation ? detail?.current?.temp ?? row?.current_temp - : detail?.airport_primary?.temp ?? + : displayAirportPrimary?.temp ?? detail?.airport_current?.temp ?? detail?.current?.temp ?? row?.current_temp) ?? null; @@ -274,7 +276,7 @@ export function AiPinnedCityCard({ : detail?.risk?.icao || detail?.current?.station_code || detail?.airport_current?.station_code || - detail?.airport_primary?.station_code || + displayAirportPrimary?.station_code || ""; const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测"; const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations"; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/koreanRunwayObservationDisplay.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/koreanRunwayObservationDisplay.test.ts new file mode 100644 index 00000000..8ace57ad --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/koreanRunwayObservationDisplay.test.ts @@ -0,0 +1,108 @@ +import { + getDisplayAirportPrimary, + shouldSuppressKoreanRunwayObservation, +} from "@/lib/airport-observation-display"; +import type { CityDetail } from "@/lib/dashboard-types"; +import { getTodayPaceView } from "@/lib/pace-utils"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +function baseDetail(overrides: Partial): CityDetail { + return { + name: "seoul", + display_name: "Seoul", + local_date: "2026-05-17", + local_time: "12:00", + temp_symbol: "°C", + current: { + temp: 25, + max_so_far: 27, + max_temp_time: null, + wu_settlement: null, + station_code: "RKSI", + obs_time: "12:00", + obs_age_min: 1, + wind_speed_kt: null, + wind_dir: null, + humidity: null, + cloud_desc: null, + clouds_raw: [], + visibility_mi: null, + wx_desc: null, + }, + hourly: { + times: ["10:00", "11:00", "12:00", "13:00"], + temps: [24, 25, 26, 27], + }, + forecast: { today_high: 29 }, + deb: { prediction: 29 }, + ...overrides, + } as CityDetail; +} + +export function runTests() { + const seoul = baseDetail({ + airport_primary: { + temp: 40, + max_so_far: 41, + obs_time: "12:00", + source_code: "amos", + source_label: "AMOS", + station_code: "RKSI", + }, + airport_current: { + temp: 26, + obs_time: "12:00", + source_label: "METAR", + station_code: "RKSI", + }, + amos: { + source: "amos", + runway_temp_range: [39, 41], + }, + }); + + assert( + shouldSuppressKoreanRunwayObservation(seoul), + "Seoul AMOS runway observation should be suppressed in web city decision", + ); + assert( + getDisplayAirportPrimary(seoul) == null, + "Seoul airport_primary AMOS runway observation must not be exposed for display", + ); + + const pace = getTodayPaceView(seoul, "zh-CN"); + assert(pace?.observedNow === 26, "pace view should fall back to METAR/current, not AMOS runway temp"); + + const busan = baseDetail({ + name: "busan", + display_name: "Busan", + airport_primary: { + temp: 35, + obs_time: "12:00", + source_label: "AMSC AWOS", + station_code: "RKPK", + }, + }); + assert( + getDisplayAirportPrimary(busan) == null, + "Busan AMSC/AWOS runway observation must not be exposed for display", + ); + + const tokyo = baseDetail({ + name: "tokyo", + display_name: "Tokyo", + airport_primary: { + temp: 31, + obs_time: "12:00", + source_label: "AMOS", + station_code: "RJTT", + }, + }); + assert( + getDisplayAirportPrimary(tokyo)?.temp === 31, + "non-Korean cities should not be affected by the Seoul/Busan runway suppression", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts index 523709d8..b3721e7e 100644 --- a/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts +++ b/frontend/components/dashboard/scan-terminal/ai-city-forecast-stream-state.ts @@ -9,6 +9,7 @@ import type { AiCityForecastPayload, AiCityForecastState, } from "@/components/dashboard/scan-terminal/types"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; import type { CityDetail } from "@/lib/dashboard-types"; import { normalizeCityKey } from "./decision-utils"; @@ -167,11 +168,12 @@ function computeFallbackPredictedMax(detail: CityDetail | null): number | null { predicted = values.reduce((a, b) => a + b, 0) / values.length; if (predicted == null) { const isHkoObservation = isHkoObservationCity(detail); + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentTemp = (isHkoObservation ? detail?.current?.temp : detail?.airport_current?.temp ?? - detail?.airport_primary?.temp ?? + displayAirportPrimary?.temp ?? detail?.current?.temp) ?? null; predicted = currentTemp != null && Number.isFinite(currentTemp) ? currentTemp @@ -193,11 +195,12 @@ export function buildAiCityFallbackPayload({ }): AiCityForecastPayload { const tempSymbol = detail?.temp_symbol || "°C"; const isHkoObservation = isHkoObservationCity(detail); + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentTemp = (isHkoObservation ? detail?.current?.temp : detail?.airport_current?.temp ?? - detail?.airport_primary?.temp ?? + displayAirportPrimary?.temp ?? detail?.current?.temp) ?? null; const currentText = currentTemp != null && Number.isFinite(Number(currentTemp)) diff --git a/frontend/lib/airport-observation-display.ts b/frontend/lib/airport-observation-display.ts new file mode 100644 index 00000000..508b2372 --- /dev/null +++ b/frontend/lib/airport-observation-display.ts @@ -0,0 +1,55 @@ +import type { AirportCurrentConditions, CityDetail } from "@/lib/dashboard-types"; + +function normalizeCityKey(value?: string | null) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); +} + +const KOREAN_RUNWAY_OBSERVATION_CITY_KEYS = new Set(["seoul", "busan"]); + +function hasRunwayObservationPayload(detail: CityDetail) { + return Boolean( + detail.amos?.runway_temp_range || + (detail.amos?.runway_temps?.length || 0) > 0 || + (detail.amos?.runway_obs?.temperatures?.length || 0) > 0 || + (detail.amos?.runway_obs?.point_temperatures?.length || 0) > 0, + ); +} + +function isRunwayObservationSource( + airportPrimary?: AirportCurrentConditions | null, + detail?: CityDetail | null, +) { + const sourceText = [ + airportPrimary?.source_code, + airportPrimary?.source_label, + airportPrimary?.station_label, + detail?.amos?.source, + detail?.amos?.source_label, + detail?.amos?.temp_source, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return ( + sourceText.includes("amos") || + sourceText.includes("amsc") || + sourceText.includes("awos") || + sourceText.includes("runway") || + sourceText.includes("跑道") + ); +} + +export function shouldSuppressKoreanRunwayObservation(detail?: CityDetail | null) { + if (!detail) return false; + const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name); + if (!KOREAN_RUNWAY_OBSERVATION_CITY_KEYS.has(cityKey)) return false; + return isRunwayObservationSource(detail.airport_primary, detail) || hasRunwayObservationPayload(detail); +} + +export function getDisplayAirportPrimary(detail?: CityDetail | null) { + if (!detail || shouldSuppressKoreanRunwayObservation(detail)) return undefined; + return detail.airport_primary; +} diff --git a/frontend/lib/chart-utils.ts b/frontend/lib/chart-utils.ts index cb71fdd5..9035fc18 100644 --- a/frontend/lib/chart-utils.ts +++ b/frontend/lib/chart-utils.ts @@ -1,4 +1,5 @@ import type { CityDetail } from "@/lib/dashboard-types"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; import type { Locale } from "@/lib/i18n"; import { getNoaaStationCode, @@ -326,6 +327,7 @@ function normalizeObservationTimeForChart( function buildCurrentObservationFallback( detail: CityDetail, ): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> { + const displayAirportPrimary = getDisplayAirportPrimary(detail); const candidates: Array<{ sourceLabel?: string | null; temp?: number | null; @@ -337,9 +339,9 @@ function buildCurrentObservationFallback( time: detail.current?.obs_time || detail.current?.report_time, }, { - sourceLabel: detail.airport_primary?.source_label || "METAR", - temp: detail.airport_primary?.temp, - time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time, + sourceLabel: displayAirportPrimary?.source_label || "METAR", + temp: displayAirportPrimary?.temp, + time: displayAirportPrimary?.obs_time || displayAirportPrimary?.report_time, }, { sourceLabel: detail.airport_current?.source_label || "METAR", diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index acc2d52c..4c0f9b07 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -21,6 +21,7 @@ import { import { formatTafMarkerType } from "@/lib/taf-utils"; import { normalizeHm } from "@/lib/time-utils"; import { getWeatherSummary } from "@/lib/weather-summary-utils"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; export { getTemperatureChartData } from "@/lib/chart-utils"; export { getModelView, getProbabilityView } from "@/lib/model-utils"; @@ -1932,6 +1933,7 @@ function getOfficialObservationCandidates(detail: CityDetail) { function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) { const { mgmNearby } = getOfficialObservationCandidates(detail); + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentSource = String( detail.current?.settlement_source || detail.current?.settlement_source_label || @@ -1940,7 +1942,7 @@ function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) { .trim() .toLowerCase(); const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc"); - const isNmcAirport = String(detail.airport_primary?.source_label || "") + const isNmcAirport = String(displayAirportPrimary?.source_label || "") .trim() .toLowerCase() .includes("nmc"); @@ -1950,8 +1952,8 @@ function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) { temp: detail.airport_current?.temp, }, { - sourceLabel: detail.airport_primary?.source_label || "METAR", - temp: isNmcAirport ? null : detail.airport_primary?.temp, + sourceLabel: displayAirportPrimary?.source_label || "METAR", + temp: isNmcAirport ? null : displayAirportPrimary?.temp, }, { sourceLabel: detail.current?.settlement_source_label, @@ -1988,6 +1990,7 @@ function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) { function getObservationUpdateProfile(detail: CityDetail, locale: Locale) { const { mgmNearby } = getOfficialObservationCandidates(detail); const mgmFirstRecord = asRecord(mgmNearby[0]); + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentSource = String( detail.current?.settlement_source || detail.current?.settlement_source_label || @@ -1998,11 +2001,11 @@ function getObservationUpdateProfile(detail: CityDetail, locale: Locale) { const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc"); const rawValue = firstNonEmptyString([ isNmcCurrent ? "" : detail.current?.obs_time, - localObservationTimeCandidate(detail.airport_primary?.obs_time), + localObservationTimeCandidate(displayAirportPrimary?.obs_time), localObservationTimeCandidate(detail.airport_current?.obs_time), localObservationTimeCandidate(mgmFirstRecord?.obs_time), localObservationTimeCandidate(mgmFirstRecord?.time), - detail.airport_primary?.report_time, + displayAirportPrimary?.report_time, detail.airport_current?.report_time, isNmcCurrent ? "" : detail.current?.report_time, mgmFirstRecord?.report_time, diff --git a/frontend/lib/pace-utils.ts b/frontend/lib/pace-utils.ts index 35bc685a..c369db63 100644 --- a/frontend/lib/pace-utils.ts +++ b/frontend/lib/pace-utils.ts @@ -1,4 +1,5 @@ import type { CityDetail } from "@/lib/dashboard-types"; +import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; import type { Locale } from "@/lib/i18n"; import { hmToMinutes, @@ -18,10 +19,11 @@ export function getTodayPaceView( const times = hourly.times || []; const temps = hourly.temps || []; if (!times.length || !temps.length) return null; + const displayAirportPrimary = getDisplayAirportPrimary(detail); const currentMinutes = hmToMinutes(detail.local_time) ?? - hmToMinutes(detail.airport_primary?.obs_time) ?? + hmToMinutes(displayAirportPrimary?.obs_time) ?? hmToMinutes(detail.airport_current?.obs_time) ?? hmToMinutes(detail.current?.obs_time); if (currentMinutes == null) return null; @@ -39,7 +41,7 @@ export function getTodayPaceView( if (expectedNow == null) return null; const observedNowCandidate = [ - detail.airport_primary?.temp, + displayAirportPrimary?.temp, detail.airport_current?.temp, detail.current?.temp, ] @@ -75,7 +77,7 @@ export function getTodayPaceView( : `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`; const topObservedCandidate = [ - detail.airport_primary?.max_so_far, + displayAirportPrimary?.max_so_far, detail.airport_current?.max_so_far, detail.current?.max_so_far, observedNow, @@ -105,7 +107,7 @@ export function getTodayPaceView( ).padStart(2, "0")}:00` : "--"; const observedLabel = - detail.airport_primary?.temp != null || detail.airport_current?.temp != null + displayAirportPrimary?.temp != null || detail.airport_current?.temp != null ? isEnglish(locale) ? "Airport obs" : "机场实测"