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),
|
preservedPatchRunway?.values.some((value) => value === 28.8),
|
||||||
"stale full-detail responses must not overwrite a newer live SSE observation point",
|
"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 = {
|
const chengduDetail = {
|
||||||
forecastTodayHigh: null,
|
forecastTodayHigh: null,
|
||||||
|
|||||||
@@ -1126,6 +1126,91 @@ function mergeRunwayPlateHistory(
|
|||||||
return Object.keys(result).length ? result : undefined;
|
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(
|
function hourlyLocalDatesConflict(
|
||||||
base: HourlyForecast,
|
base: HourlyForecast,
|
||||||
live: HourlyForecast,
|
live: HourlyForecast,
|
||||||
@@ -1286,16 +1371,17 @@ function mergeHourlyWithLiveObservations(
|
|||||||
if (!base) return live;
|
if (!base) return live;
|
||||||
if (!live) return base;
|
if (!live) return base;
|
||||||
if (hourlyLocalDatesConflict(base, live, row)) 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 runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory);
|
||||||
const amos = runwayPlateHistory
|
const amos = runwayPlateHistory
|
||||||
? {
|
? {
|
||||||
...(base.amos || {}),
|
...(detailSource.amos || {}),
|
||||||
runway_plate_history: runwayPlateHistory,
|
runway_plate_history: runwayPlateHistory,
|
||||||
} as AmosData
|
} as AmosData
|
||||||
: base.amos;
|
: detailSource.amos;
|
||||||
return {
|
return {
|
||||||
...base,
|
...detailSource,
|
||||||
localDate,
|
localDate,
|
||||||
localTime: live.localTime || base.localTime,
|
localTime: live.localTime || base.localTime,
|
||||||
runwayPlateHistory,
|
runwayPlateHistory,
|
||||||
|
|||||||
Reference in New Issue
Block a user