Prevent stale chart auto refresh rollback

This commit is contained in:
2569718930@qq.com
2026-06-14 08:39:08 +08:00
parent e2ebf5132d
commit a098a5ab20
2 changed files with 132 additions and 4 deletions
@@ -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,