强类型隔离图表观测和详情数据

This commit is contained in:
2569718930@qq.com
2026-06-16 23:30:13 +08:00
parent 53cf5b556b
commit 491415427c
5 changed files with 159 additions and 84 deletions
@@ -29,7 +29,7 @@ import {
getVisibleTemperatureSeries, getVisibleTemperatureSeries,
isTemperatureSeriesVisibleByDefault, isTemperatureSeriesVisibleByDefault,
mergeHourlyWithLiveObservations, mergeHourlyWithLiveObservations,
mergeObservationPayloadIntoHourly, mergeObservationSnapshotIntoHourly,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly, mergeRowObservationIntoHourly,
normObs, normObs,
@@ -45,6 +45,7 @@ import {
shouldPollLiveChart, shouldPollLiveChart,
validNumber, validNumber,
type HourlyForecast, type HourlyForecast,
type ObservationSnapshot,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; export { clearCityDetailCache } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -252,9 +253,9 @@ function rowObservationTimeForFreshness(row: ScanOpportunityRow | null) {
).trim() || null; ).trim() || null;
} }
function observationPayloadTimeForFreshness(payload: any) { function observationSnapshotTimeForFreshness(snapshot: ObservationSnapshot) {
const block = payload?.airport_current || payload?.airport_primary || payload?.current || {}; const block = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
return String(block.obs_time || block.observed_at || block.observation_time || payload?.local_time || "").trim() || null; return String(block.obs_time || block.observed_at || block.observation_time || snapshot.local_time || "").trim() || null;
} }
function patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) { function patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) {
@@ -847,25 +848,25 @@ export function LiveTemperatureThresholdChart({
applySuccessfulHourlyDetail, applySuccessfulHourlyDetail,
}); });
const applyLiveObservationPayload = useCallback((payload: any) => { const applyLiveObservationSnapshot = useCallback((snapshot: ObservationSnapshot) => {
if (!payload || typeof payload !== "object") return; if (!snapshot || typeof snapshot !== "object") return;
const appliedAtMs = Date.now(); const appliedAtMs = Date.now();
const condition = payload.airport_current || payload.airport_primary || payload.current || {}; const condition = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {};
const temp = validNumber(condition.temp); const temp = validNumber(condition.temp);
if (temp !== null) setLiveTemp(temp); if (temp !== null) setLiveTemp(temp);
if (typeof payload.local_date === "string" && payload.local_date) { if (typeof snapshot.local_date === "string" && snapshot.local_date) {
setCurrentCityLocalDate(payload.local_date); setCurrentCityLocalDate(snapshot.local_date);
} }
commitHourlySnapshot((prev) => commitHourlySnapshot((prev) =>
mergeObservationPayloadIntoHourly( mergeObservationSnapshotIntoHourly(
prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()),
payload, snapshot,
), ),
); );
setChartFreshness((prev) => ({ setChartFreshness((prev) => ({
...prev, ...prev,
rowAppliedAtMs: appliedAtMs, rowAppliedAtMs: appliedAtMs,
rowObservationTime: observationPayloadTimeForFreshness(payload), rowObservationTime: observationSnapshotTimeForFreshness(snapshot),
})); }));
}, [commitHourlySnapshot, getLatestRowSnapshot]); }, [commitHourlySnapshot, getLatestRowSnapshot]);
@@ -1021,14 +1022,14 @@ export function LiveTemperatureThresholdChart({
useEffect(() => { useEffect(() => {
if (!resyncVersion || !city) return; if (!resyncVersion || !city) return;
let cancelled = false; let cancelled = false;
void fetchLiveObservationForCity(city).then((payload) => { void fetchLiveObservationForCity(city).then((snapshot) => {
if (cancelled || !payload) return; if (cancelled || !snapshot) return;
applyLiveObservationPayload(payload); applyLiveObservationSnapshot(snapshot);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [resyncVersion, city, applyLiveObservationPayload]); }, [resyncVersion, city, applyLiveObservationSnapshot]);
// ── SSE fallback: visible charts merge no-store observations if patches stop. ── // ── SSE fallback: visible charts merge no-store observations if patches stop. ──
useEffect(() => { useEffect(() => {
@@ -1039,9 +1040,9 @@ export function LiveTemperatureThresholdChart({
const now = Date.now(); const now = Date.now();
lastPatchAtRef.current = now; lastPatchAtRef.current = now;
void fetchLiveObservationForCity(city).then((payload) => { void fetchLiveObservationForCity(city).then((snapshot) => {
if (cancelled || !payload) return; if (cancelled || !snapshot) return;
applyLiveObservationPayload(payload); applyLiveObservationSnapshot(snapshot);
}); });
}; };
@@ -1058,7 +1059,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true; cancelled = true;
clearInterval(id); clearInterval(id);
}; };
}, [city, compact, isActive, isMaximized, applyLiveObservationPayload]); }, [city, compact, isActive, isMaximized, applyLiveObservationSnapshot]);
useEffect(() => { useEffect(() => {
if (!activationRefreshKey) return; if (!activationRefreshKey) return;
@@ -90,7 +90,7 @@ export async function runTests() {
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") && chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
chartSource.includes("refreshLiveObservation") && chartSource.includes("refreshLiveObservation") &&
chartSource.includes("fetchLiveObservationForCity") && chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") && chartSource.includes("mergeObservationSnapshotIntoHourly") &&
chartLogicSource.includes("options.bypassLocalCache") && chartLogicSource.includes("options.bypassLocalCache") &&
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"), chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes", "visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes",
@@ -137,7 +137,7 @@ export async function runTests() {
); );
assert( assert(
chartSource.includes("fetchLiveObservationForCity") && chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") && chartSource.includes("mergeObservationSnapshotIntoHourly") &&
!chartSource.includes("rememberHourlyDetailSnapshot"), !chartSource.includes("rememberHourlyDetailSnapshot"),
"visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache", "visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache",
); );
@@ -167,9 +167,30 @@ export async function runTests() {
assert( assert(
chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") && chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") &&
chartLogicSource.includes('cache: "no-store"') && chartLogicSource.includes('cache: "no-store"') &&
chartLogicSource.includes("mergeObservationPayloadIntoHourly"), chartLogicSource.includes("mergeObservationSnapshotIntoHourly"),
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail", "live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
); );
assert(
/type FullChartDetail\s*=\s*NonNullable<HourlyForecast>\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) &&
/type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource),
"full detail and no-store observation payloads should be separate branded types instead of sharing raw HourlyForecast",
);
assert(
chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") &&
/function rememberHourlyDetailSnapshot\([\s\S]*data:\s*FullChartDetail/.test(chartLogicSource),
"hourly detail cache writes should require FullChartDetail so observation-only snapshots cannot be cached as model detail",
);
assert(
/async function fetchLiveObservationForCity\([\s\S]*Promise<ObservationSnapshot \| null>/.test(chartLogicSource) &&
chartLogicSource.includes("function observationPayloadToSnapshot") &&
chartLogicSource.includes("function mergeObservationSnapshotIntoHourly"),
"live observation fetches should return ObservationSnapshot and enter chart state through the observation snapshot merge path",
);
assert(
/async function fetchHourlyForecastForCity\([\s\S]*Promise<FullChartDetail \| null>/.test(chartLogicSource) &&
/type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource),
"model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state",
);
assert( assert(
chartLogicSource.includes("cityDetailBatchQueueKey") && chartLogicSource.includes("cityDetailBatchQueueKey") &&
chartLogicSource.includes('forceRefresh ? "force" : "cached"'), chartLogicSource.includes('forceRefresh ? "force" : "cached"'),
@@ -232,8 +232,8 @@ export function runTests() {
const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert( assert(
fallbackRefreshBlock.includes("fetchLiveObservationForCity") && fallbackRefreshBlock.includes("fetchLiveObservationForCity") &&
fallbackRefreshBlock.includes("applyLiveObservationPayload") && fallbackRefreshBlock.includes("applyLiveObservationSnapshot") &&
chart.includes("mergeObservationPayloadIntoHourly") && chart.includes("mergeObservationSnapshotIntoHourly") &&
!fallbackRefreshBlock.includes("runHourlyDetailFetch") && !fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
!fallbackRefreshBlock.includes("ignoreCache: true") && !fallbackRefreshBlock.includes("ignoreCache: true") &&
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
@@ -243,10 +243,10 @@ export function runTests() {
!chart.includes("rememberHourlyDetailSnapshot"), !chart.includes("rememberHourlyDetailSnapshot"),
"temperature chart must not write row/SSE/observation overlays back into the full-detail cache", "temperature chart must not write row/SSE/observation overlays back into the full-detail cache",
); );
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationPayload\]\);/)?.[0] || ""; const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationSnapshot\]\);/)?.[0] || "";
assert( assert(
resyncBlock.includes("fetchLiveObservationForCity") && resyncBlock.includes("fetchLiveObservationForCity") &&
resyncBlock.includes("applyLiveObservationPayload") && resyncBlock.includes("applyLiveObservationSnapshot") &&
!resyncBlock.includes("runHourlyDetailFetch") && !resyncBlock.includes("runHourlyDetailFetch") &&
!resyncBlock.includes("ignoreCache: true") && !resyncBlock.includes("ignoreCache: true") &&
!resyncBlock.includes("setIsHourlyLoading(true)"), !resyncBlock.includes("setIsHourlyLoading(true)"),
@@ -3,14 +3,16 @@ import type { CityDetail } from "@/lib/dashboard-types";
import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { import {
buildFullDayChartData, buildFullDayChartData,
mergeObservationPayloadIntoHourly, mergeObservationSnapshotIntoHourly,
mergeHourlyWithLiveObservations, mergeHourlyWithLiveObservations,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly, mergeRowObservationIntoHourly,
observationPayloadToSnapshot,
readCachedHourlyForInitialRow, readCachedHourlyForInitialRow,
rememberHourlyDetailSnapshot, rememberHourlyDetailSnapshot,
selectInitialHourlyForRowChange, selectInitialHourlyForRowChange,
seedHourlyForecastFromRow, seedHourlyForecastFromRow,
toFullChartDetail,
_hourlyCache, _hourlyCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -954,13 +956,13 @@ export function runTests() {
tz_offset_seconds: 8 * 3600, tz_offset_seconds: 8 * 3600,
metar_context: { source: "amsc_awos" }, metar_context: { source: "amsc_awos" },
} as any; } as any;
rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow)); rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any);
assert( assert(
!_hourlyCache.has(chengduCacheKey), !_hourlyCache.has(chengduCacheKey),
"instant-restore cache must not persist a row-only seed that would block the full detail fetch", "instant-restore cache must not persist a row-only seed that would block the full detail fetch",
); );
const cachedChengduDetail = { const cachedChengduDetail = toFullChartDetail({
...seedHourlyForecastFromRow(cachedChengduRow), ...seedHourlyForecastFromRow(cachedChengduRow),
localDate: "2026-06-15", localDate: "2026-06-15",
times: ["08:00", "09:00"], times: ["08:00", "09:00"],
@@ -975,7 +977,8 @@ export function runTests() {
runwayPlateHistory: { runwayPlateHistory: {
"02L/20R": [{ timestamp: "2026-06-15T08:55:00Z", temp_c: 26.2 }], "02L/20R": [{ timestamp: "2026-06-15T08:55:00Z", temp_c: 26.2 }],
}, },
} as any; } as any);
if (!cachedChengduDetail) throw new Error("test fixture should produce a full chart detail");
const cachedChengduLivePatch = mergePatchIntoHourly(cachedChengduDetail, { const cachedChengduLivePatch = mergePatchIntoHourly(cachedChengduDetail, {
city: "chengdu", city: "chengdu",
revision: 7, revision: 7,
@@ -985,7 +988,9 @@ export function runTests() {
runway_points: [{ runway: "02L/20R", temp: 26.8 }], runway_points: [{ runway: "02L/20R", temp: 26.8 }],
}, },
} as any); } as any);
rememberHourlyDetailSnapshot("chengdu", "1m", cachedChengduLivePatch); const cachedChengduPatchedDetail = toFullChartDetail(cachedChengduLivePatch);
if (!cachedChengduPatchedDetail) throw new Error("live-merged detail should preserve full chart detail fields");
rememberHourlyDetailSnapshot("chengdu", "1m", cachedChengduPatchedDetail);
const restoredChengdu = _hourlyCache.get(chengduCacheKey)?.data; const restoredChengdu = _hourlyCache.get(chengduCacheKey)?.data;
assert( assert(
restoredChengdu?.modelCurves?.ECMWF?.length === 2 && restoredChengdu?.modelCurves?.ECMWF?.length === 2 &&
@@ -1025,23 +1030,25 @@ export function runTests() {
"02L/20R": [{ time: "2026-06-15T17:45:00+08:00", tdz_temp: 27.1, end_temp: 26.8 }], "02L/20R": [{ time: "2026-06-15T17:45:00+08:00", tdz_temp: 27.1, end_temp: 26.8 }],
}, },
}; };
const observationMergedChengdu = mergeObservationPayloadIntoHourly( const observationSnapshot = observationPayloadToSnapshot(observationOnlyPayload as any);
if (!observationSnapshot) throw new Error("test observation payload should produce an observation snapshot");
const observationMergedChengdu = mergeObservationSnapshotIntoHourly(
cachedChengduDetail, cachedChengduDetail,
observationOnlyPayload as any, observationSnapshot,
); );
assert( assert(
observationMergedChengdu?.airportCurrent?.temp === 27.1 && observationMergedChengdu?.airportCurrent?.temp === 27.1 &&
observationMergedChengdu?.airportPrimary?.source_code === "amsc_awos", observationMergedChengdu?.airportPrimary?.source_code === "amsc_awos",
"observation endpoint payload should update the current observation block", "observation endpoint snapshot should update the current observation block",
); );
assert( assert(
observationMergedChengdu?.modelCurves?.ECMWF?.length === 2 && observationMergedChengdu?.modelCurves?.ECMWF?.length === 2 &&
observationMergedChengdu?.debHourlyPath?.temps?.includes(30.9), observationMergedChengdu?.debHourlyPath?.temps?.includes(30.9),
"observation endpoint payload must not clear DEB or multi-model chart detail", "observation endpoint snapshot must not clear DEB or multi-model chart detail",
); );
assert( assert(
(observationMergedChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2, (observationMergedChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2,
"observation endpoint payload should append fresh runway history onto cached detail history", "observation endpoint snapshot should append fresh runway history onto cached detail history",
); );
_hourlyCache.clear(); _hourlyCache.clear();
@@ -1055,13 +1062,14 @@ export function runTests() {
temp_symbol: "°C", temp_symbol: "°C",
tz_offset_seconds: 0, tz_offset_seconds: 0,
} as any; } as any;
rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", { const cacheDetail = toFullChartDetail({
...seedHourlyForecastFromRow(row), ...seedHourlyForecastFromRow(row),
times: ["09:00"], times: ["09:00"],
temps: [20 + i / 100], temps: [20 + i / 100],
modelTimes: ["09:00"], modelTimes: ["09:00"],
modelCurves: { ECMWF: [20 + i / 100] }, modelCurves: { ECMWF: [20 + i / 100] },
} as any); } as any);
if (cacheDetail) rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", cacheDetail);
} }
assert( assert(
_hourlyCache.size <= 160, _hourlyCache.size <= 160,
@@ -364,8 +364,8 @@ const SESSION_CACHE_TTL_MS = HOURLY_CACHE_TTL_MS;
const HOURLY_CACHE_STALE_TTL_MS = 6 * HOURLY_CACHE_TTL_MS; const HOURLY_CACHE_STALE_TTL_MS = 6 * HOURLY_CACHE_TTL_MS;
const MAX_HOURLY_CACHE_ENTRIES = 160; const MAX_HOURLY_CACHE_ENTRIES = 160;
const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000; const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>(); const _hourlyCache = new Map<string, { ts: number; data: FullChartDetail }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>(); const _hourlyRequestCache = new Map<string, Promise<FullChartDetail | null>>();
const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3;
const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000; const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000;
let _hourlyActiveDetailRequests = 0; let _hourlyActiveDetailRequests = 0;
@@ -374,7 +374,7 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
type HourlyCacheEntry = { ts: number; data: HourlyForecast }; type HourlyCacheEntry = { ts: number; data: FullChartDetail };
type HourlyDetailSnapshotSource = "memory_cache" | "session_cache"; type HourlyDetailSnapshotSource = "memory_cache" | "session_cache";
type HourlyDetailSnapshotEntry = HourlyCacheEntry & { source: HourlyDetailSnapshotSource }; type HourlyDetailSnapshotEntry = HourlyCacheEntry & { source: HourlyDetailSnapshotSource };
type CityDetailBatchDiagnostics = Record<string, any>; type CityDetailBatchDiagnostics = Record<string, any>;
@@ -442,14 +442,25 @@ function isRetainedHourlyCacheEntry(
} }
function isUsableHourlyDetailCacheEntry(entry: HourlyCacheEntry | null | undefined) { function isUsableHourlyDetailCacheEntry(entry: HourlyCacheEntry | null | undefined) {
return Boolean(entry?.data && hasFullHourlyDetailPayload(entry.data)); return Boolean(toFullChartDetail((entry as any)?.data || null));
}
function normalizeHourlyCacheEntry(entry: unknown): HourlyCacheEntry | null {
if (!entry || typeof entry !== "object") return null;
const ts = Number((entry as any).ts || 0);
const data = toFullChartDetail((entry as any).data || null);
if (!Number.isFinite(ts) || ts <= 0 || !data) return null;
return { ts, data };
} }
function pruneHourlyCache() { function pruneHourlyCache() {
for (const [key, entry] of _hourlyCache.entries()) { for (const [key, entry] of _hourlyCache.entries()) {
if (!isUsableHourlyDetailCacheEntry(entry) || !isRetainedHourlyCacheEntry(entry, HOURLY_CACHE_STALE_TTL_MS)) { const normalized = normalizeHourlyCacheEntry(entry);
if (!normalized || !isRetainedHourlyCacheEntry(normalized, HOURLY_CACHE_STALE_TTL_MS)) {
_hourlyCache.delete(key); _hourlyCache.delete(key);
continue;
} }
if (normalized !== entry) _hourlyCache.set(key, normalized);
} }
if (_hourlyCache.size <= MAX_HOURLY_CACHE_ENTRIES) return; if (_hourlyCache.size <= MAX_HOURLY_CACHE_ENTRIES) return;
@@ -476,11 +487,11 @@ function readSessionCache(
try { try {
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null; if (!raw) return null;
const item = JSON.parse(raw); const item = normalizeHourlyCacheEntry(JSON.parse(raw));
const maxAgeMs = options.allowStale const maxAgeMs = options.allowStale
? HOURLY_CACHE_STALE_TTL_MS ? HOURLY_CACHE_STALE_TTL_MS
: options.maxAgeMs ?? SESSION_CACHE_TTL_MS; : options.maxAgeMs ?? SESSION_CACHE_TTL_MS;
if (!item || !item.ts || !isUsableHourlyDetailCacheEntry(item) || !isRetainedHourlyCacheEntry(item, HOURLY_CACHE_STALE_TTL_MS)) { if (!item || !isRetainedHourlyCacheEntry(item, HOURLY_CACHE_STALE_TTL_MS)) {
sessionStorage.removeItem(`${SESSION_CACHE_PREFIX}${city}`); sessionStorage.removeItem(`${SESSION_CACHE_PREFIX}${city}`);
return null; return null;
} }
@@ -495,15 +506,16 @@ function readHourlyCacheEntry(
cacheKey: string, cacheKey: string,
options: { allowStale?: boolean; maxAgeMs?: number } = {}, options: { allowStale?: boolean; maxAgeMs?: number } = {},
): HourlyCacheEntry | null { ): HourlyCacheEntry | null {
const cached = _hourlyCache.get(cacheKey); const cachedRaw = _hourlyCache.get(cacheKey);
const cached = normalizeHourlyCacheEntry(cachedRaw);
if ( if (
cached && cached &&
isUsableHourlyDetailCacheEntry(cached) &&
(options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))
) { ) {
if (cached !== cachedRaw) _hourlyCache.set(cacheKey, cached);
return cached; return cached;
} }
if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { if (cachedRaw && (!cached || !isRetainedHourlyCacheEntry(cached))) {
_hourlyCache.delete(cacheKey); _hourlyCache.delete(cacheKey);
} }
@@ -520,15 +532,16 @@ function readHourlyCacheSnapshot(
cacheKey: string, cacheKey: string,
options: { allowStale?: boolean; maxAgeMs?: number } = {}, options: { allowStale?: boolean; maxAgeMs?: number } = {},
): HourlyDetailSnapshotEntry | null { ): HourlyDetailSnapshotEntry | null {
const cached = _hourlyCache.get(cacheKey); const cachedRaw = _hourlyCache.get(cacheKey);
const cached = normalizeHourlyCacheEntry(cachedRaw);
if ( if (
cached && cached &&
isUsableHourlyDetailCacheEntry(cached) &&
(options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs))
) { ) {
if (cached !== cachedRaw) _hourlyCache.set(cacheKey, cached);
return { ...cached, source: "memory_cache" }; return { ...cached, source: "memory_cache" };
} }
if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { if (cachedRaw && (!cached || !isRetainedHourlyCacheEntry(cached))) {
_hourlyCache.delete(cacheKey); _hourlyCache.delete(cacheKey);
} }
@@ -541,7 +554,7 @@ function readHourlyCacheSnapshot(
return null; return null;
} }
function writeSessionCache(city: string, data: HourlyForecast, ts = Date.now()) { function writeSessionCache(city: string, data: FullChartDetail, ts = Date.now()) {
if (typeof window === "undefined" || !data) return; if (typeof window === "undefined" || !data) return;
try { try {
sessionStorage.setItem( sessionStorage.setItem(
@@ -551,7 +564,7 @@ function writeSessionCache(city: string, data: HourlyForecast, ts = Date.now())
} catch {} } catch {}
} }
function writeHourlyCacheEntry(cacheKey: string, data: HourlyForecast, ts = Date.now()) { function writeHourlyCacheEntry(cacheKey: string, data: FullChartDetail, ts = Date.now()) {
if (!cacheKey || !data) return; if (!cacheKey || !data) return;
rememberMemoryHourlyCacheEntry(cacheKey, { ts, data }); rememberMemoryHourlyCacheEntry(cacheKey, { ts, data });
writeSessionCache(cacheKey, data, ts); writeSessionCache(cacheKey, data, ts);
@@ -595,12 +608,13 @@ function readCachedHourlyForInitialRow(
function rememberHourlyDetailSnapshot( function rememberHourlyDetailSnapshot(
city: string, city: string,
resolution: string, resolution: string,
data: HourlyForecast, data: FullChartDetail,
) { ) {
const cityKey = normalizeCityKey(city); const cityKey = normalizeCityKey(city);
if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return; const detail = toFullChartDetail(data);
if (!cityKey || !detail) return;
const cacheKey = hourlyCacheKey(cityKey, resolution); const cacheKey = hourlyCacheKey(cityKey, resolution);
writeHourlyCacheEntry(cacheKey, data); writeHourlyCacheEntry(cacheKey, detail);
} }
function drainHourlyDetailRequestQueue() { function drainHourlyDetailRequestQueue() {
@@ -1375,6 +1389,15 @@ function hasFullHourlyDetailPayload(hourly: HourlyForecast) {
); );
} }
function toFullChartDetail(hourly: HourlyForecast): FullChartDetail | null {
if (!hourly || !hasFullHourlyDetailPayload(hourly)) return null;
if ((hourly as any).__detailKind === "full_chart_detail") return hourly as FullChartDetail;
return {
...hourly,
__detailKind: "full_chart_detail",
};
}
function hasArrayItems<T>(value: T[] | null | undefined): value is T[] { function hasArrayItems<T>(value: T[] | null | undefined): value is T[] {
return Array.isArray(value) && value.length > 0; return Array.isArray(value) && value.length > 0;
} }
@@ -1568,6 +1591,10 @@ type HourlyForecast = {
airportPrimaryTodayObs?: RawObsPoint[]; airportPrimaryTodayObs?: RawObsPoint[];
} | null; } | null;
type FullChartDetail = NonNullable<HourlyForecast> & {
readonly __detailKind: "full_chart_detail";
};
function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast {
if (!row) return null; if (!row) return null;
const current = rowCurrentObservation(row); const current = rowCurrentObservation(row);
@@ -1680,11 +1707,11 @@ function mergeHourlyWithLiveObservations(
}; };
} }
function mergeObservationPayloadIntoHourly( function mergeObservationSnapshotIntoHourly(
prev: HourlyForecast, prev: HourlyForecast,
payload: CityObservationPayload | null | undefined, snapshot: ObservationSnapshot | null | undefined,
): HourlyForecast { ): HourlyForecast {
const live = observationPayloadToHourly(payload); const live = observationSnapshotToHourly(snapshot);
if (!prev) return live; if (!prev) return live;
if (!live) return prev; if (!live) return prev;
return mergeHourlyWithLiveObservations(prev, live, null); return mergeHourlyWithLiveObservations(prev, live, null);
@@ -1747,6 +1774,10 @@ type CityObservationPayload = {
} | null; } | null;
}; };
type ObservationSnapshot = CityObservationPayload & {
readonly __observationKind: "observation_snapshot";
};
type CityDetailBatchPayload = { type CityDetailBatchPayload = {
cities?: string[]; cities?: string[];
details?: Record<string, CityDetail | null | undefined>; details?: Record<string, CityDetail | null | undefined>;
@@ -1757,7 +1788,7 @@ type CityDetailBatchPayload = {
}; };
type CityDetailBatchWaiter = { type CityDetailBatchWaiter = {
resolve: (value: HourlyForecast) => void; resolve: (value: FullChartDetail | null) => void;
reject: (reason?: unknown) => void; reject: (reason?: unknown) => void;
}; };
@@ -1850,19 +1881,28 @@ function normalizeObservationRunwayHistory(
return Object.keys(normalized).length ? normalized : undefined; return Object.keys(normalized).length ? normalized : undefined;
} }
function observationPayloadToHourly(payload: CityObservationPayload | null | undefined): HourlyForecast { function observationPayloadToSnapshot(payload: CityObservationPayload | null | undefined): ObservationSnapshot | null {
if (!payload || typeof payload !== "object") return null; if (!payload || typeof payload !== "object") return null;
const airportCurrent = normalizeObservationCondition(payload.airport_current || payload.current); if ((payload as any).__observationKind === "observation_snapshot") return payload as ObservationSnapshot;
const airportPrimary = normalizeObservationCondition(payload.airport_primary || payload.airport_current || payload.current); return {
const current = payload.current && typeof payload.current === "object" ...payload,
__observationKind: "observation_snapshot",
};
}
function observationSnapshotToHourly(snapshot: ObservationSnapshot | null | undefined): HourlyForecast {
if (!snapshot || typeof snapshot !== "object") return null;
const airportCurrent = normalizeObservationCondition(snapshot.airport_current || snapshot.current);
const airportPrimary = normalizeObservationCondition(snapshot.airport_primary || snapshot.airport_current || snapshot.current);
const current = snapshot.current && typeof snapshot.current === "object"
? { ? {
...(payload.current as CurrentConditions), ...(snapshot.current as CurrentConditions),
temp: validNumber(payload.current.temp), temp: validNumber(snapshot.current.temp),
} }
: null; : null;
const metarTodayObs = [ const metarTodayObs = [
...((payload.timeseries?.metar_today_obs || []) as Array<Record<string, any>>), ...((snapshot.timeseries?.metar_today_obs || []) as Array<Record<string, any>>),
...((payload.metar_today_obs || []) as Array<Record<string, any>>), ...((snapshot.metar_today_obs || []) as Array<Record<string, any>>),
] ]
.map(normalizeObservationPoint) .map(normalizeObservationPoint)
.filter((point): point is ObsPoint => point !== null); .filter((point): point is ObsPoint => point !== null);
@@ -1874,8 +1914,8 @@ function observationPayloadToHourly(payload: CityObservationPayload | null | und
debPrediction: null, debPrediction: null,
debQuality: null, debQuality: null,
debHourlyPath: null, debHourlyPath: null,
localDate: payload.local_date || null, localDate: snapshot.local_date || null,
localTime: payload.local_time || airportPrimary?.obs_time || airportCurrent?.obs_time || null, localTime: snapshot.local_time || airportPrimary?.obs_time || airportCurrent?.obs_time || null,
times: [], times: [],
temps: [], temps: [],
modelTimes: undefined, modelTimes: undefined,
@@ -1883,8 +1923,8 @@ function observationPayloadToHourly(payload: CityObservationPayload | null | und
forecastDaily: [], forecastDaily: [],
multiModelDaily: {}, multiModelDaily: {},
probabilities: null, probabilities: null,
runwayPlateHistory: normalizeObservationRunwayHistory(payload.runway_plate_history, payload.runway_points), runwayPlateHistory: normalizeObservationRunwayHistory(snapshot.runway_plate_history, snapshot.runway_points),
amos: payload.amos || null, amos: snapshot.amos || null,
current, current,
airportCurrent, airportCurrent,
airportPrimary, airportPrimary,
@@ -1893,10 +1933,10 @@ function observationPayloadToHourly(payload: CityObservationPayload | null | und
}; };
} }
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast { function parseHourlyForecastFromCityDetail(json: CityDetail | null): FullChartDetail | null {
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly; const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
if (!json || !hourlySource) return null; if (!json || !hourlySource) return null;
return { const parsed: HourlyForecast = {
forecastTodayHigh: json.forecast?.today_high ?? null, forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
debQuality: json.deb ? { debQuality: json.deb ? {
@@ -1930,9 +1970,10 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined, metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined, airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
}; };
return toFullChartDetail(parsed);
} }
function preserveCachedRunwayHistory(cacheKey: string, data: HourlyForecast) { function preserveCachedRunwayHistory(cacheKey: string, data: FullChartDetail): FullChartDetail {
if (!data) return data; if (!data) return data;
const cached = readHourlyCacheEntry(cacheKey, { allowStale: true })?.data; const cached = readHourlyCacheEntry(cacheKey, { allowStale: true })?.data;
if (!cached || hourlyLocalDatesConflict(cached, data, null)) return data; if (!cached || hourlyLocalDatesConflict(cached, data, null)) return data;
@@ -1951,6 +1992,7 @@ function preserveCachedRunwayHistory(cacheKey: string, data: HourlyForecast) {
...(data.amos || {}), ...(data.amos || {}),
runway_plate_history: runwayPlateHistory, runway_plate_history: runwayPlateHistory,
} as AmosData, } as AmosData,
__detailKind: "full_chart_detail",
}; };
} }
@@ -1958,7 +2000,7 @@ function primeCityDetailCache(
city: string, city: string,
resolution: string, resolution: string,
detail: CityDetail | null | undefined, detail: CityDetail | null | undefined,
): HourlyForecast { ): FullChartDetail | null {
let data = parseHourlyForecastFromCityDetail(detail || null); let data = parseHourlyForecastFromCityDetail(detail || null);
if (!data) return null; if (!data) return null;
const cacheKey = hourlyCacheKey(city, resolution); const cacheKey = hourlyCacheKey(city, resolution);
@@ -1975,8 +2017,8 @@ function queueCityDetailBatch(
city: string, city: string,
resolution: string, resolution: string,
forceRefresh: boolean, forceRefresh: boolean,
): Promise<HourlyForecast> { ): Promise<FullChartDetail | null> {
return new Promise<HourlyForecast>((resolve, reject) => { return new Promise<FullChartDetail | null>((resolve, reject) => {
const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh); const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh);
const queue = _cityDetailBatchQueues.get(queueKey) || { const queue = _cityDetailBatchQueues.get(queueKey) || {
cities: new Set<string>(), cities: new Set<string>(),
@@ -2003,7 +2045,7 @@ function queueCityDetailBatch(
function resolveBatchWaiters( function resolveBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined, waiters: CityDetailBatchWaiter[] | undefined,
value: HourlyForecast, value: FullChartDetail | null,
) { ) {
(waiters || []).forEach((waiter) => waiter.resolve(value)); (waiters || []).forEach((waiter) => waiter.resolve(value));
} }
@@ -2120,7 +2162,7 @@ async function fetchCityDetailBatchWithTimeout(
.finally(() => globalThis.clearTimeout(timeoutId)); .finally(() => globalThis.clearTimeout(timeoutId));
} }
async function fetchLiveObservationForCity(city: string): Promise<CityObservationPayload | null> { async function fetchLiveObservationForCity(city: string): Promise<ObservationSnapshot | null> {
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
return fetch(`/api/city/${encodeURIComponent(city)}/observation`, { return fetch(`/api/city/${encodeURIComponent(city)}/observation`, {
cache: "no-store", cache: "no-store",
@@ -2128,7 +2170,8 @@ async function fetchLiveObservationForCity(city: string): Promise<CityObservatio
}) })
.then(async (res) => { .then(async (res) => {
if (!res.ok) return null; if (!res.ok) return null;
return res.json() as Promise<CityObservationPayload>; const payload = await res.json() as CityObservationPayload;
return observationPayloadToSnapshot(payload);
}) })
.catch(() => null); .catch(() => null);
} }
@@ -2136,7 +2179,7 @@ async function fetchLiveObservationForCity(city: string): Promise<CityObservatio
async function fetchHourlyForecastForCity( async function fetchHourlyForecastForCity(
city: string, city: string,
options: HourlyForecastFetchOptions = {}, options: HourlyForecastFetchOptions = {},
): Promise<HourlyForecast> { ): Promise<FullChartDetail | null> {
const resParam = options.resolution || "10m"; const resParam = options.resolution || "10m";
const cacheKey = hourlyCacheKey(city, resParam); const cacheKey = hourlyCacheKey(city, resParam);
const forceRefresh = Boolean(options.ignoreCache); const forceRefresh = Boolean(options.ignoreCache);
@@ -3476,7 +3519,7 @@ export {
getVisibleTemperatureSeries, getVisibleTemperatureSeries,
isTemperatureSeriesVisibleByDefault, isTemperatureSeriesVisibleByDefault,
mergeHourlyWithLiveObservations, mergeHourlyWithLiveObservations,
mergeObservationPayloadIntoHourly, mergeObservationSnapshotIntoHourly,
mergePatchIntoHourly, mergePatchIntoHourly,
mergeRowObservationIntoHourly, mergeRowObservationIntoHourly,
normObs, normObs,
@@ -3494,8 +3537,10 @@ export {
seedHourlyForecastFromRow, seedHourlyForecastFromRow,
seriesStats, seriesStats,
shouldPollLiveChart, shouldPollLiveChart,
observationPayloadToSnapshot,
toFullChartDetail,
validNumber, validNumber,
rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest, rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest,
}; };
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay }; export type { EvidenceSeries, FullChartDetail, HourlyForecast, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };