Add market detail depth caching for dashboard data
This commit is contained in:
@@ -177,3 +177,9 @@
|
||||
{"city": "tokyo", "timestamp": "2026-04-13T10:00:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.4349999999999998, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.3, "ECMWF": 22.8, "GFS": 22.0, "ICON": 22.6, "GEM": 23.2, "JMA": 20.3}, "max_so_far": 23.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 19.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null}
|
||||
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 1.8499999999999996, "deb_prediction": 17.1, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.321}, {"v": 20, "p": 0.303}, {"v": 21, "p": 0.215}, {"v": 22, "p": 0.115}], "shadow_prob_snapshot": [{"v": 19, "p": 0.254}, {"v": 20, "p": 0.247}, {"v": 21, "p": 0.204}, {"v": 22, "p": 0.144}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 2.4974999999999996}
|
||||
{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 0.9765625, "deb_prediction": 17.1, "ensemble": {"p10": 15.0, "median": 16.0, "p90": 17.5}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.48}, {"v": 20, "p": 0.396}, {"v": 21, "p": 0.125}], "shadow_prob_snapshot": [{"v": 19, "p": 0.4}, {"v": 20, "p": 0.359}, {"v": 21, "p": 0.186}, {"v": 22, "p": 0.055}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 1.318359375}
|
||||
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 3.8000000000000007, "deb_prediction": 18.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.184}, {"v": 24, "p": 0.181}, {"v": 25, "p": 0.167}, {"v": 26, "p": 0.143}], "shadow_prob_snapshot": [{"v": 23, "p": 0.221}, {"v": 24, "p": 0.216}, {"v": 25, "p": 0.189}, {"v": 26, "p": 0.148}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 3.0}
|
||||
{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 0.6666666666666673, "deb_prediction": 18.7, "ensemble": {"p10": 17.7, "median": 18.2, "p90": 19.3}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.569}, {"v": 24, "p": 0.391}, {"v": 25, "p": 0.04}], "shadow_prob_snapshot": [{"v": 23, "p": 0.498}, {"v": 24, "p": 0.398}, {"v": 25, "p": 0.104}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 0.9000000000000009}
|
||||
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 22.7, "raw_sigma": 1.3499999999999996, "deb_prediction": 22.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.285}, {"v": 22, "p": 0.257}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.137}], "shadow_prob_snapshot": [{"v": 23, "p": 0.294}, {"v": 22, "p": 0.263}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.134}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.7, "calibrated_sigma": 1.3014441113240975}
|
||||
{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.089999999999996, "raw_sigma": 1.2890624999999998, "deb_prediction": 22.3, "ensemble": {"p10": 21.8, "median": 24.0, "p90": 25.0}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.303}, {"v": 24, "p": 0.24}, {"v": 22, "p": 0.216}, {"v": 25, "p": 0.107}], "shadow_prob_snapshot": [{"v": 23, "p": 0.313}, {"v": 24, "p": 0.244}, {"v": 22, "p": 0.218}, {"v": 25, "p": 0.103}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.089999999999996, "calibrated_sigma": 1.2428312132581776}
|
||||
|
||||
@@ -133,7 +133,7 @@ const EAGER_SUMMARY_PRIORITY_CITY_ORDER = [
|
||||
"milan",
|
||||
"madrid",
|
||||
] as const;
|
||||
type CityDetailDepth = "panel" | "nearby" | "full";
|
||||
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
|
||||
|
||||
function countAvailableModels(
|
||||
detail?: CityDetail | null,
|
||||
@@ -172,7 +172,16 @@ function hasSparseDetailCoverage(
|
||||
);
|
||||
}
|
||||
|
||||
function hasMarketDetailCoverage(
|
||||
detail?: CityDetail | null,
|
||||
targetDate?: string | null,
|
||||
): boolean {
|
||||
if (!detail) return false;
|
||||
return countAvailableModels(detail, targetDate) > 1;
|
||||
}
|
||||
|
||||
function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth {
|
||||
if (detail?.detail_depth === "market") return "market";
|
||||
if (detail?.detail_depth === "nearby") return "nearby";
|
||||
if (detail?.detail_depth === "panel") return "panel";
|
||||
return "full";
|
||||
@@ -181,9 +190,18 @@ function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth {
|
||||
function detailSatisfiesDepth(
|
||||
detail: CityDetail | null | undefined,
|
||||
depth: CityDetailDepth,
|
||||
targetDate?: string | null,
|
||||
) {
|
||||
if (!detail) return false;
|
||||
if (depth === "panel") return true;
|
||||
if (depth === "market") {
|
||||
const normalized = normalizeDetailDepth(detail);
|
||||
return (
|
||||
normalized === "market" ||
|
||||
normalized === "full" ||
|
||||
hasMarketDetailCoverage(detail, targetDate)
|
||||
);
|
||||
}
|
||||
if (depth === "nearby") {
|
||||
const normalized = normalizeDetailDepth(detail);
|
||||
return normalized === "nearby" || normalized === "full";
|
||||
@@ -192,7 +210,67 @@ function detailSatisfiesDepth(
|
||||
}
|
||||
|
||||
function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) {
|
||||
return depth === "panel" || depth === "full";
|
||||
return depth === "panel" || depth === "market" || depth === "full";
|
||||
}
|
||||
|
||||
function hasMeaningfulModelMap(
|
||||
value: Record<string, number | null> | undefined,
|
||||
): value is Record<string, number | null> {
|
||||
return Boolean(
|
||||
value &&
|
||||
Object.values(value).some((entry) => Number.isFinite(Number(entry))),
|
||||
);
|
||||
}
|
||||
|
||||
function hasMeaningfulDailyModelMap(
|
||||
value: CityDetail["multi_model_daily"] | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
value &&
|
||||
Object.values(value).some((day) =>
|
||||
hasMeaningfulModelMap(day?.models || undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCityDetail(
|
||||
current: CityDetail | undefined,
|
||||
incoming: CityDetail,
|
||||
): CityDetail {
|
||||
if (!current) return incoming;
|
||||
if (incoming.detail_depth !== "market") return incoming;
|
||||
|
||||
const mergedDepth =
|
||||
current.detail_depth === "full" || current.detail_depth === "nearby"
|
||||
? current.detail_depth
|
||||
: incoming.detail_depth;
|
||||
|
||||
return {
|
||||
...current,
|
||||
...incoming,
|
||||
detail_depth: mergedDepth,
|
||||
current: incoming.current || current.current,
|
||||
airport_current: incoming.airport_current || current.airport_current,
|
||||
deb: incoming.deb || current.deb,
|
||||
probabilities: incoming.probabilities || current.probabilities,
|
||||
trend: incoming.trend || current.trend,
|
||||
multi_model: hasMeaningfulModelMap(incoming.multi_model)
|
||||
? incoming.multi_model
|
||||
: current.multi_model,
|
||||
multi_model_daily: hasMeaningfulDailyModelMap(incoming.multi_model_daily)
|
||||
? {
|
||||
...(current.multi_model_daily || {}),
|
||||
...(incoming.multi_model_daily || {}),
|
||||
}
|
||||
: current.multi_model_daily,
|
||||
forecast: current.forecast || incoming.forecast,
|
||||
official_nearby: current.official_nearby || incoming.official_nearby,
|
||||
mgm_nearby: current.mgm_nearby || incoming.mgm_nearby,
|
||||
network_lead_signal:
|
||||
current.network_lead_signal || incoming.network_lead_signal,
|
||||
airport_vs_network_delta:
|
||||
current.airport_vs_network_delta ?? incoming.airport_vs_network_delta,
|
||||
};
|
||||
}
|
||||
|
||||
function getStoredSelectedCityName(cities: CityListItem[]) {
|
||||
@@ -420,7 +498,7 @@ export function DashboardStoreProvider({
|
||||
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: detail,
|
||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||
}));
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
@@ -444,10 +522,18 @@ export function DashboardStoreProvider({
|
||||
) => {
|
||||
const cached = cityDetailsByName[cityName];
|
||||
const cachedMeta = cityDetailMetaByName[cityName];
|
||||
const hasRequestedDepth = detailSatisfiesDepth(cached, depth);
|
||||
const marketTargetDate =
|
||||
depth === "market" ? selectedForecastDate || cached?.local_date : null;
|
||||
const hasRequestedDepth = detailSatisfiesDepth(
|
||||
cached,
|
||||
depth,
|
||||
marketTargetDate,
|
||||
);
|
||||
const cachedIsSparse =
|
||||
shouldCheckSparseCoverageForDepth(depth) &&
|
||||
hasSparseDetailCoverage(cached, cached?.local_date);
|
||||
(depth === "market"
|
||||
? hasSparseModelCoverage(cached, marketTargetDate)
|
||||
: hasSparseDetailCoverage(cached, cached?.local_date));
|
||||
if (
|
||||
!force &&
|
||||
cached &&
|
||||
@@ -472,7 +558,7 @@ export function DashboardStoreProvider({
|
||||
const detail = latestDetail;
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: detail,
|
||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||
}));
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
@@ -508,7 +594,7 @@ export function DashboardStoreProvider({
|
||||
const detail = latestDetail;
|
||||
setCityDetailsByName((current) => ({
|
||||
...current,
|
||||
[cityName]: detail,
|
||||
[cityName]: mergeCityDetail(current[cityName], detail),
|
||||
}));
|
||||
setCitySummariesByName((current) => ({
|
||||
...current,
|
||||
@@ -858,6 +944,7 @@ export function DashboardStoreProvider({
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setSelectedForecastDate(detail.local_date);
|
||||
if (access.authenticated && access.subscriptionActive) {
|
||||
void ensureCityDetail(cityName, false, "market").catch(() => {});
|
||||
// 预热市场数据,不做 await 阻塞,后台静默拉取
|
||||
void ensureCityMarketScan(
|
||||
cityName,
|
||||
@@ -1101,10 +1188,18 @@ export function DashboardStoreProvider({
|
||||
const hasFullCachedDetail =
|
||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||
!hasSparseDetailCoverage(cachedDetail, dateStr);
|
||||
const hasMarketCachedDetail = detailSatisfiesDepth(
|
||||
cachedDetail,
|
||||
"market",
|
||||
dateStr,
|
||||
);
|
||||
|
||||
setFutureModalDate(dateStr);
|
||||
const cacheKey = getMarketScanCacheKey(selectedCity, dateStr);
|
||||
setLoadingState((current) => ({ ...current, marketScan: true }));
|
||||
if (!hasMarketCachedDetail || forceRefresh) {
|
||||
void ensureCityDetail(cityName, forceRefresh, "market").catch(() => {});
|
||||
}
|
||||
if (!hasFullCachedDetail || forceRefresh) {
|
||||
setLoadingState((current) => ({
|
||||
...current,
|
||||
@@ -1154,6 +1249,11 @@ export function DashboardStoreProvider({
|
||||
const hasFullCachedDetail =
|
||||
detailSatisfiesDepth(cachedDetail, "full") &&
|
||||
!hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date);
|
||||
const hasMarketCachedDetail = detailSatisfiesDepth(
|
||||
cachedDetail,
|
||||
"market",
|
||||
cachedDetail?.local_date,
|
||||
);
|
||||
const targetDate =
|
||||
cachedDetail?.local_date || selectedForecastDate || null;
|
||||
if (targetDate) {
|
||||
@@ -1171,6 +1271,13 @@ export function DashboardStoreProvider({
|
||||
futureDeep: needsDetailRefresh,
|
||||
marketScan: true,
|
||||
}));
|
||||
if (!hasMarketCachedDetail || forceRefresh) {
|
||||
void ensureCityDetail(
|
||||
cityName,
|
||||
Boolean(forceRefresh),
|
||||
"market",
|
||||
).catch(() => {});
|
||||
}
|
||||
const initialTargetDate =
|
||||
cachedDetail?.local_date || selectedForecastDate || null;
|
||||
const initialMarketKey = getMarketScanCacheKey(
|
||||
|
||||
@@ -29,9 +29,10 @@ function normalizeCityName(cityName: string) {
|
||||
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
|
||||
}
|
||||
|
||||
function normalizeDetailDepth(depth?: "panel" | "nearby" | "full") {
|
||||
function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") {
|
||||
if (depth === "full") return "full";
|
||||
if (depth === "nearby") return "nearby";
|
||||
if (depth === "market") return "market";
|
||||
return "panel";
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ export const dashboardClient = {
|
||||
|
||||
async getCityDetail(
|
||||
cityName: string,
|
||||
options?: { force?: boolean; depth?: "panel" | "nearby" | "full" },
|
||||
options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" },
|
||||
) {
|
||||
const force = options?.force ?? false;
|
||||
const depth = normalizeDetailDepth(options?.depth);
|
||||
|
||||
@@ -327,7 +327,7 @@ export interface AiAnalysisStructured {
|
||||
export interface CityDetail {
|
||||
name: string;
|
||||
display_name: string;
|
||||
detail_depth?: "panel" | "nearby" | "full";
|
||||
detail_depth?: "panel" | "market" | "nearby" | "full";
|
||||
lat: number;
|
||||
lon: number;
|
||||
temp_symbol: string;
|
||||
|
||||
@@ -439,6 +439,8 @@ async def city_detail(
|
||||
normalized_depth = str(depth or "panel").strip().lower()
|
||||
if normalized_depth == "full":
|
||||
detail_mode = "full"
|
||||
elif normalized_depth == "market":
|
||||
detail_mode = "market"
|
||||
elif normalized_depth == "nearby":
|
||||
detail_mode = "nearby"
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user