拆分图表实时观测兜底
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
buildIntDegreeTicks,
|
||||
buildRunwayPlates,
|
||||
fetchHourlyForecastForCity,
|
||||
fetchLiveObservationForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getDebPeakWindowRange,
|
||||
getPeakGlowState,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
getVisibleTemperatureSeries,
|
||||
isTemperatureSeriesVisibleByDefault,
|
||||
mergeHourlyWithLiveObservations,
|
||||
mergeObservationPayloadIntoHourly,
|
||||
mergePatchIntoHourly,
|
||||
mergeRowObservationIntoHourly,
|
||||
normObs,
|
||||
@@ -65,7 +67,7 @@ const PEAK_GLOW_BADGE_CLASS = {
|
||||
|
||||
const PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
const FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS = 90_000;
|
||||
const NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation;
|
||||
const LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback;
|
||||
const DETAIL_LOAD_BATCH_DELAY_MS = 0;
|
||||
const TRANSIENT_DETAIL_RETRY_DELAY_MS = 3_000;
|
||||
const INITIAL_DETAIL_LOAD_SLOTS = 3;
|
||||
@@ -252,6 +254,11 @@ 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 patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) {
|
||||
const changes = patch?.changes || {};
|
||||
return String(
|
||||
@@ -848,6 +855,28 @@ export function LiveTemperatureThresholdChart({
|
||||
applySuccessfulHourlyDetail,
|
||||
});
|
||||
|
||||
const applyLiveObservationPayload = useCallback((payload: any) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const appliedAtMs = Date.now();
|
||||
const condition = payload.airport_current || payload.airport_primary || payload.current || {};
|
||||
const temp = validNumber(condition.temp);
|
||||
if (temp !== null) setLiveTemp(temp);
|
||||
if (typeof payload.local_date === "string" && payload.local_date) {
|
||||
setCurrentCityLocalDate(payload.local_date);
|
||||
}
|
||||
commitHourlySnapshot((prev) =>
|
||||
mergeObservationPayloadIntoHourly(
|
||||
prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()),
|
||||
payload,
|
||||
),
|
||||
);
|
||||
setChartFreshness((prev) => ({
|
||||
...prev,
|
||||
rowAppliedAtMs: appliedAtMs,
|
||||
rowObservationTime: observationPayloadTimeForFreshness(payload),
|
||||
}));
|
||||
}, [commitHourlySnapshot, getLatestRowSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!city || !currentRowObservationSignature) return;
|
||||
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
|
||||
@@ -1036,37 +1065,35 @@ export function LiveTemperatureThresholdChart({
|
||||
};
|
||||
}, [resyncVersion, city, runHourlyDetailFetch]);
|
||||
|
||||
// ── SSE fallback: visible charts refresh cached detail at observation cadence if patches stop. ──
|
||||
// ── SSE fallback: visible charts merge no-store observations if patches stop. ──
|
||||
useEffect(() => {
|
||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||
let cancelled = false;
|
||||
|
||||
const refreshCachedDetail = () => {
|
||||
const refreshLiveObservation = () => {
|
||||
const now = Date.now();
|
||||
lastPatchAtRef.current = now;
|
||||
|
||||
void runHourlyDetailFetch({
|
||||
source: "network",
|
||||
fetchOptions: { bypassLocalCache: true },
|
||||
applyOptions: { updateLiveTemp: true },
|
||||
isCancelled: () => cancelled,
|
||||
onSettled: () => setIsHourlyLoading(false),
|
||||
void fetchLiveObservationForCity(city).then((payload) => {
|
||||
if (cancelled || !payload) return;
|
||||
applyLiveObservationPayload(payload);
|
||||
});
|
||||
};
|
||||
|
||||
const checkFallback = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
if (Date.now() - lastPatchAtRef.current < NO_PATCH_CACHED_DETAIL_REFRESH_MS) return;
|
||||
if (Date.now() - lastPatchAtRef.current < LIVE_OBSERVATION_FALLBACK_MS) return;
|
||||
|
||||
refreshCachedDetail();
|
||||
refreshLiveObservation();
|
||||
};
|
||||
|
||||
refreshLiveObservation();
|
||||
const id = setInterval(checkFallback, 60_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [city, compact, isActive, isMaximized, targetResolution, runHourlyDetailFetch]);
|
||||
}, [city, compact, isActive, isMaximized, applyLiveObservationPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activationRefreshKey) return;
|
||||
|
||||
@@ -36,6 +36,7 @@ async function flushMicrotasks() {
|
||||
|
||||
export async function runTests() {
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback === 3 * 60_000, "chart observation fallback should poll every 180 seconds when SSE is unavailable");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.scanRows === 2 * 60_000, "region/city rows should refresh every 2 minutes");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
|
||||
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
|
||||
@@ -86,19 +87,21 @@ export async function runTests() {
|
||||
"selected city chart should consume SSE patches and keep METAR cadence for heavy probability refreshes instead of a 2-minute forced refresh",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") &&
|
||||
chartSource.includes("refreshCachedDetail") &&
|
||||
chartSource.includes("runHourlyDetailFetch") &&
|
||||
chartSource.includes("fetchOptions: { bypassLocalCache: true }") &&
|
||||
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
|
||||
chartSource.includes("refreshLiveObservation") &&
|
||||
chartSource.includes("fetchLiveObservationForCity") &&
|
||||
chartSource.includes("mergeObservationPayloadIntoHourly") &&
|
||||
chartLogicSource.includes("options.bypassLocalCache") &&
|
||||
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
|
||||
"visible charts should bypass the five-minute browser detail cache every observation cadence without force-refreshing backend sources",
|
||||
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes",
|
||||
);
|
||||
const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || [];
|
||||
const hourlyFetcherBlock =
|
||||
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || "";
|
||||
assert(
|
||||
chartSource.includes("function useHourlyDetailFetcher") &&
|
||||
componentHourlyFetchCalls.length === 1 &&
|
||||
!/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(chartSource),
|
||||
!/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock),
|
||||
"temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects",
|
||||
);
|
||||
assert(
|
||||
@@ -159,7 +162,13 @@ export async function runTests() {
|
||||
assert(
|
||||
chartLogicSource.includes("forceRefresh: boolean") &&
|
||||
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
|
||||
"ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches",
|
||||
"ignoreCache chart detail refreshes must send force_refresh=true only for heavy model/detail resyncs, not the 180-second observation fallback",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") &&
|
||||
chartLogicSource.includes('cache: "no-store"') &&
|
||||
chartLogicSource.includes("mergeObservationPayloadIntoHourly"),
|
||||
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("cityDetailBatchQueueKey") &&
|
||||
|
||||
@@ -197,8 +197,8 @@ export function runTests() {
|
||||
"temperature chart must keep METAR cadence for heavy patch-triggered probability refreshes",
|
||||
);
|
||||
assert(
|
||||
chart.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation"),
|
||||
"temperature chart must use observation cadence for lightweight cached no-patch refreshes",
|
||||
chart.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback"),
|
||||
"temperature chart must use the 180-second observation fallback cadence when SSE patches stop",
|
||||
);
|
||||
assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas");
|
||||
assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars");
|
||||
@@ -229,13 +229,15 @@ export function runTests() {
|
||||
chart.includes("ignoreCache: true") && chart.includes("currentCityLocalDate !== loadedLocalDate"),
|
||||
"temperature chart must background-refresh full city detail when the city-local day rolls over",
|
||||
);
|
||||
const fallbackRefreshBlock = chart.match(/const refreshCachedDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
|
||||
const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
|
||||
assert(
|
||||
fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
|
||||
fallbackRefreshBlock.includes("fetchOptions: { bypassLocalCache: true }") &&
|
||||
fallbackRefreshBlock.includes("fetchLiveObservationForCity") &&
|
||||
fallbackRefreshBlock.includes("applyLiveObservationPayload") &&
|
||||
chart.includes("mergeObservationPayloadIntoHourly") &&
|
||||
!fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
|
||||
!fallbackRefreshBlock.includes("ignoreCache: true") &&
|
||||
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
|
||||
"no-patch fallback refresh should revalidate through cached backend detail without force-refreshing sources or showing the loading overlay",
|
||||
"no-patch fallback refresh should merge no-store observation data without refreshing cached detail-batch or showing the loading overlay",
|
||||
);
|
||||
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, runHourlyDetailFetch\]\);/)?.[0] || "";
|
||||
assert(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||
import {
|
||||
buildFullDayChartData,
|
||||
mergeObservationPayloadIntoHourly,
|
||||
mergeHourlyWithLiveObservations,
|
||||
mergePatchIntoHourly,
|
||||
mergeRowObservationIntoHourly,
|
||||
@@ -835,6 +836,53 @@ export function runTests() {
|
||||
"instant-restore cache must include live-merged runway history so returning to terminal shows it immediately",
|
||||
);
|
||||
|
||||
const observationOnlyPayload = {
|
||||
city: "chengdu",
|
||||
local_date: "2026-06-15",
|
||||
local_time: "17:45",
|
||||
airport_current: {
|
||||
temp: 27.1,
|
||||
obs_time: "2026-06-15T17:45:00+08:00",
|
||||
source_code: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
airport_primary: {
|
||||
temp: 27.1,
|
||||
obs_time: "2026-06-15T17:45:00+08:00",
|
||||
source_code: "amsc_awos",
|
||||
source_label: "AMSC AWOS",
|
||||
},
|
||||
metar_today_obs: [
|
||||
{
|
||||
time: "17:45",
|
||||
temp: 27.1,
|
||||
obs_time: "2026-06-15T17:45:00+08:00",
|
||||
source_code: "amsc_awos",
|
||||
},
|
||||
],
|
||||
runway_plate_history: {
|
||||
"02L/20R": [{ time: "2026-06-15T17:45:00+08:00", tdz_temp: 27.1, end_temp: 26.8 }],
|
||||
},
|
||||
};
|
||||
const observationMergedChengdu = mergeObservationPayloadIntoHourly(
|
||||
cachedChengduDetail,
|
||||
observationOnlyPayload as any,
|
||||
);
|
||||
assert(
|
||||
observationMergedChengdu?.airportCurrent?.temp === 27.1 &&
|
||||
observationMergedChengdu?.airportPrimary?.source_code === "amsc_awos",
|
||||
"observation endpoint payload 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",
|
||||
);
|
||||
assert(
|
||||
(observationMergedChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2,
|
||||
"observation endpoint payload should append fresh runway history onto cached detail history",
|
||||
);
|
||||
|
||||
_hourlyCache.clear();
|
||||
for (let i = 0; i < 180; i += 1) {
|
||||
const row = {
|
||||
|
||||
@@ -1602,6 +1602,16 @@ function mergeHourlyWithLiveObservations(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeObservationPayloadIntoHourly(
|
||||
prev: HourlyForecast,
|
||||
payload: CityObservationPayload | null | undefined,
|
||||
): HourlyForecast {
|
||||
const live = observationPayloadToHourly(payload);
|
||||
if (!prev) return live;
|
||||
if (!live) return prev;
|
||||
return mergeHourlyWithLiveObservations(prev, live, null);
|
||||
}
|
||||
|
||||
function mergeRowObservationIntoHourly(
|
||||
prev: HourlyForecast,
|
||||
row: ScanOpportunityRow | null,
|
||||
@@ -1643,6 +1653,22 @@ type HourlyForecastFetchOptions = {
|
||||
resolution?: string;
|
||||
};
|
||||
|
||||
type CityObservationPayload = {
|
||||
city?: string | null;
|
||||
local_date?: string | null;
|
||||
local_time?: string | null;
|
||||
current?: Record<string, any> | null;
|
||||
airport_current?: Record<string, any> | null;
|
||||
airport_primary?: Record<string, any> | null;
|
||||
amos?: AmosData | null;
|
||||
runway_plate_history?: Record<string, Array<Record<string, unknown>>> | null;
|
||||
runway_points?: Array<Record<string, unknown>>;
|
||||
metar_today_obs?: Array<Record<string, any>>;
|
||||
timeseries?: {
|
||||
metar_today_obs?: Array<Record<string, any>>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type CityDetailBatchPayload = {
|
||||
cities?: string[];
|
||||
details?: Record<string, CityDetail | null | undefined>;
|
||||
@@ -1669,6 +1695,126 @@ const CITY_DETAIL_BATCH_WINDOW_MS = 100;
|
||||
const CITY_DETAIL_BATCH_MAX_CITIES = 12;
|
||||
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
|
||||
|
||||
function normalizeObservationCondition(block: Record<string, any> | null | undefined): AirportCurrentConditions | null {
|
||||
if (!block || typeof block !== "object") return null;
|
||||
const temp = validNumber(block.temp);
|
||||
const obsTime = String(block.obs_time || block.observed_at || block.observation_time || block.time || "").trim();
|
||||
if (temp === null || !obsTime) return null;
|
||||
return {
|
||||
...(block as AirportCurrentConditions),
|
||||
temp,
|
||||
obs_time: obsTime,
|
||||
max_so_far: validNumber(block.max_so_far) ?? validNumber(block.max_temp_so_far) ?? temp,
|
||||
source_code: String(block.source_code || block.source || "").trim() || null,
|
||||
source_label: String(block.source_label || block.settlement_source_label || block.source || "").trim() || null,
|
||||
station_code: String(block.station_code || block.icao || "").trim() || null,
|
||||
station_label: String(block.station_label || block.station_name || "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObservationPoint(point: Record<string, any>): ObsPoint | null {
|
||||
const temp = validNumber(point.temp);
|
||||
const time = String(point.time || point.obs_time || point.observed_at || point.observation_time || "").trim();
|
||||
if (temp === null || !time) return null;
|
||||
return { time, temp };
|
||||
}
|
||||
|
||||
function normalizeObservationRunwayHistory(
|
||||
history: CityObservationPayload["runway_plate_history"],
|
||||
runwayPoints: CityObservationPayload["runway_points"],
|
||||
) {
|
||||
const normalized: Record<string, Array<Record<string, unknown>>> = {};
|
||||
Object.entries(history || {}).forEach(([runway, points]) => {
|
||||
if (!Array.isArray(points)) return;
|
||||
const normalizedPoints = points
|
||||
.map((point): Record<string, unknown> | null => {
|
||||
if (!point || typeof point !== "object") return null;
|
||||
const value =
|
||||
parseRunwayHistoryValue(point) ??
|
||||
validNumber((point as any).target_runway_max) ??
|
||||
validNumber((point as any).tdz_temp) ??
|
||||
validNumber((point as any).end_temp);
|
||||
const time = String((point as any).timestamp || (point as any).time || (point as any).observed_at || "").trim();
|
||||
if (value === null || !time) return null;
|
||||
return {
|
||||
...point,
|
||||
timestamp: time,
|
||||
value,
|
||||
temp_c: value,
|
||||
};
|
||||
})
|
||||
.filter((point): point is Record<string, unknown> => point !== null);
|
||||
if (normalizedPoints.length) normalized[runway] = normalizedPoints;
|
||||
});
|
||||
|
||||
for (const point of runwayPoints || []) {
|
||||
if (!point || typeof point !== "object") continue;
|
||||
const runway = String((point as any).runway || "").trim().toUpperCase();
|
||||
if (!runway) continue;
|
||||
const value =
|
||||
parseRunwayHistoryValue(point) ??
|
||||
validNumber((point as any).target_runway_max) ??
|
||||
validNumber((point as any).tdz_temp) ??
|
||||
validNumber((point as any).end_temp);
|
||||
const time = String((point as any).timestamp || (point as any).time || (point as any).observed_at || "").trim();
|
||||
if (value === null || !time) continue;
|
||||
normalized[runway] = [
|
||||
...(normalized[runway] || []),
|
||||
{
|
||||
...point,
|
||||
timestamp: time,
|
||||
value,
|
||||
temp_c: value,
|
||||
},
|
||||
].slice(-MAX_OBS_POINTS);
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length ? normalized : undefined;
|
||||
}
|
||||
|
||||
function observationPayloadToHourly(payload: CityObservationPayload | null | undefined): HourlyForecast {
|
||||
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"
|
||||
? {
|
||||
...(payload.current as CurrentConditions),
|
||||
temp: validNumber(payload.current.temp),
|
||||
}
|
||||
: null;
|
||||
const metarTodayObs = [
|
||||
...((payload.timeseries?.metar_today_obs || []) as Array<Record<string, any>>),
|
||||
...((payload.metar_today_obs || []) as Array<Record<string, any>>),
|
||||
]
|
||||
.map(normalizeObservationPoint)
|
||||
.filter((point): point is ObsPoint => point !== null);
|
||||
const airportPrimaryTodayObs = (airportPrimary?.obs_time && validNumber(airportPrimary.temp) !== null)
|
||||
? appendRawObservationPoint(undefined, airportPrimary.obs_time, Number(airportPrimary.temp))
|
||||
: undefined;
|
||||
return {
|
||||
forecastTodayHigh: null,
|
||||
debPrediction: null,
|
||||
debQuality: null,
|
||||
debHourlyPath: null,
|
||||
localDate: payload.local_date || null,
|
||||
localTime: payload.local_time || airportPrimary?.obs_time || airportCurrent?.obs_time || null,
|
||||
times: [],
|
||||
temps: [],
|
||||
modelTimes: undefined,
|
||||
modelCurves: undefined,
|
||||
forecastDaily: [],
|
||||
multiModelDaily: {},
|
||||
probabilities: null,
|
||||
runwayPlateHistory: normalizeObservationRunwayHistory(payload.runway_plate_history, payload.runway_points),
|
||||
amos: payload.amos || null,
|
||||
current,
|
||||
airportCurrent,
|
||||
airportPrimary,
|
||||
metarTodayObs: metarTodayObs.length ? metarTodayObs : undefined,
|
||||
airportPrimaryTodayObs,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
|
||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||
if (!json || !hourlySource) return null;
|
||||
@@ -1895,6 +2041,19 @@ async function fetchCityDetailBatchWithTimeout(
|
||||
.finally(() => globalThis.clearTimeout(timeoutId));
|
||||
}
|
||||
|
||||
async function fetchLiveObservationForCity(city: string): Promise<CityObservationPayload | null> {
|
||||
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
|
||||
return fetch(`/api/city/${encodeURIComponent(city)}/observation`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityObservationPayload>;
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
async function fetchHourlyForecastForCity(
|
||||
city: string,
|
||||
options: HourlyForecastFetchOptions = {},
|
||||
@@ -3223,6 +3382,7 @@ export {
|
||||
buildModelSummaryCards,
|
||||
buildRunwayPlates,
|
||||
fetchHourlyForecastForCity,
|
||||
fetchLiveObservationForCity,
|
||||
getActiveTemperatureSeries,
|
||||
getTemperatureSeriesForRunwayDetailsMode,
|
||||
getLiveObservationLabels,
|
||||
@@ -3230,6 +3390,7 @@ export {
|
||||
getVisibleTemperatureSeries,
|
||||
isTemperatureSeriesVisibleByDefault,
|
||||
mergeHourlyWithLiveObservations,
|
||||
mergeObservationPayloadIntoHourly,
|
||||
mergePatchIntoHourly,
|
||||
mergeRowObservationIntoHourly,
|
||||
normObs,
|
||||
|
||||
Reference in New Issue
Block a user