diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index dcb24583..dcf0ff9c 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -408,6 +408,136 @@ export function runTests() { "older automatic detail refreshes must preserve the newer visible chart probability payload", ); + const richGuangzhouDetail = { + ...seededGuangzhou, + localDate: "2026-06-10", + localTime: "12:50", + times: ["00:00", "12:00", "18:00"], + temps: [25, 31, 28], + debPrediction: 32.2, + debHourlyPath: { + times: ["00:00", "12:00", "18:00"], + temps: [25.2, 32.2, 28.4], + }, + modelTimes: ["00:00", "12:00", "18:00"], + modelCurves: { + GFS: [25, 31.5, 28], + ECMWF: [24.8, 32, 28.2], + }, + multiModelDaily: { + GFS: { high: 31.5 }, + ECMWF: { high: 32 }, + }, + airportPrimary: { temp: 28.4, obs_time: "12:50", max_so_far: 31 }, + airportPrimaryTodayObs: [["12:50", 28.4]], + } as any; + const freshObservationOnlyDetail = { + ...seededGuangzhou, + localDate: "2026-06-10", + localTime: "12:55", + times: ["00:00", "12:00", "18:00"], + temps: [25, 31, 28], + debPrediction: null, + debHourlyPath: null, + modelTimes: undefined, + modelCurves: undefined, + multiModelDaily: {}, + airportPrimary: { temp: 28.9, obs_time: "12:55", max_so_far: 31 }, + airportPrimaryTodayObs: [["12:55", 28.9]], + } as any; + const mergedFreshObservationWithRichDetail = mergeHourlyWithLiveObservations( + freshObservationOnlyDetail, + richGuangzhouDetail, + guangzhouRow, + ); + assert( + mergedFreshObservationWithRichDetail?.debPrediction === 32.2, + "fresh observation-only detail refreshes must preserve the existing DEB prediction", + ); + assert( + Object.keys(mergedFreshObservationWithRichDetail?.modelCurves || {}).length === 2, + "fresh observation-only detail refreshes must preserve existing multi-model hourly curves", + ); + assert( + Object.keys(mergedFreshObservationWithRichDetail?.multiModelDaily || {}).length === 2, + "fresh observation-only detail refreshes must preserve existing multi-model daily payloads", + ); + assert( + mergedFreshObservationWithRichDetail?.airportPrimary?.obs_time === "12:55", + "fresh observation-only detail refreshes must still update the live observation timestamp", + ); + + const guangzhouNonUsMadisChart = buildFullDayChartData( + { + city: "guangzhou", + local_date: "2026-06-10", + local_time: "12:55", + tz_offset_seconds: 8 * 60 * 60, + airport: "ZGGG", + temp_symbol: "°C", + } as any, + { + localDate: "2026-06-10", + localTime: "12:55", + times: ["00:00", "12:00", "18:00"], + temps: [25, 31, 28], + airportPrimary: { + source_code: "madis_hfmetar", + source_label: "NOAA MADIS", + station_code: "ZGGG", + temp: 28.9, + obs_time: "2026-06-10T04:55:00Z", + }, + airportPrimaryTodayObs: [["2026-06-10T04:55:00Z", 28.9]], + } as any, + false, + ); + const guangzhouNonUsMadisSeries = guangzhouNonUsMadisChart.series.find((item) => item.key === "madis"); + assert( + guangzhouNonUsMadisSeries?.label === "ZGGG METAR", + "NOAA MADIS label should be reserved for US airports; non-US airport-primary fallback should use METAR wording", + ); + + const guangzhouRunwayWithBadMadisChart = buildFullDayChartData( + { + city: "guangzhou", + local_date: "2026-06-10", + local_time: "12:55", + tz_offset_seconds: 8 * 60 * 60, + airport: "ZGGG", + temp_symbol: "°C", + } as any, + { + localDate: "2026-06-10", + localTime: "12:55", + times: ["00:00", "12:00", "18:00"], + temps: [25, 31, 28], + airportPrimary: { + source_code: "madis_hfmetar", + source_label: "NOAA MADIS", + station_code: "ZGGG", + temp: 28.9, + obs_time: "2026-06-10T04:55:00Z", + }, + airportPrimaryTodayObs: [["2026-06-10T04:55:00Z", 28.9]], + runwayPlateHistory: { + "02L/20R": [ + { timestamp: "12:51", temp_c: 28, value: 28 }, + { timestamp: "12:55", temp_c: 28.4, value: 28.4 }, + ], + }, + } as any, + false, + ); + assert( + guangzhouRunwayWithBadMadisChart.series.some((item) => item.key === "runway_02L_20R"), + "Guangzhou runway history should render the settlement runway line", + ); + assert( + !guangzhouRunwayWithBadMadisChart.series.some((item) => item.key === "madis"), + "AMSC runway cities should not show a redundant NOAA MADIS aggregate series when runway observations are present", + ); + const chengduDetail = { forecastTodayHigh: null, debPrediction: 31, diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index d3ea866d..56ab9a8c 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -726,6 +726,26 @@ function canonicalAirportPrimarySourceLabel(hourly: HourlyForecast) { return ""; } +function airportCodeForSeriesLabel( + hourly: HourlyForecast, + row?: ScanOpportunityRow | null, +) { + const candidates = [ + hourly?.airportPrimary?.station_code, + (hourly?.airportPrimary as any)?.icao, + row?.airport, + row?.metar_context?.station, + ]; + const code = candidates + .map((value) => String(value || "").trim().toUpperCase()) + .find(Boolean); + return code || ""; +} + +function isUsAirportCode(value: string) { + return /^K[A-Z0-9]{3}$/.test(String(value || "").trim().toUpperCase()); +} + function isGenericAirportPrimaryLabel(label: string) { const normalized = label.trim().toLowerCase(); return ( @@ -736,12 +756,20 @@ function isGenericAirportPrimaryLabel(label: string) { ); } -function airportPrimarySeriesLabel(hourly: HourlyForecast, isHKO: boolean) { +function airportPrimarySeriesLabel( + hourly: HourlyForecast, + isHKO: boolean, + row?: ScanOpportunityRow | null, +) { if (isHKO) return "HKO"; const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly); if (canonicalLabel === "MGM") return canonicalLabel; const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim(); if (payloadLabel && !isGenericAirportPrimaryLabel(payloadLabel)) return payloadLabel; + if (canonicalLabel === "NOAA MADIS" && !isUsAirportCode(airportCodeForSeriesLabel(hourly, row))) { + const stationCode = airportCodeForSeriesLabel(hourly, row); + return stationCode ? `${stationCode} METAR` : "METAR"; + } return canonicalLabel || payloadLabel || "NOAA MADIS"; } @@ -1143,6 +1171,39 @@ function hasFullHourlyDetailPayload(hourly: HourlyForecast) { ); } +function hasArrayItems(value: T[] | null | undefined): value is T[] { + return Array.isArray(value) && value.length > 0; +} + +function hasProbabilityPayload(value: LegacyGaussianProbabilitySource | null | undefined) { + const distribution = + value?.distribution_all || + value?.distribution || + []; + return Boolean(value?.mu != null || distribution.length > 0 || value?.engine); +} + +function preferNumber( + primary: number | null | undefined, + fallback: number | null | undefined, +) { + return validNumber(primary) ?? validNumber(fallback) ?? null; +} + +function preferArray( + primary: T[] | null | undefined, + fallback: T[] | null | undefined, +) { + return hasArrayItems(primary) ? primary : fallback; +} + +function preferRecord( + primary: Record | null | undefined, + fallback: Record | null | undefined, +) { + return hasRecordEntries(primary) ? primary : fallback; +} + function latestRawObservationRank( points: RawObsPoint[] | null | undefined, row: ScanOpportunityRow | null, @@ -1372,6 +1433,7 @@ function mergeHourlyWithLiveObservations( if (!live) return base; if (hourlyLocalDatesConflict(base, live, row)) return base; const detailSource = shouldKeepLiveHourlyDetailPayload(base, live, row) ? live : base; + const forecastFallback = detailSource === base ? live : base; const localDate = detailSource.localDate || base.localDate || live.localDate || row?.local_date || null; const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory); const amos = runwayPlateHistory @@ -1384,6 +1446,19 @@ function mergeHourlyWithLiveObservations( ...detailSource, localDate, localTime: live.localTime || base.localTime, + forecastTodayHigh: preferNumber(detailSource.forecastTodayHigh, forecastFallback.forecastTodayHigh), + debPrediction: preferNumber(detailSource.debPrediction, forecastFallback.debPrediction), + debQuality: hasRecordEntries(detailSource.debQuality) ? detailSource.debQuality : forecastFallback.debQuality, + debHourlyPath: detailSource.debHourlyPath || forecastFallback.debHourlyPath || null, + times: preferArray(detailSource.times, forecastFallback.times) || [], + temps: preferArray(detailSource.temps, forecastFallback.temps) || [], + modelTimes: preferArray(detailSource.modelTimes, forecastFallback.modelTimes) || undefined, + modelCurves: preferRecord(detailSource.modelCurves, forecastFallback.modelCurves) || undefined, + forecastDaily: preferArray(detailSource.forecastDaily, forecastFallback.forecastDaily) || [], + multiModelDaily: preferRecord(detailSource.multiModelDaily, forecastFallback.multiModelDaily) || {}, + probabilities: hasProbabilityPayload(detailSource.probabilities) + ? detailSource.probabilities + : forecastFallback.probabilities || null, runwayPlateHistory, amos, airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate), @@ -2435,9 +2510,14 @@ function buildFullDayChartData( const isHKOCity = settlementCityKey === 'hongkong' || settlementCityKey === 'laufaushan' || settlementCityKey === 'shenzhen' || (row?.city || '').toLowerCase().includes('hong kong') || (row?.city || '').toLowerCase().includes('lau fau shan'); + const airportPrimarySourceText = [ + (hourly?.airportPrimary as any)?.source, + hourly?.airportPrimary?.source_code, + hourly?.airportPrimary?.source_label, + ].map((value) => String(value || "").toLowerCase()).join(" "); const isAmscSource = - (hourly?.airportPrimary as any)?.source === "amsc_awos" || - String(hourly?.airportPrimary?.source_label || "").toLowerCase().includes("amsc"); + airportPrimarySourceText.includes("amsc") || + (AMSC_RUNWAY_CITIES.has(settlementCityKey) && runwayHistorySeries.length > 0); const isKoreanAmosSource = (settlementCityKey === "seoul" || settlementCityKey === "busan") && ( @@ -2555,7 +2635,7 @@ function buildFullDayChartData( if (madisVals.some((v) => v !== null)) { series.push({ key: "madis", - label: airportPrimarySeriesLabel(hourly, isHKO), + label: airportPrimarySeriesLabel(hourly, isHKO, row), source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), color: "#0284c7", dashed: isHKO ? true : false,