From 491415427c02877b2e9afb601fb2bfcc86c14be6 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 23:30:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E7=B1=BB=E5=9E=8B=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E8=A7=82=E6=B5=8B=E5=92=8C=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTemperatureThresholdChart.tsx | 41 +++--- .../__tests__/refreshCadencePolicy.test.ts | 27 +++- .../__tests__/ssePatchArchitecture.test.ts | 8 +- .../__tests__/temperatureChartData.test.ts | 30 ++-- .../scan-terminal/temperature-chart-logic.ts | 137 ++++++++++++------ 5 files changed, 159 insertions(+), 84 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index bf1a286e..90a7f9d7 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -29,7 +29,7 @@ import { getVisibleTemperatureSeries, isTemperatureSeriesVisibleByDefault, mergeHourlyWithLiveObservations, - mergeObservationPayloadIntoHourly, + mergeObservationSnapshotIntoHourly, mergePatchIntoHourly, mergeRowObservationIntoHourly, normObs, @@ -45,6 +45,7 @@ import { shouldPollLiveChart, validNumber, type HourlyForecast, + type ObservationSnapshot, } 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; } -function observationPayloadTimeForFreshness(payload: any) { - const block = payload?.airport_current || payload?.airport_primary || payload?.current || {}; - return String(block.obs_time || block.observed_at || block.observation_time || payload?.local_time || "").trim() || null; +function observationSnapshotTimeForFreshness(snapshot: ObservationSnapshot) { + const block = snapshot.airport_current || snapshot.airport_primary || snapshot.current || {}; + return String(block.obs_time || block.observed_at || block.observation_time || snapshot.local_time || "").trim() || null; } function patchObservationTimeForFreshness(patch: { changes?: Record } | null | undefined) { @@ -847,25 +848,25 @@ export function LiveTemperatureThresholdChart({ applySuccessfulHourlyDetail, }); - const applyLiveObservationPayload = useCallback((payload: any) => { - if (!payload || typeof payload !== "object") return; + const applyLiveObservationSnapshot = useCallback((snapshot: ObservationSnapshot) => { + if (!snapshot || typeof snapshot !== "object") return; 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); if (temp !== null) setLiveTemp(temp); - if (typeof payload.local_date === "string" && payload.local_date) { - setCurrentCityLocalDate(payload.local_date); + if (typeof snapshot.local_date === "string" && snapshot.local_date) { + setCurrentCityLocalDate(snapshot.local_date); } commitHourlySnapshot((prev) => - mergeObservationPayloadIntoHourly( + mergeObservationSnapshotIntoHourly( prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), - payload, + snapshot, ), ); setChartFreshness((prev) => ({ ...prev, rowAppliedAtMs: appliedAtMs, - rowObservationTime: observationPayloadTimeForFreshness(payload), + rowObservationTime: observationSnapshotTimeForFreshness(snapshot), })); }, [commitHourlySnapshot, getLatestRowSnapshot]); @@ -1021,14 +1022,14 @@ export function LiveTemperatureThresholdChart({ useEffect(() => { if (!resyncVersion || !city) return; let cancelled = false; - void fetchLiveObservationForCity(city).then((payload) => { - if (cancelled || !payload) return; - applyLiveObservationPayload(payload); + void fetchLiveObservationForCity(city).then((snapshot) => { + if (cancelled || !snapshot) return; + applyLiveObservationSnapshot(snapshot); }); return () => { cancelled = true; }; - }, [resyncVersion, city, applyLiveObservationPayload]); + }, [resyncVersion, city, applyLiveObservationSnapshot]); // ── SSE fallback: visible charts merge no-store observations if patches stop. ── useEffect(() => { @@ -1039,9 +1040,9 @@ export function LiveTemperatureThresholdChart({ const now = Date.now(); lastPatchAtRef.current = now; - void fetchLiveObservationForCity(city).then((payload) => { - if (cancelled || !payload) return; - applyLiveObservationPayload(payload); + void fetchLiveObservationForCity(city).then((snapshot) => { + if (cancelled || !snapshot) return; + applyLiveObservationSnapshot(snapshot); }); }; @@ -1058,7 +1059,7 @@ export function LiveTemperatureThresholdChart({ cancelled = true; clearInterval(id); }; - }, [city, compact, isActive, isMaximized, applyLiveObservationPayload]); + }, [city, compact, isActive, isMaximized, applyLiveObservationSnapshot]); useEffect(() => { if (!activationRefreshKey) return; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 1dabff97..9a5ba7f2 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -90,7 +90,7 @@ export async function runTests() { chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") && chartSource.includes("refreshLiveObservation") && chartSource.includes("fetchLiveObservationForCity") && - chartSource.includes("mergeObservationPayloadIntoHourly") && + chartSource.includes("mergeObservationSnapshotIntoHourly") && chartLogicSource.includes("options.bypassLocalCache") && 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", @@ -137,7 +137,7 @@ export async function runTests() { ); assert( chartSource.includes("fetchLiveObservationForCity") && - chartSource.includes("mergeObservationPayloadIntoHourly") && + chartSource.includes("mergeObservationSnapshotIntoHourly") && !chartSource.includes("rememberHourlyDetailSnapshot"), "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( chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") && 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", ); + assert( + /type FullChartDetail\s*=\s*NonNullable\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/.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/.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( chartLogicSource.includes("cityDetailBatchQueueKey") && chartLogicSource.includes('forceRefresh ? "force" : "cached"'), diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 07a03ffd..cdaf41f9 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -232,8 +232,8 @@ export function runTests() { const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; assert( fallbackRefreshBlock.includes("fetchLiveObservationForCity") && - fallbackRefreshBlock.includes("applyLiveObservationPayload") && - chart.includes("mergeObservationPayloadIntoHourly") && + fallbackRefreshBlock.includes("applyLiveObservationSnapshot") && + chart.includes("mergeObservationSnapshotIntoHourly") && !fallbackRefreshBlock.includes("runHourlyDetailFetch") && !fallbackRefreshBlock.includes("ignoreCache: true") && !fallbackRefreshBlock.includes("setIsHourlyLoading(true)"), @@ -243,10 +243,10 @@ export function runTests() { !chart.includes("rememberHourlyDetailSnapshot"), "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( resyncBlock.includes("fetchLiveObservationForCity") && - resyncBlock.includes("applyLiveObservationPayload") && + resyncBlock.includes("applyLiveObservationSnapshot") && !resyncBlock.includes("runHourlyDetailFetch") && !resyncBlock.includes("ignoreCache: true") && !resyncBlock.includes("setIsHourlyLoading(true)"), diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index f433455a..7946d2ac 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -3,14 +3,16 @@ import type { CityDetail } from "@/lib/dashboard-types"; import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { buildFullDayChartData, - mergeObservationPayloadIntoHourly, + mergeObservationSnapshotIntoHourly, mergeHourlyWithLiveObservations, mergePatchIntoHourly, mergeRowObservationIntoHourly, + observationPayloadToSnapshot, readCachedHourlyForInitialRow, rememberHourlyDetailSnapshot, selectInitialHourlyForRowChange, seedHourlyForecastFromRow, + toFullChartDetail, _hourlyCache, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; @@ -954,13 +956,13 @@ export function runTests() { tz_offset_seconds: 8 * 3600, metar_context: { source: "amsc_awos" }, } as any; - rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow)); + rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any); assert( !_hourlyCache.has(chengduCacheKey), "instant-restore cache must not persist a row-only seed that would block the full detail fetch", ); - const cachedChengduDetail = { + const cachedChengduDetail = toFullChartDetail({ ...seedHourlyForecastFromRow(cachedChengduRow), localDate: "2026-06-15", times: ["08:00", "09:00"], @@ -975,7 +977,8 @@ export function runTests() { runwayPlateHistory: { "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, { city: "chengdu", revision: 7, @@ -985,7 +988,9 @@ export function runTests() { runway_points: [{ runway: "02L/20R", temp: 26.8 }], }, } 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; assert( 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 }], }, }; - 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, - observationOnlyPayload as any, + observationSnapshot, ); assert( observationMergedChengdu?.airportCurrent?.temp === 27.1 && 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( observationMergedChengdu?.modelCurves?.ECMWF?.length === 2 && 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( (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(); @@ -1055,13 +1062,14 @@ export function runTests() { temp_symbol: "°C", tz_offset_seconds: 0, } as any; - rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", { + const cacheDetail = toFullChartDetail({ ...seedHourlyForecastFromRow(row), times: ["09:00"], temps: [20 + i / 100], modelTimes: ["09:00"], modelCurves: { ECMWF: [20 + i / 100] }, } as any); + if (cacheDetail) rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", cacheDetail); } assert( _hourlyCache.size <= 160, diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index ca37d0a6..d7aab8a1 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -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 MAX_HOURLY_CACHE_ENTRIES = 160; const HOURLY_FORCE_REFRESH_DEDUP_MS = 60_000; -const _hourlyCache = new Map(); -const _hourlyRequestCache = new Map>(); +const _hourlyCache = new Map(); +const _hourlyRequestCache = new Map>(); const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000; let _hourlyActiveDetailRequests = 0; @@ -374,7 +374,7 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c 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 HourlyDetailSnapshotEntry = HourlyCacheEntry & { source: HourlyDetailSnapshotSource }; type CityDetailBatchDiagnostics = Record; @@ -442,14 +442,25 @@ function isRetainedHourlyCacheEntry( } 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() { 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); + continue; } + if (normalized !== entry) _hourlyCache.set(key, normalized); } if (_hourlyCache.size <= MAX_HOURLY_CACHE_ENTRIES) return; @@ -476,11 +487,11 @@ function readSessionCache( try { const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); if (!raw) return null; - const item = JSON.parse(raw); + const item = normalizeHourlyCacheEntry(JSON.parse(raw)); const maxAgeMs = options.allowStale ? HOURLY_CACHE_STALE_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}`); return null; } @@ -495,15 +506,16 @@ function readHourlyCacheEntry( cacheKey: string, options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyCacheEntry | null { - const cached = _hourlyCache.get(cacheKey); + const cachedRaw = _hourlyCache.get(cacheKey); + const cached = normalizeHourlyCacheEntry(cachedRaw); if ( cached && - isUsableHourlyDetailCacheEntry(cached) && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) ) { + if (cached !== cachedRaw) _hourlyCache.set(cacheKey, cached); return cached; } - if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { + if (cachedRaw && (!cached || !isRetainedHourlyCacheEntry(cached))) { _hourlyCache.delete(cacheKey); } @@ -520,15 +532,16 @@ function readHourlyCacheSnapshot( cacheKey: string, options: { allowStale?: boolean; maxAgeMs?: number } = {}, ): HourlyDetailSnapshotEntry | null { - const cached = _hourlyCache.get(cacheKey); + const cachedRaw = _hourlyCache.get(cacheKey); + const cached = normalizeHourlyCacheEntry(cachedRaw); if ( cached && - isUsableHourlyDetailCacheEntry(cached) && (options.allowStale ? isRetainedHourlyCacheEntry(cached) : isFreshHourlyCacheEntry(cached, options.maxAgeMs)) ) { + if (cached !== cachedRaw) _hourlyCache.set(cacheKey, cached); return { ...cached, source: "memory_cache" }; } - if (cached && (!isUsableHourlyDetailCacheEntry(cached) || !isRetainedHourlyCacheEntry(cached))) { + if (cachedRaw && (!cached || !isRetainedHourlyCacheEntry(cached))) { _hourlyCache.delete(cacheKey); } @@ -541,7 +554,7 @@ function readHourlyCacheSnapshot( 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; try { sessionStorage.setItem( @@ -551,7 +564,7 @@ function writeSessionCache(city: string, data: HourlyForecast, ts = Date.now()) } catch {} } -function writeHourlyCacheEntry(cacheKey: string, data: HourlyForecast, ts = Date.now()) { +function writeHourlyCacheEntry(cacheKey: string, data: FullChartDetail, ts = Date.now()) { if (!cacheKey || !data) return; rememberMemoryHourlyCacheEntry(cacheKey, { ts, data }); writeSessionCache(cacheKey, data, ts); @@ -595,12 +608,13 @@ function readCachedHourlyForInitialRow( function rememberHourlyDetailSnapshot( city: string, resolution: string, - data: HourlyForecast, + data: FullChartDetail, ) { const cityKey = normalizeCityKey(city); - if (!cityKey || !data || !hasFullHourlyDetailPayload(data)) return; + const detail = toFullChartDetail(data); + if (!cityKey || !detail) return; const cacheKey = hourlyCacheKey(cityKey, resolution); - writeHourlyCacheEntry(cacheKey, data); + writeHourlyCacheEntry(cacheKey, detail); } 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(value: T[] | null | undefined): value is T[] { return Array.isArray(value) && value.length > 0; } @@ -1568,6 +1591,10 @@ type HourlyForecast = { airportPrimaryTodayObs?: RawObsPoint[]; } | null; +type FullChartDetail = NonNullable & { + readonly __detailKind: "full_chart_detail"; +}; + function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForecast { if (!row) return null; const current = rowCurrentObservation(row); @@ -1680,11 +1707,11 @@ function mergeHourlyWithLiveObservations( }; } -function mergeObservationPayloadIntoHourly( +function mergeObservationSnapshotIntoHourly( prev: HourlyForecast, - payload: CityObservationPayload | null | undefined, + snapshot: ObservationSnapshot | null | undefined, ): HourlyForecast { - const live = observationPayloadToHourly(payload); + const live = observationSnapshotToHourly(snapshot); if (!prev) return live; if (!live) return prev; return mergeHourlyWithLiveObservations(prev, live, null); @@ -1747,6 +1774,10 @@ type CityObservationPayload = { } | null; }; +type ObservationSnapshot = CityObservationPayload & { + readonly __observationKind: "observation_snapshot"; +}; + type CityDetailBatchPayload = { cities?: string[]; details?: Record; @@ -1757,7 +1788,7 @@ type CityDetailBatchPayload = { }; type CityDetailBatchWaiter = { - resolve: (value: HourlyForecast) => void; + resolve: (value: FullChartDetail | null) => void; reject: (reason?: unknown) => void; }; @@ -1850,19 +1881,28 @@ function normalizeObservationRunwayHistory( 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; - const airportCurrent = normalizeObservationCondition(payload.airport_current || payload.current); - const airportPrimary = normalizeObservationCondition(payload.airport_primary || payload.airport_current || payload.current); - const current = payload.current && typeof payload.current === "object" + if ((payload as any).__observationKind === "observation_snapshot") return payload as ObservationSnapshot; + return { + ...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), - temp: validNumber(payload.current.temp), + ...(snapshot.current as CurrentConditions), + temp: validNumber(snapshot.current.temp), } : null; const metarTodayObs = [ - ...((payload.timeseries?.metar_today_obs || []) as Array>), - ...((payload.metar_today_obs || []) as Array>), + ...((snapshot.timeseries?.metar_today_obs || []) as Array>), + ...((snapshot.metar_today_obs || []) as Array>), ] .map(normalizeObservationPoint) .filter((point): point is ObsPoint => point !== null); @@ -1874,8 +1914,8 @@ function observationPayloadToHourly(payload: CityObservationPayload | null | und debPrediction: null, debQuality: null, debHourlyPath: null, - localDate: payload.local_date || null, - localTime: payload.local_time || airportPrimary?.obs_time || airportCurrent?.obs_time || null, + localDate: snapshot.local_date || null, + localTime: snapshot.local_time || airportPrimary?.obs_time || airportCurrent?.obs_time || null, times: [], temps: [], modelTimes: undefined, @@ -1883,8 +1923,8 @@ function observationPayloadToHourly(payload: CityObservationPayload | null | und forecastDaily: [], multiModelDaily: {}, probabilities: null, - runwayPlateHistory: normalizeObservationRunwayHistory(payload.runway_plate_history, payload.runway_points), - amos: payload.amos || null, + runwayPlateHistory: normalizeObservationRunwayHistory(snapshot.runway_plate_history, snapshot.runway_points), + amos: snapshot.amos || null, current, airportCurrent, 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; if (!json || !hourlySource) return null; - return { + const parsed: HourlyForecast = { forecastTodayHigh: json.forecast?.today_high ?? null, debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null, 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, 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; const cached = readHourlyCacheEntry(cacheKey, { allowStale: true })?.data; if (!cached || hourlyLocalDatesConflict(cached, data, null)) return data; @@ -1951,6 +1992,7 @@ function preserveCachedRunwayHistory(cacheKey: string, data: HourlyForecast) { ...(data.amos || {}), runway_plate_history: runwayPlateHistory, } as AmosData, + __detailKind: "full_chart_detail", }; } @@ -1958,7 +2000,7 @@ function primeCityDetailCache( city: string, resolution: string, detail: CityDetail | null | undefined, -): HourlyForecast { +): FullChartDetail | null { let data = parseHourlyForecastFromCityDetail(detail || null); if (!data) return null; const cacheKey = hourlyCacheKey(city, resolution); @@ -1975,8 +2017,8 @@ function queueCityDetailBatch( city: string, resolution: string, forceRefresh: boolean, -): Promise { - return new Promise((resolve, reject) => { +): Promise { + return new Promise((resolve, reject) => { const queueKey = cityDetailBatchQueueKey(resolution, forceRefresh); const queue = _cityDetailBatchQueues.get(queueKey) || { cities: new Set(), @@ -2003,7 +2045,7 @@ function queueCityDetailBatch( function resolveBatchWaiters( waiters: CityDetailBatchWaiter[] | undefined, - value: HourlyForecast, + value: FullChartDetail | null, ) { (waiters || []).forEach((waiter) => waiter.resolve(value)); } @@ -2120,7 +2162,7 @@ async function fetchCityDetailBatchWithTimeout( .finally(() => globalThis.clearTimeout(timeoutId)); } -async function fetchLiveObservationForCity(city: string): Promise { +async function fetchLiveObservationForCity(city: string): Promise { const headers = await buildBrowserBackendHeaders({ Accept: "application/json" }); return fetch(`/api/city/${encodeURIComponent(city)}/observation`, { cache: "no-store", @@ -2128,7 +2170,8 @@ async function fetchLiveObservationForCity(city: string): Promise { if (!res.ok) return null; - return res.json() as Promise; + const payload = await res.json() as CityObservationPayload; + return observationPayloadToSnapshot(payload); }) .catch(() => null); } @@ -2136,7 +2179,7 @@ async function fetchLiveObservationForCity(city: string): Promise { +): Promise { const resParam = options.resolution || "10m"; const cacheKey = hourlyCacheKey(city, resParam); const forceRefresh = Boolean(options.ignoreCache); @@ -3476,7 +3519,7 @@ export { getVisibleTemperatureSeries, isTemperatureSeriesVisibleByDefault, mergeHourlyWithLiveObservations, - mergeObservationPayloadIntoHourly, + mergeObservationSnapshotIntoHourly, mergePatchIntoHourly, mergeRowObservationIntoHourly, normObs, @@ -3494,8 +3537,10 @@ export { seedHourlyForecastFromRow, seriesStats, shouldPollLiveChart, + observationPayloadToSnapshot, + toFullChartDetail, validNumber, rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest, }; -export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay }; +export type { EvidenceSeries, FullChartDetail, HourlyForecast, ObservationSnapshot, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };