Keep live chart observations current

This commit is contained in:
2569718930@qq.com
2026-06-10 13:35:23 +08:00
parent 93fe7a9b69
commit efd66ca80c
4 changed files with 240 additions and 7 deletions
@@ -28,7 +28,9 @@ import {
getObservationDisplayMetrics, getObservationDisplayMetrics,
getVisibleTemperatureSeries, getVisibleTemperatureSeries,
isTemperatureSeriesVisibleByDefault, isTemperatureSeriesVisibleByDefault,
mergeHourlyWithLiveObservations,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly,
normObs, normObs,
prefersHighFrequencyRunwayResolution, prefersHighFrequencyRunwayResolution,
readSessionCache, readSessionCache,
@@ -107,6 +109,31 @@ function getLiveTempFromHourly(data: HourlyForecast) {
return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null; return validNumber(data?.airportCurrent?.temp) ?? validNumber(data?.airportPrimary?.temp) ?? null;
} }
function rowObservationSignature(row: ScanOpportunityRow | null) {
if (!row) return "";
const metarContext = row.metar_context || null;
const pieces = [
row.city,
row.current_temp,
row.current_max_so_far,
row.local_time,
(row as any).sse_revision,
metarContext?.airport_current_temp,
metarContext?.airport_max_so_far,
metarContext?.airport_obs_time,
metarContext?.last_temp,
metarContext?.last_time,
metarContext?.last_observation_time,
metarContext?.source,
metarContext?.station_label,
];
const hasObservation =
validNumber(row.current_temp) !== null ||
validNumber(metarContext?.airport_current_temp) !== null ||
validNumber(metarContext?.last_temp) !== null;
return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : "";
}
function getWundergroundDailyHigh(hourly: HourlyForecast) { function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
} }
@@ -437,6 +464,7 @@ export function LiveTemperatureThresholdChart({
const lastAppliedPatchRevisionRef = useRef<number>(0); const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0); const lastProbabilityRefreshAtRef = useRef<number>(0);
const lastForegroundRefreshAtRef = useRef<number>(0); const lastForegroundRefreshAtRef = useRef<number>(0);
const lastRowObservationSignatureRef = useRef<string>("");
const localDayRolloverFetchDateRef = useRef<string>(""); const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState( const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined", () => typeof IntersectionObserver === "undefined",
@@ -457,6 +485,7 @@ export function LiveTemperatureThresholdChart({
const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() => const [currentCityLocalDate, setCurrentCityLocalDate] = useState(() =>
formatCityLocalDate(row?.tz_offset_seconds), formatCityLocalDate(row?.tz_offset_seconds),
); );
const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]);
useEffect(() => { useEffect(() => {
setUserToggledKeys({}); setUserToggledKeys({});
@@ -475,6 +504,7 @@ export function LiveTemperatureThresholdChart({
lastAppliedPatchRevisionRef.current = 0; lastAppliedPatchRevisionRef.current = 0;
lastProbabilityRefreshAtRef.current = 0; lastProbabilityRefreshAtRef.current = 0;
lastForegroundRefreshAtRef.current = 0; lastForegroundRefreshAtRef.current = 0;
lastRowObservationSignatureRef.current = "";
localDayRolloverFetchDateRef.current = ""; localDayRolloverFetchDateRef.current = "";
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city, detailLoadDelayMs]); }, [city, detailLoadDelayMs]);
@@ -521,15 +551,27 @@ export function LiveTemperatureThresholdChart({
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => { const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
if (!data) return; if (!data) return;
const rowSeed = seedHourlyForecastFromRow(row);
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row);
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
if (options?.updateLiveTemp) { if (options?.updateLiveTemp) {
const temp = getLiveTempFromHourly(data); const temp = getLiveTempFromHourly(dataWithCurrentRow);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
} }
setHourly(data); setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row));
setDetailError(null); setDetailError(null);
setShowingStaleDetail(false); setShowingStaleDetail(false);
}, []); }, [row]);
useEffect(() => {
if (!city || !currentRowObservationSignature) return;
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
lastRowObservationSignatureRef.current = currentRowObservationSignature;
const rowSeed = seedHourlyForecastFromRow(row);
const temp = getLiveTempFromHourly(rowSeed);
if (temp !== null) setLiveTemp(temp);
setHourly((prev) => mergeRowObservationIntoHourly(prev ?? rowSeed, row));
}, [city, currentRowObservationSignature, row]);
useEffect(() => { useEffect(() => {
if (!city) { if (!city) {
@@ -91,8 +91,8 @@ export async function runTests() {
); );
assert( assert(
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
chartSource.includes("setHourly(data)"), chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row))"),
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop", "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations",
); );
assert( assert(
chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") && chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") &&
@@ -197,11 +197,11 @@ export async function runTests() {
"city detail charts should show stale cache first and expose a retryable unavailable state", "city detail charts should show stale cache first and expose a retryable unavailable state",
); );
const successfulHourlyDetailBlock = const successfulHourlyDetailBlock =
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[\]\);/.exec(chartSource)?.[0] || ""; /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || "";
assert( assert(
successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setDetailError(null)") &&
successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") &&
successfulHourlyDetailBlock.includes("setHourly(data)"), successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"),
"successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", "successful city detail refreshes must clear stale-cache retry state when fresh detail arrives",
); );
const rawSuccessfulSetHourlyCalls = chartSource const rawSuccessfulSetHourlyCalls = chartSource
@@ -3,7 +3,9 @@ import type { CityDetail } from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { import {
buildFullDayChartData, buildFullDayChartData,
mergeHourlyWithLiveObservations,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly,
seedHourlyForecastFromRow, seedHourlyForecastFromRow,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -293,4 +295,43 @@ export function runTests() {
patchedRunway?.values.some((value) => value === 28.8), patchedRunway?.values.some((value) => value === 28.8),
"SSE runway patches must append directly to the chart series without waiting for a force-refreshed detail payload", "SSE runway patches must append directly to the chart series without waiting for a force-refreshed detail payload",
); );
const guangzhouLaterRow = {
...guangzhouRow,
current_temp: 29.1,
local_time: "12:51",
sse_revision: 43,
metar_context: {
...guangzhouRow.metar_context,
airport_current_temp: 29.1,
airport_obs_time: "12:51",
airport_max_so_far: 29.1,
},
} as any;
const guangzhouRowMerged = mergeRowObservationIntoHourly(seededGuangzhou, guangzhouLaterRow);
const guangzhouRowMergedChart = buildFullDayChartData(guangzhouLaterRow, guangzhouRowMerged, false);
const rowMergedRunway = guangzhouRowMergedChart.series.find((item) => item.key === "runway_02L_20R");
assert(
rowMergedRunway?.values.some((value) => value === 29.1),
"same-city scan row observation changes must merge into chart state without requiring an active-slot click",
);
const staleDetail = {
...seededGuangzhou,
forecastDaily: [{ date: "2026-06-10", max_temp: 31, min_temp: 24 }] as any,
probabilities: { engine: "legacy", distribution: [{ value: 30, probability: 0.4 }] },
runwayPlateHistory: {
"02L/20R": [{ timestamp: "12:45", temp_c: 28.4, value: 28.4 }],
},
airportPrimaryTodayObs: [["12:45", 28.4]],
airportCurrent: { temp: 28.4, obs_time: "12:45", max_so_far: 29 },
airportPrimary: { temp: 28.4, obs_time: "12:45", max_so_far: 29 },
} as any;
const mergedAfterStaleDetail = mergeHourlyWithLiveObservations(staleDetail, guangzhouPatched, guangzhouRow);
const mergedAfterStaleDetailChart = buildFullDayChartData(guangzhouRow, mergedAfterStaleDetail, false);
const preservedPatchRunway = mergedAfterStaleDetailChart.series.find((item) => item.key === "runway_02L_20R");
assert(
preservedPatchRunway?.values.some((value) => value === 28.8),
"stale full-detail responses must not overwrite a newer live SSE observation point",
);
} }
@@ -981,6 +981,113 @@ function appendRawObservationPoint(
return merged.slice(-MAX_OBS_POINTS); return merged.slice(-MAX_OBS_POINTS);
} }
function rawObservationKey(point: RawObsPoint) {
const normalized = normalizeRawObsPoint(point);
const time = String(normalized?.time || "").trim();
const temp = validNumber(normalized?.temp);
if (!time || temp === null) return "";
return `${time}:${temp}`;
}
function mergeRawObservationPoints(
base: RawObsPoint[] | null | undefined,
live: RawObsPoint[] | null | undefined,
) {
const merged: RawObsPoint[] = [];
const seen = new Set<string>();
[...(base || []), ...(live || [])].forEach((point) => {
const key = rawObservationKey(point);
if (!key || seen.has(key)) return;
seen.add(key);
merged.push(point);
});
return merged.length ? merged.slice(-MAX_OBS_POINTS) : undefined;
}
function observationTimeRank(
value: string | number | null | undefined,
row: ScanOpportunityRow | null,
localDateStr: string | null | undefined,
) {
const localRank = getCityLocalUtcTimestamp(
value,
row?.tz_offset_seconds ?? 0,
localDateStr || row?.local_date || null,
);
if (localRank !== null) return localRank;
if (typeof value === "number" && Number.isFinite(value)) return value;
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : null;
}
function conditionObservationTime(source: AirportCurrentConditions | null | undefined) {
return (
(source as any)?.obs_time ??
(source as any)?.observation_time ??
(source as any)?.timestamp ??
(source as any)?.time ??
null
);
}
function mergeAirportCondition(
base: AirportCurrentConditions | null | undefined,
live: AirportCurrentConditions | null | undefined,
row: ScanOpportunityRow | null,
localDateStr: string | null | undefined,
) {
if (!base) return live || null;
if (!live) return base || null;
const baseTime = observationTimeRank(conditionObservationTime(base), row, localDateStr);
const liveTime = observationTimeRank(conditionObservationTime(live), row, localDateStr);
const liveIsAtLeastAsFresh = liveTime === null || baseTime === null || liveTime >= baseTime;
const maxSoFar = Math.max(
validNumber(base.max_so_far) ?? validNumber(base.temp) ?? Number.NEGATIVE_INFINITY,
validNumber(live.max_so_far) ?? validNumber(live.temp) ?? Number.NEGATIVE_INFINITY,
);
const merged = liveIsAtLeastAsFresh ? { ...base, ...live } : { ...live, ...base };
if (Number.isFinite(maxSoFar)) {
merged.max_so_far = maxSoFar;
}
return merged;
}
function runwayHistoryPointKey(point: Record<string, unknown>) {
const time = String(
point.timestamp ??
point.time ??
point.observed_at ??
"",
).trim();
const value = parseRunwayHistoryValue(point);
if (!time || value === null) return "";
return `${time}:${value}`;
}
function mergeRunwayPlateHistory(
base: Record<string, Array<Record<string, unknown>>> | undefined,
live: Record<string, Array<Record<string, unknown>>> | undefined,
) {
if (!base && !live) return undefined;
const result: Record<string, Array<Record<string, unknown>>> = {};
Object.entries(base || {}).forEach(([rwy, points]) => {
if (Array.isArray(points)) result[rwy] = [...points];
});
Object.entries(live || {}).forEach(([rwy, points]) => {
if (!Array.isArray(points)) return;
const merged = result[rwy] ? [...result[rwy]] : [];
const seen = new Set(merged.map(runwayHistoryPointKey).filter(Boolean));
points.forEach((point) => {
const key = runwayHistoryPointKey(point);
if (key && seen.has(key)) return;
if (key) seen.add(key);
merged.push(point);
});
result[rwy] = merged.slice(-MAX_OBS_POINTS);
});
return Object.keys(result).length ? result : undefined;
}
function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) { function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) {
const cityKey = normalizeCityKey(row?.city); const cityKey = normalizeCityKey(row?.city);
const sourceText = [ const sourceText = [
@@ -1121,6 +1228,47 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
}; };
} }
function mergeHourlyWithLiveObservations(
base: HourlyForecast,
live: HourlyForecast,
row: ScanOpportunityRow | null,
): HourlyForecast {
if (!base) return live;
if (!live) return base;
const localDate = base.localDate || live.localDate || row?.local_date || null;
const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory);
const amos = runwayPlateHistory
? {
...(base.amos || {}),
runway_plate_history: runwayPlateHistory,
} as AmosData
: base.amos;
return {
...base,
localDate,
localTime: live.localTime || base.localTime,
runwayPlateHistory,
amos,
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,
metarTodayObs: mergeRawObservationPoints(base.metarTodayObs, live.metarTodayObs) as ObsPoint[] | undefined,
airportPrimaryTodayObs: mergeRawObservationPoints(
base.airportPrimaryTodayObs,
live.airportPrimaryTodayObs,
),
};
}
function mergeRowObservationIntoHourly(
prev: HourlyForecast,
row: ScanOpportunityRow | null,
): HourlyForecast {
const seeded = seedHourlyForecastFromRow(row);
if (!prev) return seeded;
return mergeHourlyWithLiveObservations(prev, seeded, row);
}
type HourlyForecastFetchOptions = { type HourlyForecastFetchOptions = {
ignoreCache?: boolean; ignoreCache?: boolean;
resolution?: string; resolution?: string;
@@ -2652,7 +2800,9 @@ export {
getObservationDisplayMetrics, getObservationDisplayMetrics,
getVisibleTemperatureSeries, getVisibleTemperatureSeries,
isTemperatureSeriesVisibleByDefault, isTemperatureSeriesVisibleByDefault,
mergeHourlyWithLiveObservations,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly,
normObs, normObs,
normalizeCityKey, normalizeCityKey,
prefersHighFrequencyRunwayResolution, prefersHighFrequencyRunwayResolution,