diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index b122c1b2..95c16736 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -159,6 +159,70 @@ export function runTests() { `multi-model curves must use models_hourly.times instead of hourly.times; got ${independentModelPeak?.label}`, ); + const floatingIsoModelTimelineChart = buildFullDayChartData( + { + city: "paris", + local_date: "2026-06-14", + local_time: "12:00", + temp_symbol: "°C", + tz_offset_seconds: 2 * 3600, + } as any, + { + forecastTodayHigh: 24, + debPrediction: 24, + localDate: "2026-06-14", + localTime: "12:00", + times: ["00:00", "12:00", "23:00"], + temps: [18, 21, 16], + modelTimes: Array.from({ length: 24 }, (_, hour) => `2026-06-14T${String(hour).padStart(2, "0")}:00`), + modelCurves: { + ECMWF: Array.from({ length: 24 }, (_, hour) => hour), + }, + } as any, + true, + ); + const floatingIsoModelLatePoint = floatingIsoModelTimelineChart.data.find( + (point) => point.label === "23:00:00" && point.model_curve_ECMWF === 23, + ); + assert( + floatingIsoModelLatePoint, + "floating ISO model times without an explicit timezone must stay on the city-local clock so late-day forecast points do not disappear", + ); + + const staleDetailWithCurrentRowChart = buildFullDayChartData( + { + city: "paris", + local_date: "2026-06-16", + local_time: "14:59", + temp_symbol: "°C", + tz_offset_seconds: 2 * 3600, + } as any, + { + forecastTodayHigh: 24, + debPrediction: 24, + localDate: "2026-06-14", + localTime: "11:00", + times: ["00:00", "12:00", "23:00"], + temps: [18, 21, 16], + modelTimes: Array.from({ length: 72 }, (_, index) => { + const date = index < 24 ? "2026-06-14" : index < 48 ? "2026-06-15" : "2026-06-16"; + const hour = index % 24; + return `${date}T${String(hour).padStart(2, "0")}:00`; + }), + modelCurves: { + ECMWF: Array.from({ length: 72 }, (_, index) => index), + }, + } as any, + true, + ); + const currentDayModelLatePoint = staleDetailWithCurrentRowChart.data.find( + (point) => point.label === "23:00:00" && point.model_curve_ECMWF === 71, + ); + assert( + currentDayModelLatePoint, + "chart should use the current scan row local date when cached detail localDate is older so today's model curve is drawn through 23:00", + ); + const correctedDetailChart = getTemperatureChartData( { name: "shanghai", @@ -571,6 +635,51 @@ export function runTests() { "non-US airport-primary fallback without explicit source metadata must not default to NOAA MADIS", ); + const parisAirportDisplayNameChart = buildFullDayChartData( + { + city: "paris", + local_date: "2026-06-16", + local_time: "14:59", + tz_offset_seconds: 2 * 60 * 60, + airport: "Paris-Le Bourget 机场", + metar_context: { + station: "LFPB", + station_label: "Paris-Le Bourget Airport", + }, + temp_symbol: "°C", + } as any, + { + localDate: "2026-06-16", + localTime: "14:59", + times: ["00:00", "12:00", "18:00"], + temps: [14, 20, 18], + settlementStationCode: "LFPB", + settlementStationLabel: "Paris-Le Bourget Airport", + airportPrimary: { + temp: 20.0, + obs_time: "2026-06-16T12:59:00Z", + }, + airportPrimaryTodayObs: [ + ["2026-06-16T10:00:00Z", 18], + ["2026-06-16T12:00:00Z", 20], + ], + metarTodayObs: [ + ["2026-06-16T10:30:00Z", 18], + ["2026-06-16T12:30:00Z", 20], + ], + } as any, + false, + ); + const parisAirportDisplayNameSeries = parisAirportDisplayNameChart.series.find((item) => item.key === "madis"); + assert( + parisAirportDisplayNameSeries?.label === "LFPB METAR", + `airport-primary fallback must prefer station code over display name; got ${parisAirportDisplayNameSeries?.label}`, + ); + assert( + !parisAirportDisplayNameChart.series.some((item) => item.key === "metar"), + "same-station airport-primary observations should suppress the redundant METAR line even when cadences differ", + ); + const ankaraScanSeedChart = buildFullDayChartData( { city: "ankara", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 83c7a033..aace1e50 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -665,6 +665,20 @@ function getCityLocalUtcTimestamp( const raw = String(value).trim(); if (!raw) return null; + const floatingIsoDateTime = raw.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2})(?::(\d{2}))?$/, + ); + if (floatingIsoDateTime) { + return Date.UTC( + Number(floatingIsoDateTime[1]), + Number(floatingIsoDateTime[2]) - 1, + Number(floatingIsoDateTime[3]), + Number(floatingIsoDateTime[4]), + Number(floatingIsoDateTime[5]), + floatingIsoDateTime[6] ? Number(floatingIsoDateTime[6]) : 0, + ); + } + if (raw.includes("T") || raw.includes("Z") || raw.includes("-")) { const d = new Date(raw); if (!Number.isNaN(d.getTime())) { @@ -725,12 +739,19 @@ function dateFromLocalTime(value?: string | null) { return match ? `${match[1]}-${match[2]}-${match[3]}` : null; } +function laterLocalDate(left?: string | null, right?: string | null) { + const a = String(left || "").trim(); + const b = String(right || "").trim(); + const isDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + if (isDate(a) && isDate(b)) return a >= b ? a : b; + return isDate(a) ? a : isDate(b) ? b : null; +} + function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) { + const hourlyDate = hourly?.localDate || dateFromLocalTime(hourly?.localTime); + const rowDate = row?.local_date || dateFromLocalTime(row?.local_time); return ( - hourly?.localDate || - dateFromLocalTime(hourly?.localTime) || - row?.local_date || - dateFromLocalTime(row?.local_time) || + laterLocalDate(hourlyDate, rowDate) || new Date().toISOString().slice(0, 10) ); } @@ -855,12 +876,15 @@ function airportCodeForSeriesLabel( const candidates = [ hourly?.airportPrimary?.station_code, (hourly?.airportPrimary as any)?.icao, - row?.airport, + hourly?.settlementStationCode, row?.metar_context?.station, + row?.icao, + row?.station_code, + row?.airport, ]; const code = candidates .map((value) => String(value || "").trim().toUpperCase()) - .find(Boolean); + .find((value) => /^[A-Z0-9]{4}$/.test(value)); return code || ""; } @@ -901,6 +925,38 @@ function airportPrimarySeriesLabel( return canonicalLabel || payloadLabel || "NOAA MADIS"; } +function airportPrimaryUsesMetarFallback( + hourly: HourlyForecast, + isHKO: boolean, + row?: ScanOpportunityRow | null, +) { + if (isHKO) return false; + const cityKey = normalizeCityKey(row?.city); + if (cityKey === "ankara" || cityKey === "istanbul") return false; + const canonicalLabel = canonicalAirportPrimarySourceLabel(hourly); + if (canonicalLabel && canonicalLabel !== "NOAA MADIS") return false; + const payloadLabel = String(hourly?.airportPrimary?.source_label || "").trim(); + return !payloadLabel || isGenericAirportPrimaryLabel(payloadLabel); +} + +function metarStationCodeForSeries(row?: ScanOpportunityRow | null, hourly?: HourlyForecast) { + const candidates = [ + row?.metar_context?.station, + hourly?.settlementStationCode, + row?.station_code, + row?.icao, + ]; + return candidates + .map((value) => String(value || "").trim().toUpperCase()) + .find((value) => /^[A-Z0-9]{4}$/.test(value)) || ""; +} + +function airportPrimaryMatchesMetarStation(hourly: HourlyForecast, row?: ScanOpportunityRow | null) { + const primaryCode = airportCodeForSeriesLabel(hourly, row); + const metarCode = metarStationCodeForSeries(row, hourly); + return Boolean(primaryCode && metarCode && primaryCode === metarCode); +} + function airportPrimaryObservationPoints(hourly: HourlyForecast) { return appendLatestAirportObservation( hourly?.airportPrimaryTodayObs, @@ -1490,6 +1546,7 @@ type HourlyForecast = { multiModelDaily?: Record; probabilities?: LegacyGaussianProbabilitySource | null; settlementTodayObs?: ObsPoint[]; + settlementStationCode?: string | null; settlementStationLabel?: string | null; metarTodayObs?: ObsPoint[]; airportPrimaryTodayObs?: RawObsPoint[]; @@ -1549,6 +1606,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca distribution_all: row.distribution_full || row.distribution_preview || [], }, settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined, + settlementStationCode: row.metar_context?.station || row.station_code || row.icao || null, metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined, airportPrimaryTodayObs: current ? appendRawObservationPoint(undefined, current.time, current.temp) @@ -1596,6 +1654,8 @@ function mergeHourlyWithLiveObservations( airportCurrent: mergeAirportCondition(base.airportCurrent, live.airportCurrent, row, localDate), airportPrimary: mergeAirportCondition(base.airportPrimary, live.airportPrimary, row, localDate), settlementTodayObs: mergeRawObservationPoints(base.settlementTodayObs, live.settlementTodayObs) as ObsPoint[] | undefined, + settlementStationCode: detailSource.settlementStationCode || forecastFallback.settlementStationCode || row?.metar_context?.station || null, + settlementStationLabel: detailSource.settlementStationLabel || forecastFallback.settlementStationLabel || null, metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined, airportPrimaryTodayObs: mergeRawObservationPoints( base.airportPrimaryTodayObs, @@ -1849,6 +1909,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec multiModelDaily: json.multi_model_daily || {}, probabilities: json.probabilities || null, settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined, + settlementStationCode: (json as any)?.settlement_station?.settlement_station_code || (json as any)?.settlement_station?.airport_code || null, settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null, metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, @@ -2845,7 +2906,14 @@ function buildFullDayChartData( Boolean(hourly?.amos?.runway_obs) ); const isRunwaySensorAggregateSource = isAmscSource || isKoreanAmosSource; - const shouldRenderMetar = metarObs.length > 0 && !observationSetContains(finalMadisObs, metarObs); + const isRedundantMetarFallback = + finalMadisObs.length > 0 && + airportPrimaryUsesMetarFallback(hourly, isHKO, row) && + airportPrimaryMatchesMetarStation(hourly, row); + const shouldRenderMetar = + metarObs.length > 0 && + !isRedundantMetarFallback && + !observationSetContains(finalMadisObs, metarObs); const timelineSet = new Set(); runwayHistorySeries.forEach((rhs) => rhs.points.forEach((point) => timelineSet.add(point.ts))); @@ -2950,7 +3018,7 @@ function buildFullDayChartData( series.push({ key: "madis", label: airportPrimarySeriesLabel(hourly, isHKO, row), - source: isHKO ? "HKO" : (hourly?.airportPrimary?.station_code || row?.airport || "MADIS"), + source: isHKO ? "HKO" : (airportCodeForSeriesLabel(hourly, row) || row?.airport || "MADIS"), color: "#0284c7", dashed: isHKO ? true : false, values: madisVals,