Prevent stale chart auto refresh rollback
This commit is contained in:
@@ -364,6 +364,48 @@ export function runTests() {
|
||||
preservedPatchRunway?.values.some((value) => value === 28.8),
|
||||
"stale full-detail responses must not overwrite a newer live SSE observation point",
|
||||
);
|
||||
const olderAutoRefreshDetail = {
|
||||
...seededGuangzhou,
|
||||
localDate: "2026-06-10",
|
||||
localTime: "12:45",
|
||||
times: ["12:00", "12:45"],
|
||||
temps: [27.8, 28.4],
|
||||
probabilities: { engine: "stale", distribution: [{ value: 28, probability: 0.2 }] },
|
||||
runwayPlateHistory: {
|
||||
"02L/20R": [{ timestamp: "12:45", temp_c: 28.4, value: 28.4 }],
|
||||
},
|
||||
airportCurrent: { temp: 28.4, obs_time: "12:45", max_so_far: 28.4 },
|
||||
airportPrimary: { temp: 28.4, obs_time: "12:45", max_so_far: 28.4 },
|
||||
airportPrimaryTodayObs: [["12:45", 28.4]],
|
||||
} as any;
|
||||
const newerVisibleDetail = {
|
||||
...seededGuangzhou,
|
||||
localDate: "2026-06-10",
|
||||
localTime: "12:55",
|
||||
times: ["12:00", "12:55"],
|
||||
temps: [27.8, 29.2],
|
||||
probabilities: { engine: "fresh", distribution: [{ value: 29, probability: 0.7 }] },
|
||||
runwayPlateHistory: {
|
||||
"02L/20R": [{ timestamp: "12:55", temp_c: 29.2, value: 29.2 }],
|
||||
},
|
||||
airportCurrent: { temp: 29.2, obs_time: "12:55", max_so_far: 29.2 },
|
||||
airportPrimary: { temp: 29.2, obs_time: "12:55", max_so_far: 29.2 },
|
||||
airportPrimaryTodayObs: [["12:55", 29.2]],
|
||||
} as any;
|
||||
const mergedAfterOlderAutoRefresh = mergeHourlyWithLiveObservations(
|
||||
olderAutoRefreshDetail,
|
||||
newerVisibleDetail,
|
||||
guangzhouRow,
|
||||
);
|
||||
assert(
|
||||
mergedAfterOlderAutoRefresh?.times.includes("12:55") &&
|
||||
!mergedAfterOlderAutoRefresh?.times.includes("12:45"),
|
||||
"older automatic detail refreshes must not roll a newer visible chart curve back to stale timestamps",
|
||||
);
|
||||
assert(
|
||||
mergedAfterOlderAutoRefresh?.probabilities?.engine === "fresh",
|
||||
"older automatic detail refreshes must preserve the newer visible chart probability payload",
|
||||
);
|
||||
|
||||
const chengduDetail = {
|
||||
forecastTodayHigh: null,
|
||||
|
||||
@@ -1126,6 +1126,91 @@ function mergeRunwayPlateHistory(
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
function hasFullHourlyDetailPayload(hourly: HourlyForecast) {
|
||||
if (!hourly) return false;
|
||||
const probabilityBuckets =
|
||||
hourly.probabilities?.distribution_all ||
|
||||
hourly.probabilities?.distribution ||
|
||||
[];
|
||||
return Boolean(
|
||||
(hourly.times || []).length > 0 ||
|
||||
(hourly.temps || []).length > 0 ||
|
||||
(hourly.debHourlyPath?.times || []).length > 0 ||
|
||||
Object.keys(hourly.modelCurves || {}).length > 0 ||
|
||||
(hourly.forecastDaily || []).length > 0 ||
|
||||
Object.keys(hourly.multiModelDaily || {}).length > 0 ||
|
||||
probabilityBuckets.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function latestRawObservationRank(
|
||||
points: RawObsPoint[] | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
localDateStr: string | null | undefined,
|
||||
) {
|
||||
let latest: number | null = null;
|
||||
(points || []).forEach((point) => {
|
||||
const normalized = normalizeRawObsPoint(point);
|
||||
const rank = observationTimeRank(normalized?.time, row, localDateStr);
|
||||
if (rank !== null) latest = latest === null ? rank : Math.max(latest, rank);
|
||||
});
|
||||
return latest;
|
||||
}
|
||||
|
||||
function latestRunwayHistoryRank(
|
||||
history: Record<string, Array<Record<string, unknown>>> | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
localDateStr: string | null | undefined,
|
||||
) {
|
||||
let latest: number | null = null;
|
||||
Object.values(history || {}).forEach((points) => {
|
||||
if (!Array.isArray(points)) return;
|
||||
points.forEach((point) => {
|
||||
const rawTime = point.timestamp ??
|
||||
point.time ??
|
||||
point.observed_at ??
|
||||
null;
|
||||
const rank = observationTimeRank(
|
||||
typeof rawTime === "string" || typeof rawTime === "number" ? rawTime : null,
|
||||
row,
|
||||
localDateStr,
|
||||
);
|
||||
if (rank !== null) latest = latest === null ? rank : Math.max(latest, rank);
|
||||
});
|
||||
});
|
||||
return latest;
|
||||
}
|
||||
|
||||
function latestHourlyObservationRank(
|
||||
hourly: HourlyForecast,
|
||||
row: ScanOpportunityRow | null,
|
||||
) {
|
||||
if (!hourly) return null;
|
||||
const localDateStr = hourly.localDate || row?.local_date || null;
|
||||
const ranks = [
|
||||
observationTimeRank(hourly.localTime, row, localDateStr),
|
||||
observationTimeRank(conditionObservationTime(hourly.airportCurrent), row, localDateStr),
|
||||
observationTimeRank(conditionObservationTime(hourly.airportPrimary), row, localDateStr),
|
||||
latestRawObservationRank(hourly.airportPrimaryTodayObs, row, localDateStr),
|
||||
latestRawObservationRank(hourly.metarTodayObs, row, localDateStr),
|
||||
latestRawObservationRank(hourly.settlementTodayObs, row, localDateStr),
|
||||
latestRunwayHistoryRank(hourly.runwayPlateHistory, row, localDateStr),
|
||||
].filter((rank): rank is number => rank !== null);
|
||||
return ranks.length ? Math.max(...ranks) : null;
|
||||
}
|
||||
|
||||
function shouldKeepLiveHourlyDetailPayload(
|
||||
base: HourlyForecast,
|
||||
live: HourlyForecast,
|
||||
row: ScanOpportunityRow | null,
|
||||
) {
|
||||
if (!base || !live) return false;
|
||||
if (!hasFullHourlyDetailPayload(base) || !hasFullHourlyDetailPayload(live)) return false;
|
||||
const baseRank = latestHourlyObservationRank(base, row);
|
||||
const liveRank = latestHourlyObservationRank(live, row);
|
||||
return baseRank !== null && liveRank !== null && liveRank > baseRank;
|
||||
}
|
||||
|
||||
function hourlyLocalDatesConflict(
|
||||
base: HourlyForecast,
|
||||
live: HourlyForecast,
|
||||
@@ -1286,16 +1371,17 @@ function mergeHourlyWithLiveObservations(
|
||||
if (!base) return live;
|
||||
if (!live) return base;
|
||||
if (hourlyLocalDatesConflict(base, live, row)) return base;
|
||||
const localDate = base.localDate || live.localDate || row?.local_date || null;
|
||||
const detailSource = shouldKeepLiveHourlyDetailPayload(base, live, row) ? live : base;
|
||||
const localDate = detailSource.localDate || base.localDate || live.localDate || row?.local_date || null;
|
||||
const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory);
|
||||
const amos = runwayPlateHistory
|
||||
? {
|
||||
...(base.amos || {}),
|
||||
...(detailSource.amos || {}),
|
||||
runway_plate_history: runwayPlateHistory,
|
||||
} as AmosData
|
||||
: base.amos;
|
||||
: detailSource.amos;
|
||||
return {
|
||||
...base,
|
||||
...detailSource,
|
||||
localDate,
|
||||
localTime: live.localTime || base.localTime,
|
||||
runwayPlateHistory,
|
||||
|
||||
Reference in New Issue
Block a user