Keep live chart observations current
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user