diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index aec414f3..0f0c549c 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -35,6 +35,15 @@ import { } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; +function formatCityLocalDate(tzOffsetSeconds: number | null | undefined) { + const cityOffsetMs = (tzOffsetSeconds ?? 0) * 1000; + const cityNow = new Date(Date.now() + cityOffsetMs + new Date().getTimezoneOffset() * 60_000); + const y = cityNow.getFullYear(); + const m = String(cityNow.getMonth() + 1).padStart(2, "0"); + const d = String(cityNow.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -74,12 +83,16 @@ export function LiveTemperatureThresholdChart({ const hasLoadedHourlyDetailRef = useRef(false); const lastPatchAtRef = useRef(Date.now()); const lastAppliedPatchRevisionRef = useRef(0); + const localDayRolloverFetchDateRef = useRef(""); const [showRunwayDetails, setShowRunwayDetails] = useState(true); const [refAreaLeft, setRefAreaLeft] = useState(null); const [refAreaRight, setRefAreaRight] = useState(null); const [zoomRange, setZoomRange] = useState<[number, number] | null>(null); const [targetResolution, setTargetResolution] = useState("10m"); + const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() => + formatCityLocalDate(row?.tz_offset_seconds), + ); useEffect(() => { setUserToggledKeys({}); @@ -91,8 +104,18 @@ export function LiveTemperatureThresholdChart({ hasLoadedHourlyDetailRef.current = false; lastPatchAtRef.current = Date.now(); lastAppliedPatchRevisionRef.current = 0; + localDayRolloverFetchDateRef.current = ""; + setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); }, [city]); + useEffect(() => { + setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); + const id = setInterval(() => { + setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); + }, 60_000); + return () => clearInterval(id); + }, [row?.tz_offset_seconds]); + useEffect(() => { if (!city) { setIsHourlyLoading(false); @@ -216,7 +239,40 @@ export function LiveTemperatureThresholdChart({ }; }, [city, compact, isActive, isMaximized]); - const { data, series } = useMemo(() => buildFullDayChartData(row, hourly, isEn), [row, hourly, isEn]); + useEffect(() => { + if (!city || !currentCityLocalDate) return; + const loadedLocalDate = hourly?.localDate || row?.local_date || ""; + if (currentCityLocalDate === loadedLocalDate) return; + if (localDayRolloverFetchDateRef.current === currentCityLocalDate) return; + + localDayRolloverFetchDateRef.current = currentCityLocalDate; + let cancelled = false; + fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) + .then((data) => { + if (cancelled || !data) return; + hasLoadedHourlyDetailRef.current = true; + setHourly(data); + }) + .catch(() => { + if (!cancelled) localDayRolloverFetchDateRef.current = ""; + }); + + return () => { + cancelled = true; + }; + }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution]); + + const chartHourly = useMemo(() => { + if (!hourly) return hourly; + const loadedLocalDate = hourly.localDate || row?.local_date || ""; + if (currentCityLocalDate && currentCityLocalDate !== loadedLocalDate) { + return { ...hourly, localDate: currentCityLocalDate }; + } + return hourly; + }, [hourly, currentCityLocalDate, row?.local_date]); + const chartLocalDate = chartHourly?.localDate || row?.local_date || currentCityLocalDate; + + const { data, series } = useMemo(() => buildFullDayChartData(row, chartHourly, isEn), [row, chartHourly, isEn]); const autoWindowRange = useMemo( () => (viewMode === "auto" ? getDebPeakWindowRange(data, series) : null), @@ -253,17 +309,17 @@ export function LiveTemperatureThresholdChart({ const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { - let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, row?.local_date || null); - if (!obs.length && !hourly?.runwayPlateHistory) { - const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, row?.local_date || null); + let obs = normObs(chartHourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, undefined, chartLocalDate || null); + if (!obs.length && !chartHourly?.runwayPlateHistory) { + const mObs = normObs(chartHourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset, undefined, chartLocalDate || null); if (mObs.length > 0) { obs = mObs; } } return obs; - }, [row, hourly, tzOffset]); + }, [row, chartHourly, tzOffset, chartLocalDate]); - const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]); + const runwayPlates = useMemo(() => buildRunwayPlates(chartHourly?.amos, row, settlementObs), [chartHourly?.amos, row, settlementObs]); const hasRunwayData = runwayPlates.length > 0; const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]); @@ -293,26 +349,26 @@ export function LiveTemperatureThresholdChart({ metarHighLabel, runwayHeaderLabel, runwayHighLabel, - } = useMemo(() => getLiveObservationLabels(row, hourly), [row, hourly]); + } = useMemo(() => getLiveObservationLabels(row, chartHourly), [row, chartHourly]); const { currentRunwayTemp, observedHighMetar, observedHighRunway } = useMemo( - () => getObservationDisplayMetrics(row, hourly, settlementPlate), - [row, hourly, settlementPlate], + () => getObservationDisplayMetrics(row, chartHourly, settlementPlate), + [row, chartHourly, settlementPlate], ); const displayRunwayTemp = liveTemp ?? currentRunwayTemp; - const wundergroundDailyHigh = validNumber(hourly?.airportCurrent?.max_so_far ?? hourly?.airportPrimary?.max_so_far) ?? null; + const wundergroundDailyHigh = validNumber(chartHourly?.airportCurrent?.max_so_far ?? chartHourly?.airportPrimary?.max_so_far) ?? null; - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const localDateStr = chartLocalDate || new Date().toISOString().slice(0, 10); const modelSources = (row?.model_cluster_sources && Object.keys(row.model_cluster_sources).length > 0) ? row.model_cluster_sources - : (hourly?.multiModelDaily?.[localDateStr]?.models || null); + : (chartHourly?.multiModelDaily?.[localDateStr]?.models || null); const modelValues = Object.values(modelSources || {}) .map(validNumber) .filter((v): v is number => v !== null); const modelMin = modelValues.length ? Math.min(...modelValues) : (row?.cluster_core_low ?? null); const modelMax = modelValues.length ? Math.max(...modelValues) : (row?.cluster_core_high ?? null); - const debVal = validNumber(hourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null; + const debVal = validNumber(chartHourly?.debPrediction) ?? validNumber(row?.deb_prediction) ?? null; const spread = (modelMax !== null && modelMin !== null) ? modelMax - modelMin : null; const spreadLabel = spread === null ? "" : (spread <= 2.0 ? "低分歧" : (spread <= 4.0 ? "中等分歧" : "高分歧")); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 4af44e37..16d67639 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -120,6 +120,12 @@ export function runTests() { assert(chart.includes("isHourlyLoading"), "temperature chart must keep a per-panel hourly loading state"); assert(chartCanvas.includes("加载图表") && chartCanvas.includes("absolute inset-2"), "temperature chart must render an in-chart loading overlay"); assert(chart.includes("hasLoadedHourlyDetailRef"), "temperature chart must distinguish first load from background refreshes"); + assert(chart.includes("currentCityLocalDate"), "temperature chart must track the current city-local date while the page stays open"); + assert(chart.includes("localDayRolloverFetchDateRef"), "temperature chart must avoid duplicate midnight rollover detail fetches"); + assert( + chart.includes("ignoreCache: true") && chart.includes("currentCityLocalDate !== loadedLocalDate"), + "temperature chart must background-refresh full city detail when the city-local day rolls over", + ); const fallbackRefreshBlock = chart.match(/const refreshFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; assert( !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index fba50346..b3c3de74 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -394,12 +394,12 @@ export function runTests() { null, ); assert( - panamaLabels.runwayHeaderLabel === "机场气象站", - "Panama City/MPMG should not be labeled as runway observations when no runway sensor feed exists", + panamaLabels.runwayHeaderLabel === "机场报文", + "Panama City/MPMG should be labeled as an airport METAR report when no station or runway sensor feed exists", ); assert( - panamaLabels.runwayHighLabel === "机场气象站", - "Panama City high label should use airport weather station wording, not runway wording", + panamaLabels.runwayHighLabel === "机场报文", + "Panama City high label should use airport METAR report wording, not weather-station or runway wording", ); const newYorkWithMadis = __buildTemperatureChartDataForTest( @@ -610,6 +610,36 @@ export function runTests() { "Full-day chart should start at local 00:00 when the DEB hourly path has a midnight point", ); + const chongqingRolledToNextDay = __buildTemperatureChartDataForTest( + { + city: "chongqing", + local_date: "2026-05-26", + local_time: "23:50", + tz_offset_seconds: 8 * 60 * 60, + deb_prediction: 22, + } as any, + { + localDate: "2026-05-27", + localTime: "00:34", + times: ["00:00", "06:00", "12:00", "18:00", "23:00"], + temps: [25.2, 25.6, 28.4, 27.6, 26.1], + debPrediction: 30.1, + } as any, + "1D", + ); + const chongqingNextDayStart = Date.UTC(2026, 4, 27, 0, 0, 0); + const chongqingNextDayEnd = Date.UTC(2026, 4, 28, 0, 0, 0); + assert( + chongqingRolledToNextDay.data.every((point) => point.ts >= chongqingNextDayStart && point.ts < chongqingNextDayEnd), + "Full-day chart should switch to the city-detail localDate after local midnight instead of keeping stale terminal row.local_date", + ); + const chongqingNextDayDeb = seriesByKey(chongqingRolledToNextDay.series, "hourly_forecast") as any; + const chongqingNextDayDebValues = chongqingNextDayDeb.values.filter((value: number | null): value is number => value !== null); + assert( + Math.max(...chongqingNextDayDebValues) === 30.1, + "DEB curve should use the next local day's detail debPrediction after local midnight", + ); + // ── Runway range band and runway_max test ── const shanghaiWithBand = __buildTemperatureChartDataForTest( { diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index dbfbf072..96659ae3 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -327,6 +327,21 @@ function getLocalDayBounds(localDateStr: string): LocalDayBounds | null { return Number.isFinite(start) ? { start, end: start + DAY_MS } : null; } +function dateFromLocalTime(value?: string | null) { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || "").trim()); + return match ? `${match[1]}-${match[2]}-${match[3]}` : null; +} + +function resolveChartLocalDate(row: ScanOpportunityRow | null, hourly: HourlyForecast) { + return ( + hourly?.localDate || + dateFromLocalTime(hourly?.localTime) || + row?.local_date || + dateFromLocalTime(row?.local_time) || + new Date().toISOString().slice(0, 10) + ); +} + function isWithinLocalDay(ts: number | null, bounds: LocalDayBounds | null) { return ts !== null && Number.isFinite(ts) && (!bounds || (ts >= bounds.start && ts < bounds.end)); } @@ -423,7 +438,7 @@ function getObservationDisplayMetrics( settlementPlate?: { maxTemp: number | null } | null, ) { const tzOffset = row?.tz_offset_seconds ?? 0; - const localDateStr = row?.local_date || null; + const localDateStr = resolveChartLocalDate(row, hourly); const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset, MAX_OBS_POINTS, localDateStr); const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset, MAX_OBS_POINTS, localDateStr); const madisObs = normObs(hourly?.airportPrimaryTodayObs, tzOffset, MAX_OBS_POINTS, localDateStr); @@ -509,6 +524,7 @@ function runwayLabelFromPair(rawPair: unknown, index: number) { type HourlyForecast = { forecastTodayHigh?: number | null; debPrediction?: number | null; + localDate?: string | null; localTime?: string | null; times: string[]; temps: Array; @@ -531,6 +547,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca return { forecastTodayHigh: null, debPrediction: validNumber(row.deb_prediction), + localDate: row.local_date || null, localTime: row.local_time || null, times: [], temps: [], @@ -559,6 +576,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec return { forecastTodayHigh: json.forecast?.today_high ?? null, debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, + localDate: json.local_date || (json as any)?.overview?.local_date || null, localTime: json.local_time || null, times: hourlySource.times || [], temps: hourlySource.temps || [], @@ -687,7 +705,7 @@ function getLiveObservationLabels( : isTaipei ? "CWA (10分钟)" : isWeatherStation ? "气象站实测" : isRunwaySensorCity ? "跑道实测 (1分钟)" - : "机场气象站"; + : "机场报文"; const metarHeaderLabel = (isShenzhen || isHKO) ? "天文台实测 (10分钟)" : "METAR 结算 (30分钟)"; @@ -700,7 +718,7 @@ function getLiveObservationLabels( : isTaipei ? "CWA" : isWeatherStation ? "气象站" : isRunwaySensorCity ? "跑道实测" - : "机场气象站"; + : "机场报文"; const metarHighLabel = isShenzhen ? "天文台" : isHKO ? "天文台" @@ -735,6 +753,7 @@ function mergePatchIntoHourly( ...(prev || { forecastTodayHigh: null, debPrediction: null, + localDate: null, localTime: null, times: [], temps: [], @@ -744,6 +763,10 @@ function mergePatchIntoHourly( ...explicitHourlyPatch, }; + if (typeof (changes as any).local_date === "string") { + next.localDate = (changes as any).local_date; + } + if (changes.amos && typeof changes.amos === "object") { const oldAmos = prev?.amos || {}; const newAmos = changes.amos as AmosData; @@ -982,7 +1005,7 @@ function buildDailyChartData( hourly: HourlyForecast, daysCount: number, ): { data: Array>; series: EvidenceSeries[] } { - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const localDateStr = resolveChartLocalDate(row, hourly); const slots = generateDailySlots(localDateStr, daysCount); const series: EvidenceSeries[] = [ @@ -1162,7 +1185,7 @@ function buildFullDayChartData( isEn: boolean, ): { data: Array>; series: EvidenceSeries[] } { const tzOffset = row?.tz_offset_seconds ?? 0; - const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const localDateStr = resolveChartLocalDate(row, hourly); const localDayBounds = getLocalDayBounds(localDateStr); const settlementObs = filterTimelinePointsToLocalDay(