强类型隔离图表观测和详情数据
This commit is contained in:
@@ -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)"),
|
||||||
|
|||||||
+19
-11
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user