Compare commits

...

20 Commits

Author SHA1 Message Date
2569718930@qq.com 9823e3961f Add localized meteorology text and filter implausible METAR temps 2026-04-16 17:34:39 +08:00
2569718930@qq.com 2598c5ac98 Disable caching for city list updates 2026-04-16 17:18:19 +08:00
2569718930@qq.com e2cb0cfe5e Add professional intraday meteorology analysis 2026-04-16 17:13:53 +08:00
2569718930@qq.com fd2b870d6a Add Manila Karachi and Masroor weather sources 2026-04-16 16:43:37 +08:00
2569718930@qq.com 46b0e63ffd Prefer METAR over NMC for city observation display 2026-04-16 15:53:00 +08:00
2569718930@qq.com 0eee5c8948 Stabilize METAR fetching with fallback requests 2026-04-16 15:43:47 +08:00
2569718930@qq.com 25424700ce Restore observed temperatures on map and trend charts 2026-04-16 15:29:26 +08:00
2569718930@qq.com 767f7ed6bf Show observed temp and format observation updates 2026-04-16 15:19:31 +08:00
2569718930@qq.com c0e5307893 Add cached dashboard prewarm hints for priority cities 2026-04-16 15:05:14 +08:00
2569718930@qq.com b635070106 Fix history fallback handling and auth status probe behavior 2026-04-16 13:26:53 +08:00
2569718930@qq.com 2ead56f59d Simplify detail panel loading state 2026-04-16 13:21:29 +08:00
2569718930@qq.com 611e2d9009 Expand calibration samples and extend training retention 2026-04-16 01:05:43 +08:00
2569718930@qq.com 53d2ae4c10 Retire dual storage mode and default runtime state to sqlite 2026-04-16 00:52:55 +08:00
2569718930@qq.com 4bfd2534bb Allow free users to load city detail panels 2026-04-16 00:30:12 +08:00
2569718930@qq.com df82234b66 Remove intraday structure signals from analysis modal 2026-04-16 00:24:30 +08:00
2569718930@qq.com fcdece7ab4 Fix dashboard observation legend ordering 2026-04-16 00:16:33 +08:00
2569718930@qq.com 2e81894fd1 Remove detail summary card from the dashboard panel 2026-04-16 00:12:00 +08:00
2569718930@qq.com 26c4162f50 Simplify detail panel by removing snapshot copy and scenery 2026-04-16 00:08:29 +08:00
2569718930@qq.com 6249a00382 Refine dashboard detail panel layout 2026-04-15 23:51:54 +08:00
2569718930@qq.com da3acbbb6e feat: initialize project dashboard layout and global design system with Tailwind CSS 2026-04-15 23:08:56 +08:00
43 changed files with 4024 additions and 2481 deletions
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -42,25 +42,25 @@
"nws"
],
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
"sample_count": 54,
"train_count": 42,
"sample_count": 18,
"train_count": 6,
"validation_count": 12,
"metrics": {
"validation": {
"sample_count": 12,
"lgbm_mae": 1.349,
"deb_mae": 0.875,
"best_single_mae": 0.325,
"median_mae": 0.758
"lgbm_mae": 7.992,
"deb_mae": 2.375,
"best_single_mae": 1.05,
"median_mae": 2.292
},
"full_sample": {
"sample_count": 54,
"lgbm_mae": 0.691,
"deb_mae": 6.287,
"best_single_mae": 5.431,
"median_mae": 6.265
"sample_count": 18,
"lgbm_mae": 3.034,
"deb_mae": 1.689,
"best_single_mae": 0.822,
"median_mae": 1.711
}
},
"generated_at": "2026-04-02T16:27:44.816882Z",
"trained_at": "2026-04-02T16:27:44.816882Z"
"generated_at": "2026-04-15T16:41:56.814421Z",
"trained_at": "2026-04-15T16:41:56.814421Z"
}
+63 -44
View File
@@ -1,20 +1,20 @@
{
"version": "emos-20260402162744",
"trained_at": "2026-04-02T16:27:44.114836+00:00",
"version": "emos-20260415170349",
"trained_at": "2026-04-15T17:03:49.122865+00:00",
"global": {
"mu": {
"intercept": 1.54512641,
"raw_mu_coef": 2.96105052,
"deb_coef": -1.53260815,
"ens_median_coef": -0.72849343,
"max_so_far_gap_coef": 9.52557689
"intercept": 2.45853035,
"raw_mu_coef": 2.03689031,
"deb_coef": -1.41207502,
"ens_median_coef": 0.08616811,
"max_so_far_gap_coef": -0.5886898
},
"sigma": {
"intercept": 0.67432479,
"raw_sigma_coef": 0.6936692,
"spread_coef": 0.08877484,
"peak_flag_coef": -0.58374835,
"max_so_far_gap_coef": -0.8172477
"intercept": 0.61260118,
"raw_sigma_coef": -1.48747774,
"spread_coef": 2.24562634,
"peak_flag_coef": -0.15906747,
"max_so_far_gap_coef": -0.01835251
}
},
"sigma_constraints": {
@@ -29,62 +29,81 @@
"max_bucket_brier_increase": 0.05
},
"blending": {
"alpha_mu": 0.0,
"alpha_sigma": 0.05
"alpha_mu": 0.05,
"alpha_sigma": 1.0
},
"cities": {
"tokyo": {
"samples": 5,
"mu_bias": 2.223946,
"sigma_scale": 2.0,
"confidence": 0.625
},
"ankara": {
"samples": 6,
"mu_bias": -0.396953,
"sigma_scale": 1.094474,
"confidence": 0.75
},
"istanbul": {
"samples": 3,
"mu_bias": 1.271844,
"sigma_scale": 1.477644,
"mu_bias": -0.163153,
"sigma_scale": 0.5,
"confidence": 0.375
},
"hong kong": {
"samples": 4,
"mu_bias": 1.32008,
"sigma_scale": 1.04023,
"confidence": 0.5
"samples": 7,
"mu_bias": 3.267553,
"sigma_scale": 2.0,
"confidence": 0.875
},
"taipei": {
"samples": 5,
"mu_bias": 4.80044,
"sigma_scale": 2.0,
"confidence": 0.625
},
"seoul": {
"samples": 3,
"mu_bias": -3.080962,
"sigma_scale": 1.437353,
"confidence": 0.375
},
"milan": {
"samples": 3,
"mu_bias": -3.935178,
"mu_bias": -4.20255,
"sigma_scale": 2.0,
"confidence": 0.375
},
"shanghai": {
"samples": 3,
"mu_bias": 1.810495,
"sigma_scale": 2.0,
"confidence": 0.375
},
"taipei": {
"samples": 3,
"mu_bias": 3.577828,
"sigma_scale": 2.0,
"mu_bias": 1.185335,
"sigma_scale": 1.68386,
"confidence": 0.375
},
"warsaw": {
"samples": 3,
"mu_bias": -0.625333,
"sigma_scale": 1.25968,
"mu_bias": -1.574246,
"sigma_scale": 1.343402,
"confidence": 0.375
}
},
"metrics": {
"sample_count": 54,
"mean_crps": 3.792563,
"legacy_mean_crps": 4.308029,
"legacy_mean_mae": 4.51037,
"legacy_bucket_hit_rate": 0.537037,
"legacy_bucket_brier": 0.833294,
"selected_mean_crps": 4.249828,
"selected_mean_mae": 4.51037,
"selected_bucket_hit_rate": 0.555556,
"selected_bucket_brier": 0.831872,
"selected_score": 5.991436,
"legacy_score": 6.078481,
"sample_count": 71,
"mean_crps": 3.226613,
"legacy_mean_crps": 3.608885,
"legacy_mean_mae": 3.81507,
"legacy_bucket_hit_rate": 0.492958,
"legacy_bucket_brier": 0.873776,
"selected_mean_crps": 3.44742,
"selected_mean_mae": 3.749078,
"selected_bucket_hit_rate": 0.492958,
"selected_bucket_brier": 0.878621,
"selected_score": 5.241857,
"legacy_score": 5.406287,
"filled_actual_from_history": 0,
"settlement_history_city_count": 30
"settlement_history_city_count": 30,
"legacy_archive_samples": 54
},
"source": "artifacts\\probability_calibration\\default.json"
}
@@ -1,257 +1,284 @@
{
"summary": {
"sample_count": 54,
"filled_actual_from_history": 2,
"sample_count": 71,
"filled_actual_from_history": 0,
"legacy": {
"mean_crps": 4.300621,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
"mean_crps": 3.608885,
"mean_mae": 3.81507,
"bucket_hit_rate": 0.492958
},
"emos": {
"mean_crps": 4.213889,
"mean_mae": 4.502963,
"bucket_hit_rate": 0.537037
"mean_crps": 3.881798,
"mean_mae": 4.378282,
"bucket_hit_rate": 0.225352
},
"delta": {
"crps": -0.086732,
"mae": 0.0,
"bucket_hit_rate": 0.0
"crps": 0.272913,
"mae": 0.563211,
"bucket_hit_rate": -0.267606
}
},
"by_city": {
"ankara": {
"samples": 3,
"legacy_mean_crps": 0.327701,
"emos_mean_crps": 0.439705,
"legacy_mean_mae": 0.066667,
"emos_mean_mae": 0.066667,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"samples": 6,
"legacy_mean_crps": 1.365135,
"emos_mean_crps": 1.454394,
"legacy_mean_mae": 1.466667,
"emos_mean_mae": 1.781865,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.333333
},
"atlanta": {
"samples": 2,
"legacy_mean_crps": 30.449382,
"emos_mean_crps": 30.578432,
"emos_mean_crps": 30.2136,
"legacy_mean_mae": 32.015,
"emos_mean_mae": 32.015,
"emos_mean_mae": 31.431544,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"buenos aires": {
"samples": 2,
"legacy_mean_crps": 9.113412,
"emos_mean_crps": 8.759954,
"emos_mean_crps": 9.25091,
"legacy_mean_mae": 10.27,
"emos_mean_mae": 10.27,
"emos_mean_mae": 10.234011,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"busan": {
"samples": 1,
"legacy_mean_crps": 0.451701,
"emos_mean_crps": 1.176676,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 1.887548,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.0
},
"chengdu": {
"samples": 1,
"legacy_mean_crps": 0.306469,
"emos_mean_crps": 0.555327,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.929502,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.0
},
"chicago": {
"samples": 1,
"legacy_mean_crps": 1.250268,
"emos_mean_crps": 0.701085,
"emos_mean_crps": 2.090175,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 3.396557,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"dallas": {
"samples": 1,
"legacy_mean_crps": 2.173363,
"emos_mean_crps": 0.701085,
"emos_mean_crps": 1.05572,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 1.653475,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"hong kong": {
"samples": 4,
"legacy_mean_crps": 0.29509,
"emos_mean_crps": 0.387946,
"legacy_mean_mae": 0.075,
"emos_mean_mae": 0.075,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 0.75
"samples": 7,
"legacy_mean_crps": 0.706099,
"emos_mean_crps": 1.573367,
"legacy_mean_mae": 0.657143,
"emos_mean_mae": 1.88803,
"legacy_bucket_hit_rate": 0.714286,
"emos_bucket_hit_rate": 0.142857
},
"istanbul": {
"samples": 3,
"legacy_mean_crps": 1.264103,
"emos_mean_crps": 1.266844,
"legacy_mean_mae": 1.5,
"emos_mean_mae": 1.415758,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.666667
},
"london": {
"samples": 2,
"legacy_mean_crps": 3.885033,
"emos_mean_crps": 3.866915,
"emos_mean_crps": 4.057521,
"legacy_mean_mae": 4.135,
"emos_mean_mae": 4.135,
"emos_mean_mae": 4.313103,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"lucknow": {
"samples": 2,
"legacy_mean_crps": 2.487193,
"emos_mean_crps": 2.342342,
"emos_mean_crps": 2.021264,
"legacy_mean_mae": 3.205,
"emos_mean_mae": 3.205,
"emos_mean_mae": 2.47462,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
"emos_bucket_hit_rate": 0.5
},
"madrid": {
"samples": 2,
"legacy_mean_crps": 6.27726,
"emos_mean_crps": 5.967277,
"emos_mean_crps": 6.588692,
"legacy_mean_mae": 7.33,
"emos_mean_mae": 7.33,
"emos_mean_mae": 7.486713,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"miami": {
"samples": 2,
"legacy_mean_crps": 28.637631,
"emos_mean_crps": 28.482516,
"emos_mean_crps": 28.800796,
"legacy_mean_mae": 30.175,
"emos_mean_mae": 30.175,
"emos_mean_mae": 30.107593,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"milan": {
"samples": 3,
"legacy_mean_crps": 4.401392,
"emos_mean_crps": 3.858031,
"emos_mean_crps": 4.947416,
"legacy_mean_mae": 4.06,
"emos_mean_mae": 4.06,
"emos_mean_mae": 5.6958,
"legacy_bucket_hit_rate": 0.666667,
"emos_bucket_hit_rate": 0.666667
"emos_bucket_hit_rate": 0.333333
},
"munich": {
"samples": 2,
"legacy_mean_crps": 3.145192,
"emos_mean_crps": 3.011312,
"emos_mean_crps": 3.200946,
"legacy_mean_mae": 3.64,
"emos_mean_mae": 3.64,
"emos_mean_mae": 3.634475,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"new york": {
"samples": 1,
"legacy_mean_crps": 3.692845,
"emos_mean_crps": 3.407357,
"emos_mean_crps": 3.881457,
"legacy_mean_mae": 4.94,
"emos_mean_mae": 4.94,
"emos_mean_mae": 4.954702,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"paris": {
"samples": 2,
"legacy_mean_crps": 4.013782,
"emos_mean_crps": 3.979293,
"emos_mean_crps": 4.130614,
"legacy_mean_mae": 4.265,
"emos_mean_mae": 4.265,
"emos_mean_mae": 4.314216,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 0.5
},
"sao paulo": {
"samples": 2,
"legacy_mean_crps": 5.540967,
"emos_mean_crps": 5.272063,
"emos_mean_crps": 5.483258,
"legacy_mean_mae": 6.57,
"emos_mean_mae": 6.57,
"emos_mean_mae": 6.358434,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"seattle": {
"samples": 1,
"legacy_mean_crps": 0.315488,
"emos_mean_crps": 0.425909,
"emos_mean_crps": 0.271998,
"legacy_mean_mae": 0.0,
"emos_mean_mae": 0.0,
"emos_mean_mae": 0.105037,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
},
"seoul": {
"samples": 2,
"legacy_mean_crps": 0.313754,
"emos_mean_crps": 0.412831,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"samples": 3,
"legacy_mean_crps": 0.508331,
"emos_mean_crps": 1.718042,
"legacy_mean_mae": 0.2,
"emos_mean_mae": 2.57121,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.0
},
"shanghai": {
"samples": 3,
"legacy_mean_crps": 0.299116,
"emos_mean_crps": 0.394855,
"emos_mean_crps": 0.553444,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"emos_mean_mae": 0.71746,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.666667
},
"shenzhen": {
"samples": 1,
"legacy_mean_crps": 0.798351,
"emos_mean_crps": 0.762787,
"legacy_mean_mae": 0.9,
"emos_mean_mae": 0.9,
"legacy_mean_crps": 1.198351,
"emos_mean_crps": 0.507942,
"legacy_mean_mae": 1.3,
"emos_mean_mae": 0.644561,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"singapore": {
"samples": 2,
"legacy_mean_crps": 0.281993,
"emos_mean_crps": 0.37264,
"emos_mean_crps": 0.877023,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"emos_mean_mae": 1.142765,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
},
"taipei": {
"samples": 3,
"legacy_mean_crps": 0.356996,
"emos_mean_crps": 0.472738,
"legacy_mean_mae": 0.1,
"emos_mean_mae": 0.1,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"samples": 5,
"legacy_mean_crps": 0.950739,
"emos_mean_crps": 1.257972,
"legacy_mean_mae": 0.94,
"emos_mean_mae": 1.687014,
"legacy_bucket_hit_rate": 0.6,
"emos_bucket_hit_rate": 0.2
},
"tel aviv": {
"samples": 2,
"legacy_mean_crps": 0.446758,
"emos_mean_crps": 0.578006,
"emos_mean_crps": 0.482341,
"legacy_mean_mae": 0.3,
"emos_mean_mae": 0.3,
"emos_mean_mae": 0.62659,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
},
"tokyo": {
"samples": 2,
"legacy_mean_crps": 0.450128,
"emos_mean_crps": 0.582151,
"legacy_mean_mae": 0.25,
"emos_mean_mae": 0.25,
"legacy_bucket_hit_rate": 0.5,
"emos_bucket_hit_rate": 1.0
"samples": 5,
"legacy_mean_crps": 0.879366,
"emos_mean_crps": 0.989336,
"legacy_mean_mae": 1.022,
"emos_mean_mae": 1.450474,
"legacy_bucket_hit_rate": 0.2,
"emos_bucket_hit_rate": 0.2
},
"toronto": {
"samples": 2,
"legacy_mean_crps": 5.497916,
"emos_mean_crps": 5.240552,
"emos_mean_crps": 6.296632,
"legacy_mean_mae": 6.33,
"emos_mean_mae": 6.33,
"emos_mean_mae": 7.003984,
"legacy_bucket_hit_rate": 0.0,
"emos_bucket_hit_rate": 0.0
},
"warsaw": {
"samples": 3,
"legacy_mean_crps": 1.618875,
"emos_mean_crps": 1.553232,
"emos_mean_crps": 1.649515,
"legacy_mean_mae": 2.056667,
"emos_mean_mae": 2.056667,
"emos_mean_mae": 2.073494,
"legacy_bucket_hit_rate": 0.333333,
"emos_bucket_hit_rate": 0.333333
"emos_bucket_hit_rate": 0.0
},
"wellington": {
"samples": 2,
"legacy_mean_crps": 0.364919,
"emos_mean_crps": 0.475875,
"emos_mean_crps": 0.939463,
"legacy_mean_mae": 0.15,
"emos_mean_mae": 0.15,
"emos_mean_mae": 1.007263,
"legacy_bucket_hit_rate": 1.0,
"emos_bucket_hit_rate": 1.0
"emos_bucket_hit_rate": 0.5
}
}
}
+3 -6
View File
@@ -21,12 +21,9 @@ export async function GET(req: NextRequest) {
const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false,
});
const fetchOptions = {
headers: auth.headers,
next: { revalidate: 300 },
} as const;
const res = await fetch(`${API_BASE}/api/cities`, {
...fetchOptions,
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
@@ -40,7 +37,7 @@ export async function GET(req: NextRequest) {
const response = buildCachedJsonResponse(
req,
data,
"public, max-age=0, s-maxage=300, stale-while-revalidate=1800",
"no-store, max-age=0",
);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
+169 -50
View File
@@ -2,13 +2,101 @@
@tailwind components;
@tailwind utilities;
/* ══════════════════════════════════════════════════════════════
PolyWeather — Unified Design Token System
══════════════════════════════════════════════════════════════ */
@layer base {
:root {
/* ── Background Scale ── */
--color-bg-base: #080c14;
--color-bg-raised: #0d1321;
--color-bg-overlay: #111827;
--color-bg-card: rgba(15, 23, 42, 0.72);
--color-bg-input: rgba(255, 255, 255, 0.04);
/* ── Text Scale ── */
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-disabled: #475569;
/* ── Accent Colors ── */
--color-accent-primary: #00e5ff;
--color-accent-secondary: #6366f1;
--color-accent-tertiary: #a78bfa;
/* ── Signal / Semantic Colors ── */
--color-signal-success: #22c55e;
--color-signal-warning: #f59e0b;
--color-signal-danger: #ef4444;
--color-signal-info: #3b82f6;
/* ── Risk Colors (aliased from signal) ── */
--color-risk-high: var(--color-signal-danger);
--color-risk-medium: var(--color-signal-warning);
--color-risk-low: var(--color-signal-success);
/* ── Border ── */
--color-border-default: rgba(99, 102, 241, 0.12);
--color-border-hover: rgba(99, 102, 241, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.06);
/* ── Shadow / Elevation ── */
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2);
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25);
/* ── Typography ── */
--font-data:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-display: "Geist", "Inter", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
/* ── Spacing (4px grid) ── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* ── Border Radius ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-full: 9999px;
/* ── Glass / Blur ── */
--glass-blur-1: blur(10px);
--glass-blur-2: blur(16px);
--glass-blur-3: blur(24px);
--glass-opacity-1: 0.72;
--glass-opacity-2: 0.85;
--glass-opacity-3: 0.92;
/* ── Layout ── */
--header-height: 52px;
--sidebar-width: 260px;
--panel-width: 560px;
/* ── Motion ── */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
--background: 223 53% 4%;
--foreground: 210 40% 98%;
--card: 223 46% 8%;
--card-foreground: 210 40% 98%;
--primary: 190 95% 56%;
--primary: 187 100% 50%;
--primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%;
@@ -22,28 +110,32 @@
}
body {
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
font-family: var(--font-data);
background:
radial-gradient(
circle at 10% -10%,
rgba(34, 211, 238, 0.2),
rgba(0, 229, 255, 0.12),
transparent 40%
),
radial-gradient(
circle at 90% 0%,
rgba(59, 130, 246, 0.14),
rgba(99, 102, 241, 0.1),
transparent 36%
),
radial-gradient(
circle at 80% 100%,
rgba(8, 47, 73, 0.5),
rgba(8, 47, 73, 0.4),
transparent 48%
),
hsl(var(--background));
color: hsl(var(--foreground));
var(--color-bg-base);
color: var(--color-text-primary);
}
}
/* ══════════════════════════════════════════════════════════════
Map Components (Leaflet overrides + map markers)
══════════════════════════════════════════════════════════════ */
@layer components {
.leaflet-container {
width: 100%;
@@ -52,38 +144,41 @@
}
.leaflet-control-zoom a {
background: rgba(2, 6, 23, 0.82) !important;
border-color: rgba(71, 85, 105, 0.5) !important;
color: #e2e8f0 !important;
background: rgba(8, 12, 20, 0.85) !important;
border-color: var(--color-border-default) !important;
color: var(--color-text-primary) !important;
}
.leaflet-control-zoom a:hover {
background: rgba(15, 23, 42, 0.95) !important;
background: rgba(13, 19, 33, 0.95) !important;
border-color: var(--color-border-hover) !important;
}
/* ── Map Pill (temperature labels on map) ── */
.map-pill {
min-width: 52px;
border-radius: 9999px;
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 5px 10px;
color: #f8fafc;
color: var(--color-text-primary);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
backdrop-filter: blur(8px);
backdrop-filter: var(--glass-blur-1);
box-shadow:
0 2px 14px rgba(2, 6, 23, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.map-pill.high {
background: linear-gradient(135deg, #ef4444, #b91c1c);
background: linear-gradient(135deg, var(--color-signal-danger), #b91c1c);
}
.map-pill.medium {
background: linear-gradient(135deg, #f59e0b, #b45309);
background: linear-gradient(135deg, var(--color-signal-warning), #b45309);
}
.map-pill.low {
@@ -93,21 +188,23 @@
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(34, 211, 238, 0.4),
0 8px 24px rgba(0, 229, 255, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
/* ── Glass utility ── */
.glass {
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.9) 0%,
rgba(2, 6, 23, 0.75) 100%
rgba(13, 19, 33, 0.88) 0%,
rgba(8, 12, 20, 0.75) 100%
);
backdrop-filter: blur(10px);
backdrop-filter: var(--glass-blur-1);
}
/* ── Fade-up entrance ── */
.fade-up {
animation: fadeUp 450ms ease-out;
animation: fadeUp 400ms var(--transition-slow);
}
}
@@ -124,47 +221,52 @@
}
}
/* ══════════════════════════════════════════════════════════════
Map Marker Components (nearby stations, city bubbles)
══════════════════════════════════════════════════════════════ */
@layer components {
.nearby-marker {
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.9);
color: rgba(226, 232, 240, 0.92);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px 10px;
gap: var(--space-2);
background: rgba(13, 19, 33, 0.9);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: 11px;
font-weight: 600;
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-elevation-2);
backdrop-filter: var(--glass-blur-1);
white-space: nowrap;
}
.nearby-name {
color: rgba(148, 163, 184, 0.95);
color: var(--color-text-secondary);
}
.nearby-temp {
color: #fff;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.nearby-unit {
color: rgba(148, 163, 184, 0.85);
color: var(--color-text-muted);
font-size: 9px;
}
.wind-info {
display: flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding-left: 6px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(34, 211, 238, 0.9);
gap: var(--space-1);
margin-left: var(--space-1);
padding-left: var(--space-2);
border-left: 1px solid var(--color-border-subtle);
color: var(--color-accent-primary);
font-size: 10px;
opacity: 0.9;
}
.wind-arrow {
@@ -172,6 +274,7 @@
transform-origin: center;
}
/* ── City Marker (bubble on map) ── */
.city-marker {
position: relative;
display: flex;
@@ -184,13 +287,14 @@
position: relative;
min-width: 46px;
padding: 5px 10px;
border-radius: 12px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: center;
color: white;
border: 1px solid transparent;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-elevation-2);
}
.marker-bubble::after {
@@ -207,48 +311,63 @@
}
.marker-bubble.risk-high {
background: linear-gradient(135deg, #dc2626, #ef4444);
background: linear-gradient(135deg, #dc2626, var(--color-signal-danger));
border-color: rgba(239, 68, 68, 0.5);
}
.marker-bubble.risk-high::after { border-top-color: #ef4444; }
.marker-bubble.risk-high::after {
border-top-color: var(--color-signal-danger);
}
.marker-bubble.risk-medium {
background: linear-gradient(135deg, #d97706, #f59e0b);
background: linear-gradient(135deg, #d97706, var(--color-signal-warning));
border-color: rgba(245, 158, 11, 0.5);
}
.marker-bubble.risk-medium::after { border-top-color: #f59e0b; }
.marker-bubble.risk-medium::after {
border-top-color: var(--color-signal-warning);
}
.marker-bubble.risk-low {
background: linear-gradient(135deg, #059669, #10b981);
border-color: rgba(16, 185, 129, 0.5);
}
.marker-bubble.risk-low::after { border-top-color: #10b981; }
.marker-bubble.risk-low::after {
border-top-color: #10b981;
}
.marker-name {
margin-top: 8px;
margin-top: var(--space-2);
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.88);
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.city-marker.selected .marker-bubble {
animation: markerGlow 2s ease-in-out infinite;
}
/* ── Scrollbar ── */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(51, 65, 85, 0.75);
border-radius: 9999px;
background: rgba(99, 102, 241, 0.2);
border-radius: var(--radius-full);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.35);
}
}
@keyframes markerGlow {
0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); }
50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); }
0%,
100% {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
50% {
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3);
}
}
+9 -3
View File
@@ -12,7 +12,9 @@ export const metadata: Metadata = {
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
shortcut: ["/favicon.ico"],
},
};
@@ -24,9 +26,13 @@ export default function RootLayout({
<html lang="zh-CN" className="dark">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
+34 -10
View File
@@ -2,6 +2,7 @@
import { startTransition, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { Clock } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
@@ -71,8 +72,7 @@ export function CitySidebar() {
const aSamples = Number(a.deb_recent_sample_count ?? 0);
const bSamples = Number(b.deb_recent_sample_count ?? 0);
return (
(riskOrder[aGroup] ?? 3) -
(riskOrder[bGroup] ?? 3) ||
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
bHitRate - aHitRate ||
bSamples - aSamples ||
a.display_name.localeCompare(b.display_name)
@@ -126,8 +126,7 @@ export function CitySidebar() {
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
if (!monitor?.available) return "";
const label =
locale === "en-US" ? monitor.label_en : monitor.label_zh;
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
const trendLabel =
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
if (!label) return "";
@@ -170,10 +169,20 @@ export function CitySidebar() {
}))
}
>
<span className="city-group-title">{group.label}</span>
<span className="city-group-title">
<span
className={clsx("city-group-indicator", group.key)}
aria-hidden="true"
/>
{group.label}
</span>
<span className="city-group-meta">
<span className="city-group-count">{citiesInGroup.length}</span>
<span className={clsx("city-group-arrow", expanded && "expanded")}>
<span className="city-group-count">
{citiesInGroup.length}
</span>
<span
className={clsx("city-group-arrow", expanded && "expanded")}
>
</span>
</span>
@@ -203,7 +212,9 @@ export function CitySidebar() {
time: detail.current.max_temp_time,
})
: detail?.current?.max_temp_time
? t("sidebar.peakAt", { time: detail.current.max_temp_time })
? t("sidebar.peakAt", {
time: detail.current.max_temp_time,
})
: "";
const deviationDirection =
snapshot?.deviation_monitor?.direction || "normal";
@@ -225,7 +236,9 @@ export function CitySidebar() {
>
<div className="city-item-main">
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">{city.display_name}</span>
<span className="city-name-text">
{city.display_name}
</span>
<span
className={clsx(
"city-temp",
@@ -238,7 +251,18 @@ export function CitySidebar() {
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? `🕒 ${snapshot.local_time}` : ""}
{snapshot?.local_time ? (
<>
<Clock
size={10}
strokeWidth={2}
className="city-clock-icon"
/>
{snapshot.local_time}
</>
) : (
""
)}
</span>
<span
className={clsx(
+431 -148
View File
@@ -3,44 +3,44 @@
────────────────────────────────────────────────────────── */
.root {
/* Core palette */
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.85);
--bg-glass: rgba(15, 23, 42, 0.75);
--border-glass: rgba(99, 102, 241, 0.15);
--border-subtle: rgba(255, 255, 255, 0.06);
/* ── Palette (bridged from globals.css unified tokens) ── */
--bg-primary: var(--color-bg-base);
--bg-secondary: var(--color-bg-raised);
--bg-card: var(--color-bg-card);
--bg-glass: rgba(13, 19, 33, 0.75);
--border-glass: var(--color-border-default);
--border-subtle: var(--color-border-subtle);
/* Text */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-muted: var(--color-text-muted);
/* Accents */
--accent-cyan: #22d3ee;
--accent-blue: #6366f1;
/* Accents — upgraded primary cyan to match new token */
--accent-cyan: var(--color-accent-primary);
--accent-blue: var(--color-accent-secondary);
--accent-green: #34d399;
--accent-orange: #fb923c;
--accent-red: #f87171;
--accent-yellow: #fbbf24;
--accent-purple: #a78bfa;
--accent-purple: var(--color-accent-tertiary);
/* Risk colors */
--risk-high: #ef4444;
--risk-medium: #f59e0b;
--risk-low: #22c55e;
--risk-high: var(--color-risk-high);
--risk-medium: var(--color-risk-medium);
--risk-low: var(--color-risk-low);
/* Spacing */
/* Spacing — aligned with global layout tokens */
--panel-width: 560px;
--header-height: 56px;
--header-height: 52px;
--sidebar-width: 260px;
/* Effects */
/* Effects — using global elevation tokens */
--glass-blur: 20px;
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.5);
--shadow-glow-cyan: 0 0 20px rgba(34, 211, 238, 0.3);
--shadow-glow-blue: 0 0 20px rgba(99, 102, 241, 0.3);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: var(--shadow-elevation-3);
--shadow-glow-cyan: var(--shadow-glow-accent);
--shadow-glow-blue: var(--shadow-glow-secondary);
--transition: all var(--transition-base);
}
/* ── Reset & Base ── */
@@ -174,44 +174,46 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
padding: 0 20px;
background: linear-gradient(
180deg,
rgba(10, 14, 26, 0.95) 0%,
rgba(10, 14, 26, 0.7) 100%
rgba(8, 12, 20, 0.96) 0%,
rgba(8, 12, 20, 0.82) 100%
);
backdrop-filter: blur(16px);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-glass);
}
.root :global(.brand) {
display: flex;
align-items: baseline;
gap: 12px;
gap: 10px;
}
.root :global(.brand h1) {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
font-size: 18px;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-blue) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.root :global(.subtitle) {
font-size: 12px;
font-weight: 400;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.5px;
letter-spacing: 0.6px;
text-transform: uppercase;
opacity: 0.8;
}
.root :global(.header-right) {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
}
.root :global(.lang-switch) {
@@ -244,27 +246,28 @@
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.16);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.26);
background: rgba(0, 229, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.2);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
font-size: 11px;
font-weight: 600;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--accent-green);
letter-spacing: 1px;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.root :global(.pulse-dot) {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
animation: pulse 2s ease-in-out infinite;
@@ -283,23 +286,22 @@
}
.root :global(.refresh-btn) {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
background: rgba(13, 19, 33, 0.6);
color: var(--text-secondary);
font-size: 18px;
cursor: pointer;
transition: var(--transition);
transition: all 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.refresh-btn:hover) {
background: rgba(99, 102, 241, 0.15);
border-color: var(--accent-blue);
color: var(--text-primary);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
color: var(--accent-cyan);
}
.root :global(.refresh-btn.spinning) {
animation: spin 1s linear infinite;
@@ -335,20 +337,23 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 18px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-subtle);
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.root :global(.city-count) {
background: var(--accent-blue);
color: white;
font-size: 12px;
background: rgba(99, 102, 241, 0.2);
color: var(--accent-blue);
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 12px;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid rgba(99, 102, 241, 0.15);
}
.root :global(.city-list-items) {
@@ -382,20 +387,52 @@
.root :global(.city-group-header) {
width: 100%;
border: none;
background: rgba(99, 102, 241, 0.08);
background: rgba(99, 102, 241, 0.06);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
padding: 7px 10px;
cursor: pointer;
font-family: inherit;
transition: background 150ms ease;
}
.root :global(.city-group-header:hover) {
background: rgba(99, 102, 241, 0.1);
}
.root :global(.city-group-title) {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.root :global(.city-group-indicator) {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.root :global(.city-group-indicator.high) {
background: var(--risk-high);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
}
.root :global(.city-group-indicator.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
}
.root :global(.city-group-indicator.low) {
background: var(--risk-low);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
.root :global(.city-group-indicator.other) {
background: var(--text-muted);
opacity: 0.5;
}
.root :global(.city-group-meta) {
@@ -457,8 +494,9 @@
border-color: var(--border-glass);
}
.root :global(.city-item.active) {
background: rgba(99, 102, 241, 0.15);
border-color: var(--accent-blue);
background: rgba(0, 229, 255, 0.08);
border-color: rgba(0, 229, 255, 0.3);
box-shadow: 0 0 16px rgba(0, 229, 255, 0.06), inset 0 0 0 1px rgba(0, 229, 255, 0.08);
}
.root :global(.city-item-main) {
@@ -531,13 +569,21 @@
box-shadow: 0 0 6px var(--risk-low);
}
.root :global(.city-clock-icon) {
display: inline-block;
vertical-align: -1px;
margin-right: 3px;
opacity: 0.6;
}
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-cyan);
opacity: 0;
transition: var(--transition);
transition: opacity 200ms ease;
}
.root :global(.city-item .city-temp.loaded) {
opacity: 1;
@@ -1319,13 +1365,12 @@
position: fixed;
inset: 0;
z-index: 2000;
background:
radial-gradient(
circle at 50% 50%,
rgba(8, 14, 32, 0.76) 0%,
rgba(4, 8, 22, 0.9) 44%,
rgba(2, 6, 20, 0.96) 100%
);
background: radial-gradient(
circle at 50% 50%,
rgba(8, 14, 32, 0.76) 0%,
rgba(4, 8, 22, 0.9) 44%,
rgba(2, 6, 20, 0.96) 100%
);
display: flex;
align-items: center;
justify-content: center;
@@ -1340,12 +1385,11 @@
min-width: 240px;
padding: 28px 32px;
border-radius: 28px;
background:
linear-gradient(
180deg,
rgba(10, 18, 36, 0.78) 0%,
rgba(8, 14, 30, 0.62) 100%
);
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.78) 0%,
rgba(8, 14, 30, 0.62) 100%
);
border: 1px solid rgba(92, 142, 255, 0.16);
box-shadow:
0 24px 90px rgba(0, 0, 0, 0.42),
@@ -1365,7 +1409,11 @@
height: 18px;
border-radius: 999px;
background:
radial-gradient(circle at 30% 50%, rgba(186, 230, 253, 0.22), transparent 54%),
radial-gradient(
circle at 30% 50%,
rgba(186, 230, 253, 0.22),
transparent 54%
),
linear-gradient(
180deg,
rgba(148, 163, 184, 0.28) 0%,
@@ -1463,14 +1511,13 @@
width: 108px;
height: 108px;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(22, 78, 99, 0.16) 0%,
rgba(7, 18, 36, 0.08) 48%,
rgba(5, 10, 22, 0.02) 76%,
transparent 100%
);
background: radial-gradient(
circle,
rgba(22, 78, 99, 0.16) 0%,
rgba(7, 18, 36, 0.08) 48%,
rgba(5, 10, 22, 0.02) 76%,
transparent 100%
);
box-shadow:
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
0 0 40px rgba(34, 211, 238, 0.08);
@@ -2281,7 +2328,8 @@
scrollbar-width: none;
}
.root :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
.root
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
width: 0;
height: 0;
}
@@ -2390,12 +2438,11 @@
padding: 24px 26px;
border-radius: 24px;
border: 1px solid rgba(96, 165, 250, 0.16);
background:
linear-gradient(
180deg,
rgba(10, 18, 36, 0.72) 0%,
rgba(8, 14, 30, 0.5) 100%
);
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.72) 0%,
rgba(8, 14, 30, 0.5) 100%
);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
@@ -2406,7 +2453,12 @@
width: 84px;
height: 84px;
border-radius: 50%;
background: radial-gradient(circle, rgba(14, 165, 233, 0.08) 0%, rgba(14, 165, 233, 0.02) 62%, transparent 100%);
background: radial-gradient(
circle,
rgba(14, 165, 233, 0.08) 0%,
rgba(14, 165, 233, 0.02) 62%,
transparent 100%
);
}
.root :global(.history-modal .history-fetch-ring) {
@@ -2604,16 +2656,23 @@
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
.root
:global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
width: 8px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
.root
:global(
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track
) {
background: rgba(15, 23, 42, 0.32);
border-radius: 999px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
.root
:global(
.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb
) {
background: linear-gradient(
180deg,
rgba(56, 189, 248, 0.72) 0%,
@@ -2862,6 +2921,97 @@
gap: 10px;
}
.root :global(.future-v2-sync-strip-compact) {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
padding: 8px 10px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.62);
}
.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
display: none;
}
.root :global(.future-v2-meteorology-brief) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
gap: 14px;
padding: 16px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.78));
}
.root :global(.future-v2-meteorology-copy) {
display: grid;
gap: 10px;
min-width: 0;
}
.root :global(.future-v2-meteorology-copy h3) {
margin: 0;
color: #f8fafc;
font-size: 18px;
font-weight: 800;
line-height: 1.35;
letter-spacing: 0;
}
.root :global(.future-v2-meteorology-meta) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.future-v2-meteorology-meta span) {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
background: rgba(15, 23, 42, 0.62);
color: #cbd5e1;
font-size: 12px;
line-height: 1.35;
}
.root :global(.future-v2-meteorology-paths) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.root :global(.future-v2-meteorology-paths div) {
min-width: 0;
padding: 12px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 8px;
background: rgba(2, 6, 23, 0.5);
}
.root :global(.future-v2-meteorology-paths span) {
display: block;
color: var(--text-muted);
font-size: 11px;
line-height: 1.3;
}
.root :global(.future-v2-meteorology-paths strong) {
display: block;
margin-top: 8px;
color: #67e8f9;
font-size: 22px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
}
.root :global(.future-v2-sync-chip) {
display: flex;
align-items: flex-start;
@@ -2962,21 +3112,19 @@
}
.root :global(.future-v2-focus-card) {
background:
linear-gradient(
180deg,
rgba(34, 211, 238, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
background: linear-gradient(
180deg,
rgba(34, 211, 238, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-support-card) {
background:
linear-gradient(
180deg,
rgba(148, 163, 184, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
background: linear-gradient(
180deg,
rgba(148, 163, 184, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.future-v2-hero-card) {
@@ -3075,15 +3223,14 @@
position: relative;
height: 12px;
border-radius: 999px;
background:
linear-gradient(
90deg,
rgba(15, 23, 42, 0.85) 0%,
rgba(56, 189, 248, 0.18) 15%,
rgba(250, 204, 21, 0.24) 50%,
rgba(56, 189, 248, 0.16) 85%,
rgba(15, 23, 42, 0.85) 100%
);
background: linear-gradient(
90deg,
rgba(15, 23, 42, 0.85) 0%,
rgba(56, 189, 248, 0.18) 15%,
rgba(250, 204, 21, 0.24) 50%,
rgba(56, 189, 248, 0.16) 85%,
rgba(15, 23, 42, 0.85) 100%
);
overflow: hidden;
}
@@ -3309,14 +3456,13 @@
margin-top: 12px;
border-radius: 999px;
overflow: hidden;
background:
linear-gradient(
90deg,
rgba(34, 211, 238, 0.12) 0%,
rgba(255, 255, 255, 0.06) 46%,
rgba(255, 255, 255, 0.06) 54%,
rgba(251, 191, 36, 0.12) 100%
);
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0.12) 0%,
rgba(255, 255, 255, 0.06) 46%,
rgba(255, 255, 255, 0.06) 54%,
rgba(251, 191, 36, 0.12) 100%
);
}
.root :global(.future-v2-pace-meter-midline) {
@@ -3356,13 +3502,12 @@
margin-bottom: 10px;
border-radius: 14px;
border: 1px solid rgba(99, 102, 241, 0.14);
background:
radial-gradient(
circle at 50% 18%,
rgba(34, 211, 238, 0.14) 0%,
rgba(255, 255, 255, 0.02) 48%,
rgba(255, 255, 255, 0.015) 100%
);
background: radial-gradient(
circle at 50% 18%,
rgba(34, 211, 238, 0.14) 0%,
rgba(255, 255, 255, 0.02) 48%,
rgba(255, 255, 255, 0.015) 100%
);
overflow: hidden;
}
@@ -3634,6 +3779,124 @@
min-height: 340px;
}
.root :global(.future-v2-chart-thresholds) {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.root :global(.future-v2-chart-thresholds span) {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border: 1px solid rgba(34, 211, 238, 0.16);
border-radius: 8px;
background: rgba(8, 47, 73, 0.24);
color: #cbd5e1;
font-size: 12px;
line-height: 1.35;
}
.root :global(.future-v2-meteorology-grid) {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
gap: 12px;
}
.root :global(.future-v2-evidence-list) {
display: grid;
gap: 8px;
}
.root :global(.future-v2-evidence-row) {
padding: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-left-width: 3px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.5);
}
.root :global(.future-v2-evidence-row.cyan) {
border-left-color: #22d3ee;
}
.root :global(.future-v2-evidence-row.amber) {
border-left-color: #f59e0b;
}
.root :global(.future-v2-evidence-row.blue) {
border-left-color: #60a5fa;
}
.root :global(.future-v2-evidence-head) {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.root :global(.future-v2-evidence-head strong) {
color: #f8fafc;
font-size: 13px;
line-height: 1.35;
}
.root :global(.future-v2-evidence-head span) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.35;
text-align: right;
}
.root :global(.future-v2-evidence-row p) {
margin: 7px 0 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.root :global(.future-v2-rule-list) {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.root :global(.future-v2-rule-list li) {
position: relative;
padding-left: 16px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.root :global(.future-v2-rule-list li::before) {
content: "";
position: absolute;
left: 0;
top: 0.65em;
width: 6px;
height: 6px;
border-radius: 999px;
background: #22d3ee;
}
.root :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
background: #f59e0b;
}
.root :global(.future-v2-model-note) {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid rgba(148, 163, 184, 0.14);
color: var(--text-muted);
font-size: 12px;
line-height: 1.55;
}
.root :global(.future-modal-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
@@ -3717,14 +3980,13 @@
}
.root :global(.future-trend-meter.center) {
background:
linear-gradient(
90deg,
rgba(56, 189, 248, 0.1) 0%,
rgba(255, 255, 255, 0.05) 48%,
rgba(255, 255, 255, 0.05) 52%,
rgba(245, 158, 11, 0.1) 100%
);
background: linear-gradient(
90deg,
rgba(56, 189, 248, 0.1) 0%,
rgba(255, 255, 255, 0.05) 48%,
rgba(255, 255, 255, 0.05) 52%,
rgba(245, 158, 11, 0.1) 100%
);
}
.root :global(.future-trend-meter-midline) {
@@ -3856,6 +4118,15 @@
}
@media (max-width: 960px) {
.root :global(.future-v2-meteorology-brief),
.root :global(.future-v2-meteorology-grid) {
grid-template-columns: 1fr;
}
.root :global(.future-v2-meteorology-paths) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.root :global(.future-v2-layout) {
grid-template-columns: 1fr;
}
@@ -3881,6 +4152,18 @@
padding-right: 0;
}
.root :global(.future-v2-meteorology-brief) {
padding: 12px;
}
.root :global(.future-v2-meteorology-paths) {
grid-template-columns: 1fr;
}
.root :global(.future-v2-meteorology-copy h3) {
font-size: 16px;
}
.root :global(.future-v2-card),
.root :global(.future-modal-section) {
border-radius: 12px;
+110 -208
View File
@@ -9,7 +9,6 @@ import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
@@ -151,19 +150,11 @@ export function DetailPanel() {
);
const isPro = store.proAccess.subscriptionActive;
const isAuthenticated = store.proAccess.authenticated;
const isProStateLoading = store.proAccess.loading;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(store.futureModalDate) ||
store.historyState.isOpen;
const isVisible =
store.isPanelOpen &&
Boolean(store.selectedCity) &&
!isOverlayOpen;
const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem,
);
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
const panelDisplayName =
detail?.display_name ||
selectedSummary?.display_name ||
@@ -187,10 +178,6 @@ export function DetailPanel() {
() => getTodayPolymarketUrl(detail, locale),
[detail, locale],
);
const scenery = getCityScenery(detail?.name);
const basicCurrentTemp = selectedSummary?.current?.temp;
const basicObsTime = selectedSummary?.current?.obs_time;
const basicDeb = selectedSummary?.deb?.prediction;
const basicSettlementLabel =
selectedSummary?.current?.settlement_source_label ||
selectedCityItem?.settlement_source_label ||
@@ -200,11 +187,10 @@ export function DetailPanel() {
selectedCityItem?.airport ||
selectedSummary?.icao ||
(locale === "en-US" ? "Airport pending" : "机场待确认");
const isBasicSummaryLoading =
!detail && !selectedSummary && store.loadingState.cityDetail;
const shouldShowSyncCard =
!detail &&
(store.loadingState.cityDetail || isProStateLoading || isAuthenticated);
const heroSettlementLabel =
detail?.current?.settlement_source_label || basicSettlementLabel;
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
@@ -212,6 +198,7 @@ export function DetailPanel() {
active.blur();
}
};
const handleFeatureAccess = (feature: "today" | "history") => {
blurActiveElement();
@@ -251,10 +238,7 @@ export function DetailPanel() {
if (!isVisible) {
panel.setAttribute("inert", "");
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
@@ -303,10 +287,7 @@ export function DetailPanel() {
}, [detail, isVisible]);
return (
<aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
>
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
<div className="panel-header">
<button
type="button"
@@ -319,8 +300,15 @@ export function DetailPanel() {
>
×
</button>
<div className="panel-title-area">
<h2>{panelDisplayName.toUpperCase()}</h2>
<div className="panel-title-area">
<div className="panel-title-stack">
<div className="panel-overline">
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
<span className="panel-overline-sep"></span>
<span>{panelDisplayName.toUpperCase()}</span>
</div>
<h2>{panelDisplayName}</h2>
</div>
{store.loadingState.cityDetail && (
<div className="panel-loading-hint" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" />
@@ -335,202 +323,111 @@ export function DetailPanel() {
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
</span>
<div className="relative group">
{marketUrl ? (
<a
className="history-btn"
href={marketUrl}
target="_blank"
rel="noreferrer"
title={
locale === "en-US"
? "Open today's Polymarket market"
: "打开今日 Polymarket 题目页"
}
>
{locale === "en-US" ? "Open Market" : "打开 Polymarket"}
</a>
) : null}
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
</div>
<div className="panel-actions">
{marketUrl ? (
<a
className="panel-action-button panel-action-button-ghost"
href={marketUrl}
target="_blank"
rel="noreferrer"
title={
isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
locale === "en-US"
? "Open today's Polymarket market"
: "打开今日 Polymarket 题目页"
}
onClick={() => handleFeatureAccess("today")}
disabled={!store.selectedCity}
>
{isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx("history-btn", !isPro && "pro-locked")}
title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => handleFeatureAccess("history")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div>
{locale === "en-US" ? "Market" : "市场页"}
</a>
) : null}
<button
type="button"
className={clsx(
"panel-action-button",
"panel-action-button-primary",
!isPro && "pro-locked",
)}
title={
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => handleFeatureAccess("today")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
className={clsx(
"panel-action-button",
"panel-action-button-secondary",
!isPro && "pro-locked",
)}
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
onClick={() => handleFeatureAccess("history")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
</button>
</div>
</div>
</div>
<div className="panel-body">
{!hasBasicPanelContent ? (
<section>
<section className="detail-summary-shell detail-empty-state">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? t("detail.loading")
: t("detail.emptyHint")}
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<>
<section className="detail-section">
<h3>{locale === "en-US" ? "City Snapshot" : "城市概览"}</h3>
{isBasicSummaryLoading ? (
<div className="detail-mini-meta">
{locale === "en-US"
? "Syncing public city snapshot..."
: "正在同步城市基础信息..."}
</div>
) : null}
<div className="detail-grid">
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Current temp" : "当前温度"}
</span>
<span className="detail-value">
{basicCurrentTemp != null
? `${basicCurrentTemp}${selectedSummary?.temp_symbol || ""}`
: locale === "en-US"
? "--"
: "--"}
</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Observed" : "观测时间"}
</span>
<span className="detail-value">
{basicObsTime || (locale === "en-US" ? "Pending" : "待更新")}
</span>
</div>
<div className="detail-card">
<span className="detail-label">DEB</span>
<span className="detail-value">
{basicDeb != null
? `${basicDeb}${selectedSummary?.temp_symbol || ""}`
: locale === "en-US"
? isBasicSummaryLoading
? "Syncing..."
: "Pending"
: isBasicSummaryLoading
? "同步中..."
: "待更新"}
</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Settlement" : "结算口径"}
</span>
<span className="detail-value">{basicSettlementLabel}</span>
</div>
<div className="detail-card">
<span className="detail-label">
{locale === "en-US" ? "Airport" : "结算机场"}
</span>
<span className="detail-value">{basicAirportLabel}</span>
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-card">
<span className="detail-label">
{shouldShowSyncCard
? locale === "en-US"
? "Detail sync"
: "详情同步"
: locale === "en-US"
? "Pro features"
: "Pro 功能"}
</span>
<span className="detail-value" style={{ fontSize: "15px" }}>
{shouldShowSyncCard
? locale === "en-US"
? "Full city detail is still syncing. The deeper panel will appear automatically."
: "完整城市详情仍在同步中,深度面板会自动补齐。"
: locale === "en-US"
? "Intraday analysis, history reconciliation, and deeper structure signals require Pro."
: "今日日内分析、历史对账和更深入的结构信号需要 Pro。"}
</span>
</div>
</section>
</>
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-scenery-card">
{scenery ? (
<>
<img
className="detail-scenery-image"
src={scenery.imageUrl}
alt={t("detail.sceneryAlt", { city: detail?.display_name || "" })}
/>
<div className="detail-scenery-overlay">
<div className="detail-scenery-copy">
<span className="detail-scenery-kicker">
{detail?.display_name}
</span>
</div>
<a
className="detail-scenery-credit"
href={scenery.creditUrl}
target="_blank"
rel="noreferrer"
>
{scenery.creditLabel}
</a>
</div>
</>
) : (
<div className="detail-scenery-fallback">
<span className="detail-scenery-kicker">
{detail?.display_name}
</span>
<strong className="detail-scenery-title">
{t("detail.sceneryTitle")}
</strong>
<span className="detail-scenery-subtitle">
{t("detail.sceneryFallback")}
</span>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
<h3>{t("detail.profile")}</h3>
</div>
)}
</section>
<section className="detail-section">
<h3>{t("detail.profile")}</h3>
</div>
<div className="detail-grid">
{profileStats.map((item) => (
<div key={item.label} className="detail-card">
<article key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</div>
</article>
))}
</div>
</section>
{officialLinks.length > 0 ? (
<section className="detail-section">
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
</div>
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
</div>
</div>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
@@ -545,9 +442,7 @@ export function DetailPanel() {
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
<span className="detail-source-label">{link.label}</span>
</a>
))}
@@ -555,10 +450,17 @@ export function DetailPanel() {
</section>
) : null}
<section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3>
<section className="detail-structured-section rounded-2xl">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail!} />
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
@@ -0,0 +1,213 @@
.root :global(.panel-title-stack) {
display: grid;
gap: 8px;
}
.root :global(.panel-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.panel-overline-sep) {
opacity: 0.45;
}
.root :global(.panel-subtitle) {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.root :global(.panel-weather-chip) {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.root :global(.panel-meta-chip) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.root :global(.panel-meta-chip-strong) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.1);
border-color: rgba(34, 211, 238, 0.2);
}
.root :global(.panel-meta-chip-muted) {
color: var(--text-muted);
}
.root :global(.panel-actions) {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.root :global(.panel-action-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-decoration: none;
cursor: pointer;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
}
.root :global(.panel-action-button:hover) {
transform: translateY(-1px);
}
.root :global(.panel-action-button-primary) {
color: #05111f;
background: linear-gradient(135deg, #67e8f9, #38bdf8);
border-color: rgba(103, 232, 249, 0.3);
}
.root :global(.panel-action-button-secondary) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.root :global(.panel-action-button-ghost) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-summary-shell) {
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 16px;
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
}
.root :global(.panel-body section.detail-summary-shell) {
padding: 16px;
margin: 18px 0 0;
}
.root :global(.detail-summary-shell-live) {
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
}
.root :global(.detail-section-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.root :global(.detail-section-kicker) {
display: inline-block;
margin-bottom: 6px;
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.detail-summary-main) {
display: grid;
gap: 14px;
}
.root :global(.detail-summary-hero) {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.root :global(.detail-summary-temp) {
color: var(--text-primary);
font-size: 38px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.root :global(.detail-summary-supporting) {
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.root :global(.detail-summary-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 14px;
}
.root :global(.detail-card-hero) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-card-wide) {
grid-column: span 2;
}
.root :global(.detail-callout-card) {
background: rgba(255, 255, 255, 0.025);
}
.root :global(.detail-value-muted) {
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
.root :global(.detail-structured-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
padding: 16px 16px 18px;
}
.root :global(.detail-empty-state) {
color: var(--text-muted);
font-size: 13px;
line-height: 1.7;
}
@@ -31,6 +31,7 @@ import {
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
@@ -175,6 +176,61 @@ function parsePercentFromText(value?: string | number | null) {
return parseLeadingNumber(text);
}
function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "high") return locale === "en-US" ? "High" : "高";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
return locale === "en-US" ? "Pending" : "待确认";
}
function formatSignalDirection(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "support") return locale === "en-US" ? "Support" : "支持升温";
if (normalized === "suppress") return locale === "en-US" ? "Suppress" : "压制峰值";
return locale === "en-US" ? "Neutral" : "中性";
}
function formatSignalStrength(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
return locale === "en-US" ? "Weak" : "弱";
}
function signalTone(signal?: IntradayMeteorologySignal | null) {
const direction = String(signal?.direction || "").trim().toLowerCase();
if (direction === "support") return "cyan";
if (direction === "suppress") return "amber";
return "blue";
}
function localizedText(
locale: string,
primary?: string | null,
english?: string | null,
) {
const en = String(english || "").trim();
const value = String(primary || "").trim();
if (locale === "en-US" && en) return en;
return value || en;
}
function localizedList(
locale: string,
primary?: string[] | null,
english?: string[] | null,
) {
const en = Array.isArray(english)
? english.filter((item) => String(item || "").trim())
: [];
const value = Array.isArray(primary)
? primary.filter((item) => String(item || "").trim())
: [];
if (locale === "en-US" && en.length) return en;
return value.length ? value : en;
}
function getTrendMetricVisual(metric: {
label?: string;
value?: string;
@@ -1081,6 +1137,66 @@ export function FutureForecastModal() {
}
return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
const intradayMeteorology = detail.intraday_meteorology || {};
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
? intradayMeteorology.signal_contributions
: [];
const invalidationRules = localizedList(
locale,
intradayMeteorology.invalidation_rules,
intradayMeteorology.invalidation_rules_en,
);
const confirmationRules = localizedList(
locale,
intradayMeteorology.confirmation_rules,
intradayMeteorology.confirmation_rules_en,
);
const meteorologyHeadline =
localizedText(
locale,
intradayMeteorology.headline,
intradayMeteorology.headline_en,
) ||
todayTradeSummaryLines[0] ||
(locale === "en-US"
? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
: "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
const baseCaseBucket =
String(intradayMeteorology.base_case_bucket || "").trim() ||
formatBucketLabel(topProbabilityBucket);
const nextObservationTime =
String(intradayMeteorology.next_observation_time || "").trim() || "--";
const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
const referenceObservedTemp =
topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
? Number(topObservedTemp)
: detail.current?.temp != null
? Number(detail.current.temp)
: null;
const gapToBaseBucket =
baseBucketNumber != null && referenceObservedTemp != null
? Math.max(0, baseBucketNumber - referenceObservedTemp)
: null;
const pathStatus =
gapToBaseBucket == null
? locale === "en-US"
? "Awaiting anchor"
: "等待锚点"
: gapToBaseBucket <= 0.05
? locale === "en-US"
? "Base path touched"
: "基准路径已触达"
: gapToBaseBucket <= 1.0
? locale === "en-US"
? "Base path open"
: "基准路径开放"
: locale === "en-US"
? "Needs peak push"
: "需要峰值推动";
const peakWindowText =
String(intradayMeteorology.peak_window || "").trim() ||
paceView?.peakWindowText ||
"--";
const syncStatusItems = [
{
key: "base",
@@ -1089,8 +1205,8 @@ export function FutureForecastModal() {
locale === "en-US" ? "Base analysis ready" : "基础分析已加载",
note:
locale === "en-US"
? "Forecast curve, anchor state, and current structure are available."
: "预测曲线、锚点状态和当前结构已经可用。",
? "Forecast curve, anchor state, and the core intraday view are available."
: "预测曲线、锚点状态和核心日内视图已经可用。",
},
{
key: "market",
@@ -1104,26 +1220,6 @@ export function FutureForecastModal() {
? "Probability buckets are derived from the local model stack."
: "概率桶当前由本地模型栈推导。",
},
{
key: "structure",
state: isStructureSyncing ? "syncing" : "ready",
label:
locale === "en-US"
? isStructureSyncing
? "Backfilling deep structure"
: "Deep structure ready"
: isStructureSyncing
? "深度结构补齐中"
: "深度结构已加载",
note:
locale === "en-US"
? isStructureSyncing
? "Upper-air, nearby network, and deeper fusion signals are still coming in."
: "Upper-air, nearby network, and deeper fusion signals are ready."
: isStructureSyncing
? "高空、周边站网和更深层融合信号还在补齐。"
: "高空、周边站网和更深层融合信号已可用。",
},
] as const;
return (
@@ -1155,58 +1251,74 @@ export function FutureForecastModal() {
) : (
<div className="modal-content large future-modal">
<div className="modal-header">
<h2
id="future-modal-title"
className="future-modal-title-with-actions"
>
<span>
{isToday
? t("future.todayTitle", {
city: detail.display_name.toUpperCase(),
})
: t("future.dateTitle", {
city: detail.display_name.toUpperCase(),
date: dateStr,
})}
</span>
<button
className={clsx(
"future-refresh-btn",
isAnyLayerSyncing && "spinning",
)}
disabled={!isPro || isProLoading}
onClick={() => {
if (isToday) {
void store.openTodayModal(true);
return;
}
store.openFutureModal(dateStr, true);
}}
title={
!isPro
? locale === "en-US"
? "Pro subscription required"
: "需要 Pro 订阅"
: locale === "en-US"
? "Refresh Data"
: "刷新数据"
}
<div className="modal-title-stack">
<div className="modal-overline">
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
<span className="modal-overline-sep"></span>
<span>{detail.display_name.toUpperCase()}</span>
</div>
<h2
id="future-modal-title"
className="future-modal-title-with-actions"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
<span>
{isToday
? t("future.todayTitle", {
city: detail.display_name.toUpperCase(),
})
: t("future.dateTitle", {
city: detail.display_name.toUpperCase(),
date: dateStr,
})}
</span>
<button
className={clsx(
"future-refresh-btn",
isAnyLayerSyncing && "spinning",
)}
disabled={!isPro || isProLoading}
onClick={() => {
if (isToday) {
void store.openTodayModal(true);
return;
}
store.openFutureModal(dateStr, true);
}}
title={
!isPro
? locale === "en-US"
? "Pro subscription required"
: "需要 Pro 订阅"
: locale === "en-US"
? "Refresh Data"
: "刷新数据"
}
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</h2>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</h2>
<div className="modal-subtitle">
{isToday
? locale === "en-US"
? "Base signal first, then probability and model layers."
: "先看基础信号,再看概率层和模型层。"
: locale === "en-US"
? "Forward date view with phased model and structure sync."
: "未来日期视图,模型层与结构层分阶段补齐。"}
</div>
</div>
<button
type="button"
className="modal-close"
@@ -1219,14 +1331,53 @@ export function FutureForecastModal() {
</button>
</div>
<div className="modal-body future-modal-body">
<section className="future-v2-sync-strip" aria-live="polite">
{isToday && (
<section className="future-v2-meteorology-brief">
<div className="future-v2-meteorology-copy">
<div className="modal-section-kicker">
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
</div>
<h3>{meteorologyHeadline}</h3>
<div className="future-v2-meteorology-meta">
<span>
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
{formatConfidenceLabel(intradayMeteorology.confidence, locale)}
</span>
<span>
{locale === "en-US" ? "Peak window" : "峰值窗口"} · {peakWindowText}
</span>
<span>
{locale === "en-US" ? "Next observation" : "下一观测"} · {nextObservationTime}
</span>
</div>
</div>
<div className="future-v2-meteorology-paths">
<div>
<span>{locale === "en-US" ? "Base case" : "基准路径"}</span>
<strong>{baseCaseBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Upside" : "上修路径"}</span>
<strong>{intradayMeteorology.upside_bucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Downside" : "下修路径"}</span>
<strong>{intradayMeteorology.downside_bucket || "--"}</strong>
</div>
</div>
</section>
)}
<section
className={clsx(
"future-v2-sync-strip",
isToday && "future-v2-sync-strip-compact",
)}
aria-live="polite"
>
{syncStatusItems.map((item) => (
<div
key={item.key}
className={clsx(
"future-v2-sync-chip",
item.state === "syncing" && "syncing",
)}
className="future-v2-sync-chip"
>
<span className="future-v2-sync-dot" aria-hidden="true" />
<div className="future-v2-sync-copy">
@@ -1237,18 +1388,7 @@ export function FutureForecastModal() {
))}
</section>
{isNoaaSettlement && (
<div
style={{
marginBottom: "16px",
padding: "12px 14px",
border: "1px solid rgba(56, 189, 248, 0.24)",
borderRadius: "12px",
background: "rgba(14, 165, 233, 0.08)",
color: "var(--text-secondary)",
fontSize: "13px",
lineHeight: 1.6,
}}
>
<div className="modal-callout modal-callout-info">
{locale === "en-US"
? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.`
: `${detail.display_name} 当前按 NOAA ${noaaStationCode}${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`}
@@ -1332,6 +1472,22 @@ export function FutureForecastModal() {
</span>
<strong>{detail.current?.obs_time || "--"}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Gap to base" : "距基准档"}
</span>
<strong>
{gapToBaseBucket != null
? `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Path state" : "路径状态"}
</span>
<strong>{pathStatus}</strong>
</div>
<div className="future-v2-mini-item">
<span>
{locale === "en-US" ? "Sunrise" : "日出时间"}
@@ -1491,19 +1647,120 @@ export function FutureForecastModal() {
<main className="future-v2-right">
<section className="future-modal-section future-v2-main-chart">
<h3>
{locale === "en-US"
? "Today's temperature forecast (obs + market)"
: "今日气温预测(观测 + 市场)"}
</h3>
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Primary view" : "主视图"}
</div>
<h3>
{locale === "en-US"
? "Today's temperature forecast (obs + market)"
: "今日气温预测(观测 + 市场)"}
</h3>
</div>
<DailyTemperatureChart dateStr={dateStr} />
<div className="future-v2-chart-thresholds">
<span>{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}</span>
<span>{locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}</span>
<span>{locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}</span>
</div>
</section>
<div className="future-v2-meteorology-grid">
<section className="future-modal-section future-v2-evidence-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
</div>
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
</div>
<div className="future-v2-evidence-list">
{meteorologySignals.length > 0 ? (
meteorologySignals.map((signal, index) => (
<div
key={`${signal.label || "signal"}-${index}`}
className={clsx(
"future-v2-evidence-row",
signalTone(signal),
)}
>
<div className="future-v2-evidence-head">
<strong>
{localizedText(locale, signal.label, signal.label_en) || "--"}
</strong>
<span>
{formatSignalDirection(signal.direction, locale)} ·{" "}
{formatSignalStrength(signal.strength, locale)}
</span>
</div>
<p>
{localizedText(locale, signal.summary, signal.summary_en) || "--"}
</p>
</div>
))
) : (
<div className="future-text-block">
{locale === "en-US"
? "Meteorology signals are still loading."
: "气象信号仍在加载。"}
</div>
)}
</div>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Failure modes" : "失效条件"}
</div>
<h3>{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}</h3>
</div>
<ul className="future-v2-rule-list">
{(invalidationRules.length > 0
? invalidationRules
: [
locale === "en-US"
? "If observations stop tracking the expected curve, wait for the next refresh."
: "若实测不再贴近预期曲线,等待下一次刷新确认。",
]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Confirmation" : "确认条件"}
</div>
<h3>{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}</h3>
</div>
<ul className="future-v2-rule-list">
{(confirmationRules.length > 0
? confirmationRules
: [
locale === "en-US"
? "Keep watching the next settlement-source observation."
: "继续观察下一次结算源报文。",
]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
<div className="future-v2-model-note">{modelSummary}</div>
</section>
</div>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>
{locale === "en-US" ? "Current Hit Odds" : "当前命中胜率"}
</h3>
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Auxiliary probability" : "辅助概率"}
</div>
<h3>
{locale === "en-US" ? "Model & Market Reference" : "模型与市场参考"}
</h3>
</div>
<div className="future-text-block" style={{ marginBottom: "12px" }}>
{probabilitySummary}
</div>
@@ -1511,14 +1768,20 @@ export function FutureForecastModal() {
<ProbabilityDistribution
detail={detail}
targetDate={dateStr}
marketScan={detail.market_scan}
hideTitle
/>
</div>
</section>
<section className="future-modal-section">
<h3>
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
</h3>
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Model layer" : "模型层"}
</div>
<h3>
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
</h3>
</div>
<div className="future-text-block" style={{ marginBottom: "12px" }}>
{modelSummary}
</div>
@@ -1530,175 +1793,6 @@ export function FutureForecastModal() {
</section>
</div>
{showDeferredTodaySections ? (
<section className="future-modal-section">
<h3>{t("future.structureToday")}</h3>
<div className="future-front-score">
<div className="future-front-bar" style={barStyle}>
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
left: "50%",
width: "2px",
background: "rgba(255, 255, 255, 0.2)",
transform: "translateX(-50%)",
zIndex: 1,
}}
/>
</div>
<div className="future-front-meta">
<span className="future-front-pill">
{t("future.judgement")}: {view.front.label}
</span>
<span className="future-front-pill">
{t("future.confidence")}:{" "}
{t(`confidence.${view.front.confidence}`)}
</span>
<span className="future-front-pill">
{t("future.maxPrecip")}:{" "}
{Math.round(view.front.precipMax)}%
</span>
</div>
{todayTradeSummaryLines.length > 0 ? (
<div className="future-trend-summary">
{todayTradeSummaryLines.map((line, index) => (
<div key={`${index}-${line}`}>{line}</div>
))}
</div>
) : null}
</div>
<div className="future-subsection-title">
{locale === "en-US" ? "Surface Structure" : "近地面信号"}
</div>
<div className="future-trend-grid">
{view.front.metrics.slice(0, 6).map((metric) => (
<div key={metric.label} className="future-trend-card">
<div className="future-trend-label">{metric.label}</div>
<div
className={clsx(
"future-trend-value",
metric.tone === "warm" && "warm",
metric.tone === "cold" && "cold",
)}
>
{metric.value}
</div>
{getTrendMetricVisual(metric) ? (
<div
className={clsx(
"future-trend-meter",
getTrendMetricVisual(metric)?.mode === "center" &&
"center",
)}
>
{getTrendMetricVisual(metric)?.mode === "center" ? (
<span className="future-trend-meter-midline" />
) : null}
<div
className={clsx(
"future-trend-meter-fill",
getTrendMetricVisual(metric)?.tone === "warm" &&
"warm",
getTrendMetricVisual(metric)?.tone === "cold" &&
"cold",
)}
style={{
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
}}
/>
</div>
) : null}
<div className="future-trend-note">{metric.note}</div>
</div>
))}
</div>
<>
<div className="future-subsection-title">
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
</div>
{displayedUpperAirSummary ? (
<div className="future-trend-summary">
{displayedUpperAirSummary}
</div>
) : (
<div className="future-trend-summary future-trend-summary-muted">
{locale === "en-US"
? "Upper-air structure is temporarily unavailable for this city. For now, lean on surface structure and TAF timing."
: "该城市当前暂无可用的高空结构数据,先以近地面结构和 TAF 时段作为主判断。"}
</div>
)}
{displayedUpperAirMetrics.length > 0 ? (
<div className="future-trend-grid">
{displayedUpperAirMetrics.map((metric) => (
<div key={metric.label} className="future-trend-card">
<div className="future-trend-label">{metric.label}</div>
<div
className={clsx(
"future-trend-value",
metric.tone === "warm" && "warm",
metric.tone === "cold" && "cold",
)}
>
{metric.value}
</div>
{getTrendMetricVisual(metric) ? (
<div
className={clsx(
"future-trend-meter",
getTrendMetricVisual(metric)?.mode === "center" &&
"center",
)}
>
{getTrendMetricVisual(metric)?.mode === "center" ? (
<span className="future-trend-meter-midline" />
) : null}
<div
className={clsx(
"future-trend-meter-fill",
getTrendMetricVisual(metric)?.tone === "warm" &&
"warm",
getTrendMetricVisual(metric)?.tone === "cold" &&
"cold",
)}
style={{
width: `${getTrendMetricVisual(metric)?.percent ?? 0}%`,
}}
/>
</div>
) : null}
<div className="future-trend-note">{metric.note}</div>
</div>
))}
</div>
) : (
<div className="future-trend-card future-trend-card-empty">
<div className="future-trend-label">
{locale === "en-US" ? "Upper-air source" : "高空数据源"}
</div>
<div className="future-trend-value">
{locale === "en-US" ? "Not available" : "暂不可用"}
</div>
<div className="future-trend-note">
{locale === "en-US"
? "No upper-air diagnostic feed is attached to this city right now."
: "当前该城市未接入可用的高空诊断源,所以这里先保留说明卡片。"}
</div>
</div>
)}
</>
</section>
) : (
<section className="future-modal-section">
<h3>{t("future.structureToday")}</h3>
<div className="future-trend-summary future-trend-summary-muted">
{locale === "en-US"
? "Surface structure, upper-air diagnostics, and trade commentary are loading after the primary chart."
: "近地面结构、高空诊断和交易提示会在主图之后继续后台补齐。"}
</div>
</section>
)}
</main>
</div>
) : (
+23 -12
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { LogIn, UserRound } from "lucide-react";
import { LogIn, UserRound, RotateCw, BookOpen, Sparkles } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
@@ -29,12 +29,16 @@ export function HeaderBar() {
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
const trialPromoLabel =
locale === "en-US" ? "New users get 3-day Pro trial" : "新用户可免费体验 3 天 Pro";
locale === "en-US"
? "New users get 3-day Pro trial"
: "新用户可免费体验 3 天 Pro";
const accountHref = isAuthenticated
? "/account"
: "/auth/login?next=%2Faccount";
const accountLabel = isAuthenticated ? t("header.account") : t("header.signIn");
const accountLabel = isAuthenticated
? t("header.account")
: t("header.signIn");
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
@@ -49,12 +53,10 @@ export function HeaderBar() {
const showRenewReminder =
isAuthenticated &&
!store.proAccess.loading &&
(
(store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo))
);
((store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo)));
const renewReminderLabel = !showRenewReminder
? ""
: !store.proAccess.subscriptionActive
@@ -81,7 +83,11 @@ export function HeaderBar() {
</div>
<div className="header-right">
<div className="lang-switch" role="group" aria-label={t("header.langAria")}>
<div
className="lang-switch"
role="group"
aria-label={t("header.langAria")}
>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
@@ -104,6 +110,7 @@ export function HeaderBar() {
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
{t("header.docs")}
</Link>
@@ -113,6 +120,7 @@ export function HeaderBar() {
title={trialPromoLabel}
aria-label={trialPromoLabel}
>
<Sparkles size={12} strokeWidth={2} />
<span>{trialPromoLabel}</span>
</Link>
@@ -147,12 +155,15 @@ export function HeaderBar() {
<button
type="button"
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
className={clsx(
"refresh-btn",
store.loadingState.refresh && "spinning",
)}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={() => void store.refreshAll()}
>
<RotateCw size={16} strokeWidth={2} />
</button>
</div>
</header>
+63 -39
View File
@@ -228,30 +228,40 @@ export function HistoryModal() {
) : (
<div className="modal-content history-modal">
<div className="modal-header">
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
{meta?.mode === "preview" ? (
<div
style={{
color: "var(--text-muted)",
fontSize: "12px",
marginLeft: "12px",
}}
>
{isRecordsLoading
? locale === "en-US"
? "Loading full records in background..."
: "完整历史正在后台补齐..."
: meta.hasMore
? locale === "en-US"
? `Preview ${meta.previewCount}/${meta.fullCount}`
: `预览 ${meta.previewCount}/${meta.fullCount}`
: null}
<div className="modal-title-stack">
<div className="modal-overline">
<span>{locale === "en-US" ? "Audit workspace" : "对账工作台"}</span>
<span className="modal-overline-sep"></span>
<span>{store.selectedCity?.toUpperCase() || ""}</span>
</div>
) : null}
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
})}
</h2>
<div className="modal-subtitle">
{locale === "en-US"
? "Observed highs, DEB path, and historical baseline consistency."
: "查看实测最高温、DEB 路径与历史基线是否一致。"}
</div>
{meta?.mode === "preview" ? (
<div className="modal-header-meta">
<span className="modal-meta-pill">
{isRecordsLoading
? locale === "en-US"
? "Full records syncing"
: "完整记录补齐中"
: meta.hasMore
? locale === "en-US"
? `Preview ${meta.previewCount}/${meta.fullCount}`
: `预览 ${meta.previewCount}/${meta.fullCount}`
: locale === "en-US"
? "Full set loaded"
: "完整记录已到齐"}
</span>
</div>
) : null}
</div>
<button
type="button"
className="modal-close"
@@ -263,18 +273,7 @@ export function HistoryModal() {
</div>
<div className="modal-body">
{isNoaaSettlement && (
<div
style={{
marginBottom: "16px",
padding: "12px 14px",
border: "1px solid rgba(56, 189, 248, 0.24)",
borderRadius: "12px",
background: "rgba(14, 165, 233, 0.08)",
color: "var(--text-secondary)",
fontSize: "13px",
lineHeight: 1.6,
}}
>
<div className="modal-callout modal-callout-info">
{t("lang") === "en-US"
? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.`
: `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
@@ -316,6 +315,21 @@ export function HistoryModal() {
</div>
) : (
<>
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Performance snapshot" : "表现快照"}
</div>
<h3>
{locale === "en-US"
? "Recent settlement performance"
: "近期结算表现"}
</h3>
<div className="modal-section-note">
{locale === "en-US"
? "Hit rate, MAE, and baseline comparison across recent settled days."
: "查看近期已结算样本里的命中率、误差与基线对照。"}
</div>
</div>
<div className="history-stats">
{error ? (
<span style={{ color: "var(--accent-red)" }}>
@@ -371,10 +385,20 @@ export function HistoryModal() {
{!error && <HistoryChart />}
{!error && settledPeakRows.length > 0 && (
<div className="history-peak-reference">
<div className="history-peak-reference-title">
{locale === "en-US"
? "Peak-12h DEB Reference (Approx.)"
: "峰值前 12 小时 DEB 参考(近似)"}
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Reference table" : "参考表"}
</div>
<div className="history-peak-reference-title">
{locale === "en-US"
? "Peak-12h DEB Reference (Approx.)"
: "峰值前 12 小时 DEB 参考(近似)"}
</div>
<div className="modal-section-note">
{locale === "en-US"
? "Use peak-minus-12h DEB as a fast sanity check for settled highs."
: "用峰值前 12 小时的 DEB 作为历史结算高温的快速校验。"}
</div>
</div>
<div className="history-peak-reference-scroll">
{settledPeakRows.map((row) => (
@@ -0,0 +1,82 @@
.root :global(.modal-title-stack) {
display: grid;
gap: 8px;
min-width: 0;
}
.root :global(.modal-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-overline-sep) {
opacity: 0.5;
}
.root :global(.modal-subtitle) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
.root :global(.modal-header-meta) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.modal-meta-pill) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(34, 211, 238, 0.08);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.root :global(.modal-callout) {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.035);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.modal-callout-info) {
border-color: rgba(56, 189, 248, 0.24);
background: rgba(14, 165, 233, 0.08);
}
.root :global(.modal-section-heading) {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.root :global(.modal-section-kicker) {
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-section-note) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
+12 -12
View File
@@ -485,12 +485,12 @@ export function ProbabilityDistribution({
const buyNoText = toPriceCents(bucket.no_buy);
const marketTag = buyYesText
? locale === "en-US"
? `Buy Yes: ${buyYesText}`
: `买 Yes: ${buyYesText}`
? `Market ref: ${buyYesText}`
: `市场参考: ${buyYesText}`
: buyNoText
? locale === "en-US"
? `Buy No: ${buyNoText}`
: `买 No: ${buyNoText}`
? `Market hedge: ${buyNoText}`
: `市场反向: ${buyNoText}`
: null;
return (
@@ -530,23 +530,23 @@ export function ProbabilityDistribution({
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const marketTag = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${marketYesText || "--"}`
: `买 Yes: ${marketYesText || "--"}`
? `Market ref: ${marketYesText || "--"}`
: `市场参考: ${marketYesText || "--"}`
: marketNoText
? locale === "en-US"
? `Buy No: ${marketNoText}`
: `买 No: ${marketNoText}`
? `Market hedge: ${marketNoText}`
: `市场反向: ${marketNoText}`
: null;
const yesPriceText = toPriceCents(marketYesPrice);
const noPriceText = toPriceCents(marketNoPrice);
const marketTagFinal = isMarketBucket
? locale === "en-US"
? `Buy Yes: ${yesPriceText || "--"}`
: `买 Yes: ${yesPriceText || "--"}`
? `Market ref: ${yesPriceText || "--"}`
: `市场参考: ${yesPriceText || "--"}`
: noPriceText
? locale === "en-US"
? `Buy No: ${noPriceText}`
: `买 No: ${noPriceText}`
? `Market hedge: ${noPriceText}`
: `市场反向: ${noPriceText}`
: marketTag;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
@@ -1,7 +1,10 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { useEffect } from "react";
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import {
DashboardStoreProvider,
useDashboardStore,
@@ -92,7 +95,13 @@ function DashboardScreen() {
!activeSummary;
return (
<div className={styles.root}>
<div
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
>
<MapCanvas />
<HeaderBar />
<CitySidebar />
+69 -32
View File
@@ -14,6 +14,10 @@ import {
toCitySummary,
} from "@/lib/dashboard-client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import {
CityDetail,
CityListItem,
@@ -95,6 +99,36 @@ const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
async function buildAuthMeHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
Accept: "application/json",
};
if (!hasSupabasePublicEnv()) {
return headers;
}
try {
const {
data: { session: cachedSession },
} = await getSupabaseBrowserClient().auth.getSession();
let accessToken = String(cachedSession?.access_token || "").trim();
const expiresAtSec = Number(cachedSession?.expires_at || 0);
const nowSec = Math.floor(Date.now() / 1000);
if (!accessToken || (Number.isFinite(expiresAtSec) && expiresAtSec <= nowSec + 60)) {
const {
data: { session: refreshedSession },
} = await getSupabaseBrowserClient().auth.refreshSession();
accessToken = String(refreshedSession?.access_token || accessToken || "").trim();
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
} catch {
// The same-origin route can still fall back to cookie-backed auth.
}
return headers;
}
function countAvailableModels(
detail?: CityDetail | null,
targetDate?: string | null,
@@ -310,14 +344,10 @@ export function DashboardStoreProvider({
);
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const selectedDetail =
selectedCity && proAccess.subscriptionActive
? cityDetailsByName[selectedCity] || null
: null;
const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null;
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
dashboardClient.clearCityDetailCache();
return;
}
dashboardClient.writeCityDetailCacheBundle(
@@ -376,8 +406,6 @@ export function DashboardStoreProvider({
if (proAccess.loading) return;
if (proAccess.authenticated && proAccess.subscriptionActive) return;
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
}, [proAccess]);
const scheduleBackgroundDetailRefresh = (
@@ -524,7 +552,6 @@ export function DashboardStoreProvider({
if (proAccess.loading) return;
if (!selectedCity) return;
if (!isPanelOpen) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) return;
if (cityDetailsByName[selectedCity]) return;
let cancelled = false;
@@ -570,11 +597,10 @@ export function DashboardStoreProvider({
error: null,
}));
try {
const headers = await buildAuthMeHeaders();
const response = await fetch("/api/auth/me", {
cache: "no-store",
headers: {
Accept: "application/json",
},
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
@@ -625,6 +651,25 @@ export function DashboardStoreProvider({
void loadCities();
}, []);
useEffect(() => {
if (!cities.length) return;
if (typeof window === "undefined") return;
const schedule = () => dashboardClient.sendPriorityWarmHint();
const idleCallback = (window as Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
}).requestIdleCallback;
if (typeof idleCallback === "function") {
const id = idleCallback(schedule, { timeout: 3000 });
return () => {
if (typeof window.cancelIdleCallback === "function") {
window.cancelIdleCallback(id);
}
};
}
const timer = window.setTimeout(schedule, 2000);
return () => window.clearTimeout(timer);
}, [cities.length]);
useEffect(() => {
void refreshProAccess();
}, []);
@@ -722,20 +767,14 @@ export function DashboardStoreProvider({
if (proAccessRef.current.loading) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
const detailPromise = ensureCityDetail(cityName, false, "panel");
try {
await summaryPromise;
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
return;
}
const access = proAccessRef.current;
if (!access.authenticated || !access.subscriptionActive) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
await summaryPromise;
const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]);
if (selectedCityRef.current === cityName) {
if (detail.status === "fulfilled") {
setSelectedForecastDate(detail.value.local_date);
}
}
} catch {
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
@@ -749,15 +788,13 @@ export function DashboardStoreProvider({
cachedDetail?.local_date,
);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
await summaryPromise;
} catch {
}
void ensureCityDetail(cityName, needsDetailRefresh, "panel")
.then((detail) => {
const detailPromise = ensureCityDetail(cityName, needsDetailRefresh, "panel");
void Promise.allSettled([summaryPromise, detailPromise])
.then(([, detail]) => {
if (selectedCityRef.current !== cityName) return;
setSelectedForecastDate(detail.local_date);
if (detail.status === "fulfilled") {
setSelectedForecastDate(detail.value.local_date);
}
})
.finally(() => {
if (selectedCityRef.current !== cityName) return;
+37 -3
View File
@@ -52,6 +52,40 @@ function getMarkerDisplayOffset(cityName: string) {
};
}
function pickMarkerTemperature(
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
if (!snapshot) return null;
const detail = snapshot as Partial<CityDetail>;
const currentSource = String(
snapshot.current?.settlement_source ||
snapshot.current?.settlement_source_label ||
"",
)
.trim()
.toLowerCase();
const currentTemp =
currentSource === "nmc" || currentSource.includes("nmc")
? null
: snapshot.current?.temp;
const isNmcStation = (station?: { source_label?: string | null }) =>
String(station?.source_label || "")
.trim()
.toLowerCase()
.includes("nmc");
const candidates = [
currentTemp,
detail.airport_current?.temp,
isNmcStation(detail.airport_primary) ? null : detail.airport_primary?.temp,
detail.mgm_nearby?.[0]?.temp,
];
for (const value of candidates) {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
}
return null;
}
function createMarkerIcon(
city: CityListItem,
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
@@ -60,8 +94,8 @@ function createMarkerIcon(
const label = city.display_name;
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
const tempText =
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
const markerTemp = pickMarkerTemperature(snapshot);
const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--";
const offset = getMarkerDisplayOffset(city.name);
const styleAttr =
offset.x || offset.y
@@ -91,7 +125,7 @@ function getMarkerSignature(
city.temp_unit,
city.lat,
city.lon,
snapshot?.current?.temp ?? "",
pickMarkerTemperature(snapshot) ?? "",
].join("|");
}
+19
View File
@@ -12,6 +12,7 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingHistoryRequests = new Map<string, Promise<HistoryPayload>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
type CityCacheMeta = {
cachedAt: number;
@@ -142,6 +143,24 @@ export const dashboardClient = {
return data.cities || [];
},
sendPriorityWarmHint(timezone?: string | null) {
if (!isClient()) return;
const tz = String(
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
).trim();
if (!tz) return;
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
if (window.sessionStorage.getItem(cacheKey)) return;
window.sessionStorage.setItem(cacheKey, "1");
const params = new URLSearchParams({ timezone: tz });
void fetch(`/api/system/priority-warm?${params.toString()}`, {
method: "POST",
headers: { Accept: "application/json" },
cache: "no-store",
keepalive: true,
}).catch(() => {});
},
async getCitySummary(cityName: string, options?: { force?: boolean }) {
const force = options?.force ?? false;
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
@@ -126,6 +126,47 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
kind: "metar",
},
],
manila: [
{
label: "Wunderground RPLL",
href: "https://www.wunderground.com/history/daily/ph/manila/RPLL",
kind: "agency",
},
{
label: "Ninoy Aquino International Airport",
href: "https://www.newnaia.com.ph/",
kind: "airport",
},
{
label: "RPLL METAR",
href: "https://aviationweather.gov/data/metar/?id=RPLL&decoded=1&taf=1",
kind: "metar",
},
],
karachi: [
{
label: "Wunderground OPKC",
href: "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
kind: "agency",
},
{
label: "Jinnah International Airport",
href: "https://www.caapakistan.com.pk/",
kind: "airport",
},
{
label: "OPKC METAR",
href: "https://aviationweather.gov/data/metar/?id=OPKC&decoded=1&taf=1",
kind: "metar",
},
],
"masroor air base": [
{
label: "OPMR METAR",
href: "https://aviationweather.gov/data/metar/?id=OPMR&decoded=1&taf=1",
kind: "metar",
},
],
istanbul: [
{
label: "MGM",
+26
View File
@@ -316,6 +316,31 @@ export interface MarketScan {
websocket?: Record<string, unknown>;
}
export interface IntradayMeteorologySignal {
label?: string | null;
label_en?: string | null;
direction?: "support" | "suppress" | "neutral" | string | null;
strength?: "weak" | "medium" | "strong" | string | null;
summary?: string | null;
summary_en?: string | null;
}
export interface IntradayMeteorology {
headline?: string | null;
headline_en?: string | null;
confidence?: "low" | "medium" | "high" | string | null;
base_case_bucket?: string | null;
upside_bucket?: string | null;
downside_bucket?: string | null;
next_observation_time?: string | null;
peak_window?: string | null;
invalidation_rules?: string[] | null;
invalidation_rules_en?: string[] | null;
confirmation_rules?: string[] | null;
confirmation_rules_en?: string[] | null;
signal_contributions?: IntradayMeteorologySignal[] | null;
}
export interface AiAnalysisStructured {
summary?: string | null;
text?: string | null;
@@ -473,6 +498,7 @@ export interface CityDetail {
multi_model_daily?: Record<string, DailyModelForecast>;
source_forecasts?: SourceForecasts;
market_scan?: MarketScan;
intraday_meteorology?: IntradayMeteorology;
}
export interface HistoryPoint {
+284 -15
View File
@@ -312,6 +312,89 @@ function buildObservationPoints(items: Array<{ time?: string; temp?: number | nu
.filter((point): point is { labelTime: string; x: number; y: number } => point != null);
}
function sortObservationItemsByTime<T extends { time?: string | null }>(items: T[]) {
return [...items].sort((left, right) => {
const leftMinutes = hmToMinutes(left.time);
const rightMinutes = hmToMinutes(right.time);
if (leftMinutes == null && rightMinutes == null) return 0;
if (leftMinutes == null) return 1;
if (rightMinutes == null) return -1;
return leftMinutes - rightMinutes;
});
}
function normalizeObservationTimeForChart(
value: unknown,
detail: CityDetail,
) {
const raw = String(value || "").trim();
if (raw && !raw.includes("T")) {
return normalizeHm(raw) || raw;
}
return normalizeHm(detail.local_time) || normalizeHm(raw) || raw;
}
function buildCurrentObservationFallback(
detail: CityDetail,
): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> {
const candidates: Array<{
sourceLabel?: string | null;
temp?: number | null;
time?: string | null;
}> = [
{
sourceLabel: detail.current?.settlement_source_label,
temp: detail.current?.temp,
time: detail.current?.obs_time || detail.current?.report_time,
},
{
sourceLabel: detail.airport_primary?.source_label || "METAR",
temp: detail.airport_primary?.temp,
time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time,
},
{
sourceLabel: detail.airport_current?.source_label || "METAR",
temp: detail.airport_current?.temp,
time: detail.airport_current?.obs_time || detail.airport_current?.report_time,
},
{
sourceLabel:
detail.center_station_candidate?.source_label ||
detail.center_station_candidate?.source_code,
temp: detail.center_station_candidate?.temp,
time: String((detail.center_station_candidate as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
{
sourceLabel:
detail.official_nearby?.[0]?.source_label ||
detail.official_nearby?.[0]?.source_code,
temp: detail.official_nearby?.[0]?.temp,
time: String((detail.official_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
{
sourceLabel:
detail.mgm_nearby?.[0]?.source_label ||
detail.mgm_nearby?.[0]?.source_code,
temp: detail.mgm_nearby?.[0]?.temp,
time: String((detail.mgm_nearby?.[0] as Record<string, unknown> | null | undefined)?.obs_time || ""),
},
];
const first = candidates.find((item) => {
const numeric = Number(item.temp);
return Number.isFinite(numeric);
});
if (!first) return [];
return [
{
sourceLabel: first.sourceLabel,
temp: Number(first.temp),
time: normalizeObservationTimeForChart(first.time, detail),
},
];
}
function interpolateSeriesAtMinutes(
times: string[],
values: Array<number | null | undefined>,
@@ -612,9 +695,38 @@ export function getTemperatureChartData(
? [{ time: detail.current.obs_time, temp: detail.current.temp }]
: []
: [];
const metarObservationSource = detail.metar_today_obs?.length
? detail.metar_today_obs
: detail.trend?.recent || [];
const currentObservationFallback = buildCurrentObservationFallback(detail);
const minPlausibleObservationTemp = (() => {
const name = String(detail.name || "").trim().toLowerCase();
const icao = String(detail.risk?.icao || "").trim().toUpperCase();
if (name === "karachi" || name === "masroor air base" || icao === "OPKC" || icao === "OPMR") {
return detail.temp_symbol === "°F" ? 41 : 5;
}
return null;
})();
const filterPlausibleObservations = <T extends { temp?: number | null }>(
rows?: T[] | null,
) =>
(Array.isArray(rows) ? rows : []).filter((row) => {
const value = Number(row?.temp);
if (!Number.isFinite(value)) return false;
return minPlausibleObservationTemp == null || value >= minPlausibleObservationTemp;
});
const plausibleMetarTodayObs = filterPlausibleObservations(detail.metar_today_obs);
const plausibleTrendRecent = filterPlausibleObservations(detail.trend?.recent);
const plausibleCurrentFallback = filterPlausibleObservations(currentObservationFallback);
const metarObservationSource = plausibleMetarTodayObs.length
? plausibleMetarTodayObs
: plausibleTrendRecent.length
? plausibleTrendRecent
: plausibleCurrentFallback;
const usingCurrentObservationFallback =
!plausibleMetarTodayObs.length &&
!plausibleTrendRecent.length &&
plausibleCurrentFallback.length > 0;
const currentFallbackTag =
currentObservationFallback[0]?.sourceLabel ||
getObservationSourceTag(detail);
const allowMetarFallback = settlementSource && observationCode !== "hko";
const shouldUseMetarFallback =
allowMetarFallback &&
@@ -633,7 +745,9 @@ export function getTemperatureChartData(
return `${icao} METAR`;
})();
const observationDisplayTag =
observationCode === "wunderground"
usingCurrentObservationFallback
? String(currentFallbackTag).toUpperCase()
: observationCode === "wunderground"
? metarFallbackTag
: useSettlementObservationSource && shouldUseMetarFallback
? metarFallbackTag
@@ -869,21 +983,20 @@ export function getTemperatureChartData(
);
}
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
const recentData =
const recentData = sortObservationItemsByTime(
observationSource.length > 0
? [...observationSource]
: [...(detail.trend?.recent || [])];
: [...(detail.trend?.recent || [])],
);
const recentText = recentData
.slice(0, 4)
.reverse()
.slice(-4)
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(`${observationDisplayTag}: ${recentText}`);
}
if (airportMetarSource.length > 0) {
const airportRecentText = [...airportMetarSource]
const airportRecentText = sortObservationItemsByTime([...airportMetarSource])
.slice(-4)
.reverse()
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
.join(" -> ");
legendParts.push(
@@ -2827,9 +2940,164 @@ export function getHistorySummary(
};
}
function toFiniteNumber(value: unknown): number | null {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: null;
}
function firstFiniteNumber(values: unknown[]) {
for (const value of values) {
const numeric = toFiniteNumber(value);
if (numeric != null) return numeric;
}
return null;
}
function firstNonEmptyString(values: unknown[]) {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) return text;
}
return null;
}
function formatObservationUpdate(value: unknown, locale: Locale) {
const raw = String(value ?? "").trim();
if (!raw) return null;
const looksDated =
/^\d{4}-\d{2}-\d{2}/.test(raw) ||
raw.includes("T") ||
/[+-]\d{2}:?\d{2}$/.test(raw);
if (looksDated) {
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) {
return new Intl.DateTimeFormat(isEnglish(locale) ? "en-US" : "zh-CN", {
day: "2-digit",
hour: "2-digit",
hour12: false,
minute: "2-digit",
month: "2-digit",
})
.format(parsed)
.replace(",", "");
}
}
return normalizeHm(raw) || raw;
}
function getOfficialObservationCandidates(detail: CityDetail) {
const officialNearby = Array.isArray(detail.official_nearby)
? detail.official_nearby
: [];
const mgmNearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
const officialAnchor =
officialNearby.find(
(station) => station.is_settlement_anchor || station.is_airport_station,
) || officialNearby[0];
return {
centerStation: detail.center_station_candidate,
mgmNearby,
officialAnchor,
officialNearby,
};
}
function getObservedTemperatureProfile(detail: CityDetail, locale: Locale) {
const { mgmNearby } = getOfficialObservationCandidates(detail);
const currentSource = String(
detail.current?.settlement_source ||
detail.current?.settlement_source_label ||
"",
)
.trim()
.toLowerCase();
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
const isNmcAirport = String(detail.airport_primary?.source_label || "")
.trim()
.toLowerCase()
.includes("nmc");
const candidates = [
{
sourceLabel: detail.airport_current?.source_label || "METAR",
temp: detail.airport_current?.temp,
},
{
sourceLabel: detail.airport_primary?.source_label || "METAR",
temp: isNmcAirport ? null : detail.airport_primary?.temp,
},
{
sourceLabel: detail.current?.settlement_source_label,
temp: isNmcCurrent ? null : detail.current?.temp,
},
{
sourceLabel: mgmNearby[0]?.source_label,
temp: mgmNearby[0]?.temp,
},
];
const selected = candidates.find((candidate) =>
Number.isFinite(Number(candidate.temp)),
);
if (!selected) {
return isEnglish(locale) ? "Unavailable" : "未提供";
}
const observedTemp = Number(selected.temp);
const sourceLabel = firstNonEmptyString([
selected.sourceLabel,
getObservationSourceTag(detail),
]);
const rounded =
Math.abs(observedTemp - Math.round(observedTemp)) < 0.05
? String(Math.round(observedTemp))
: observedTemp.toFixed(1);
return `${rounded}${detail.temp_symbol || "°C"}${
sourceLabel ? ` · ${sourceLabel}` : ""
}`;
}
function getObservationUpdateProfile(detail: CityDetail, locale: Locale) {
const { mgmNearby } = getOfficialObservationCandidates(detail);
const mgmFirstRecord = asRecord(mgmNearby[0]);
const currentSource = String(
detail.current?.settlement_source ||
detail.current?.settlement_source_label ||
"",
)
.trim()
.toLowerCase();
const isNmcCurrent = currentSource === "nmc" || currentSource.includes("nmc");
const rawValue = firstNonEmptyString([
detail.airport_primary?.obs_time,
detail.airport_primary?.report_time,
detail.airport_current?.obs_time,
detail.airport_current?.report_time,
isNmcCurrent ? "" : detail.current?.obs_time,
isNmcCurrent ? "" : detail.current?.report_time,
mgmFirstRecord?.obs_time,
mgmFirstRecord?.report_time,
mgmFirstRecord?.time,
detail.updated_at,
]);
return (
formatObservationUpdate(rawValue, locale) ||
(isEnglish(locale) ? "Unavailable" : "未提供")
);
}
export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN") {
const risk = detail.risk || {};
const current = detail.current || {};
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
const nearbySource = String(detail.nearby_source || "").trim().toLowerCase();
const sourceCode = getObservationSourceCode(detail);
@@ -2901,12 +3169,13 @@ export function getCityProfileStats(detail: CityDetail, locale: Locale = "zh-CN"
? "Not marked"
: "未标注",
},
{
label: isEnglish(locale) ? "Observed temp" : "实测温度",
value: getObservedTemperatureProfile(detail, locale),
},
{
label: isEnglish(locale) ? "Observation update" : "观测更新",
value:
current.obs_time ||
detail.updated_at ||
(isEnglish(locale) ? "Unavailable" : "未提供"),
value: getObservationUpdateProfile(detail, locale),
},
{
label: isEnglish(locale) ? "Nearby stations" : "周边站点",
+53 -1
View File
@@ -10,6 +10,7 @@ const config: Config = {
theme: {
extend: {
colors: {
/* Legacy shadcn/ui HSL tokens */
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: "hsl(var(--card))",
@@ -21,9 +22,60 @@ const config: Config = {
accent: "hsl(var(--accent))",
"accent-foreground": "hsl(var(--accent-foreground))",
border: "hsl(var(--border))",
/* PolyWeather Design Tokens — direct CSS var refs */
"pw-bg": {
base: "var(--color-bg-base)",
raised: "var(--color-bg-raised)",
overlay: "var(--color-bg-overlay)",
card: "var(--color-bg-card)",
input: "var(--color-bg-input)",
},
"pw-text": {
primary: "var(--color-text-primary)",
secondary: "var(--color-text-secondary)",
muted: "var(--color-text-muted)",
disabled: "var(--color-text-disabled)",
},
"pw-accent": {
primary: "var(--color-accent-primary)",
secondary: "var(--color-accent-secondary)",
tertiary: "var(--color-accent-tertiary)",
},
"pw-signal": {
success: "var(--color-signal-success)",
warning: "var(--color-signal-warning)",
danger: "var(--color-signal-danger)",
info: "var(--color-signal-info)",
},
},
fontFamily: {
data: ["var(--font-data)"],
display: ["var(--font-display)"],
mono: ["var(--font-mono)"],
},
spacing: {
"pw-1": "var(--space-1)",
"pw-2": "var(--space-2)",
"pw-3": "var(--space-3)",
"pw-4": "var(--space-4)",
"pw-5": "var(--space-5)",
"pw-6": "var(--space-6)",
"pw-8": "var(--space-8)",
},
borderRadius: {
"pw-sm": "var(--radius-sm)",
"pw-md": "var(--radius-md)",
"pw-lg": "var(--radius-lg)",
"pw-xl": "var(--radius-xl)",
},
boxShadow: {
glow: "0 0 0 1px rgba(34, 211, 238, 0.2), 0 8px 32px rgba(8, 47, 73, 0.5)",
glow: "0 0 0 1px rgba(0, 229, 255, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
"glow-accent": "var(--shadow-glow-accent)",
"glow-secondary": "var(--shadow-glow-secondary)",
"elevation-1": "var(--shadow-elevation-1)",
"elevation-2": "var(--shadow-elevation-2)",
"elevation-3": "var(--shadow-elevation-3)",
},
},
},
+5 -3
View File
@@ -9,9 +9,11 @@ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.analysis.deb_algorithm import load_history # noqa: E402
from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
from scripts.fit_probability_calibration import _default_history_arg # noqa: E402
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_load_history_with_fallback,
)
def _sf(value):
@@ -126,7 +128,7 @@ def main():
)
args = parser.parse_args()
history = load_history(args.history_file)
history = _load_history_with_fallback(args.history_file)
overall = _blank_metrics()
by_city = defaultdict(_blank_metrics)
by_date = defaultdict(_blank_metrics)
+15 -2
View File
@@ -18,9 +18,14 @@ from src.analysis.settlement_rounding import apply_city_settlement # noqa: E402
from scripts.fit_probability_calibration import ( # noqa: E402
_default_history_arg,
_extract_samples,
_load_history_with_fallback,
_load_json_if_exists,
_load_legacy_training_samples,
_load_snapshot_rows,
_load_training_feature_history,
_load_truth_history,
merge_samples_with_legacy_archive,
)
from src.analysis.deb_algorithm import load_history # noqa: E402
def _mean(values):
@@ -93,12 +98,20 @@ def main():
)
args = parser.parse_args()
history = load_history(args.history_file)
history = _load_history_with_fallback(args.history_file)
training_feature_history = _load_training_feature_history()
truth_history = _load_truth_history()
settlement_history = _load_json_if_exists(args.settlement_history)
snapshot_rows = _load_snapshot_rows(None)
legacy_training_samples = _load_legacy_training_samples()
samples, filled_actual_from_history = _extract_samples(
history,
training_feature_history=training_feature_history,
truth_history=truth_history,
settlement_history=settlement_history,
snapshot_rows=snapshot_rows,
)
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
legacy_crps = []
emos_crps = []
+56 -2
View File
@@ -42,6 +42,15 @@ def _load_json_if_exists(path):
return data if isinstance(data, dict) else {}
def _legacy_training_samples_path():
return os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"training_samples.json",
)
def _legacy_history_path():
return os.path.join(PROJECT_ROOT, "data", "daily_records.json")
@@ -63,10 +72,10 @@ def _load_history_with_fallback(path):
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return DailyRecordRepository().load_all()
return {}
data = load_history(path)
data = _load_json_if_exists(path)
if data:
return data
return _load_json_if_exists(path)
return load_history(path)
def _load_truth_history():
@@ -107,6 +116,14 @@ def _load_snapshot_rows(path):
return rows
def _load_legacy_training_samples(path=None):
payload = _load_json_if_exists(path or _legacy_training_samples_path())
rows = payload.get("samples") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _actual_high_for(history, truth_history, settlement_history, city, date_str):
city_rows = (history or {}).get(city) or {}
record = city_rows.get(date_str) or {}
@@ -327,6 +344,40 @@ def _extract_samples(history, training_feature_history=None, truth_history=None,
return snapshot_samples + daily_samples, snapshot_filled + daily_filled
def merge_samples_with_legacy_archive(samples, legacy_samples=None):
merged = []
seen = set()
for sample in samples or []:
if not isinstance(sample, dict):
continue
key = (
str(sample.get("city") or "").strip().lower(),
str(sample.get("date") or "").strip(),
str(sample.get("sample_source") or "").strip().lower(),
)
if not key[0] or not key[1]:
continue
if key in seen:
continue
merged.append(sample)
seen.add(key)
for sample in legacy_samples or []:
if not isinstance(sample, dict):
continue
key = (
str(sample.get("city") or "").strip().lower(),
str(sample.get("date") or "").strip(),
str(sample.get("sample_source") or "").strip().lower(),
)
if not key[0] or not key[1]:
continue
if key in seen:
continue
merged.append(sample)
seen.add(key)
return merged
def main():
parser = argparse.ArgumentParser(description="Fit PolyWeather probability calibration parameters.")
parser.add_argument(
@@ -366,6 +417,7 @@ def main():
truth_history = _load_truth_history()
settlement_history = _load_json_if_exists(args.settlement_history)
snapshot_rows = _load_snapshot_rows(args.snapshot_file)
legacy_training_samples = _load_legacy_training_samples()
samples, filled_actual_from_history = _extract_samples(
history,
training_feature_history=training_feature_history,
@@ -373,6 +425,7 @@ def main():
settlement_history=settlement_history,
snapshot_rows=snapshot_rows,
)
samples = merge_samples_with_legacy_archive(samples, legacy_training_samples)
calibration = fit_calibration(samples, version=args.version)
if not samples:
calibration = default_calibration_payload(
@@ -382,6 +435,7 @@ def main():
calibration.setdefault("metrics", {})
calibration["metrics"]["filled_actual_from_history"] = filled_actual_from_history
calibration["metrics"]["settlement_history_city_count"] = len(settlement_history)
calibration["metrics"]["legacy_archive_samples"] = len(legacy_training_samples)
try:
calibration["source"] = os.path.relpath(args.output, PROJECT_ROOT)
except ValueError:
+8 -11
View File
@@ -7,7 +7,6 @@ from src.analysis.settlement_rounding import apply_city_settlement
from loguru import logger
from src.database.runtime_state import (
DailyRecordRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
TruthRecordRepository,
@@ -79,7 +78,7 @@ def load_history(filepath):
logger.error(f"Error loading daily records from sqlite, fallback to file: {e}")
if not os.path.exists(filepath):
if mode == STATE_STORAGE_DUAL:
if mode == STATE_STORAGE_SQLITE:
try:
data = _daily_record_repo.load_all()
_history_cache = data
@@ -113,13 +112,12 @@ def save_history(filepath, data):
_history_cache = data
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode == STATE_STORAGE_SQLITE:
try:
_daily_record_repo.replace_all(data)
except Exception as e:
logger.error(f"Error saving daily records to sqlite: {e}")
if mode == STATE_STORAGE_SQLITE:
return
return
if mode == STATE_STORAGE_SQLITE:
return
@@ -884,22 +882,21 @@ def update_daily_record(
except Exception as e:
logger.error(f"Error persisting training feature record city={city_name} date={date_str}: {e}")
# 自动清理:保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
# 自动清理:训练特征需要更长窗口,保留最近 180 天的记录
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
for city in list(data.keys()):
old_dates = [d for d in data[city] if d < cutoff]
for d in old_dates:
del data[city][d]
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode == STATE_STORAGE_SQLITE:
try:
_daily_record_repo.upsert_record(city_name, date_str, existing)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
cutoff = (datetime.now() - timedelta(days=180)).strftime("%Y-%m-%d")
_daily_record_repo.delete_older_than(cutoff)
except Exception as e:
logger.error(f"Error upserting daily record to sqlite city={city_name} date={date_str}: {e}")
if mode == STATE_STORAGE_SQLITE:
raise
raise
if mode != STATE_STORAGE_SQLITE:
save_history(history_file, data)
+2 -3
View File
@@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional
from src.database.runtime_state import (
ProbabilitySnapshotRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TrainingFeatureRecordRepository,
get_state_storage_mode,
@@ -105,7 +104,7 @@ def load_snapshot_rows_for_day(
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
path = archive_path or os.path.join(root_dir, "data", "probability_training_snapshots.jsonl")
if not os.path.exists(path):
if mode == STATE_STORAGE_DUAL:
if mode == STATE_STORAGE_SQLITE:
return _snapshot_repo.load_rows_by_city_date(city_key, date_key)
return []
@@ -260,7 +259,7 @@ def append_probability_snapshot(
return
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode == STATE_STORAGE_SQLITE:
_snapshot_repo.append_snapshot(payload)
_training_feature_repo.upsert_record(
city_key,
+64
View File
@@ -228,6 +228,60 @@ CITY_REGISTRY = {
"distance_km": 12.0,
"warning": "市场现按 Wunderground Halim 机场站整度°C口径结算;热带午后阵雨常在峰值前后快速压制气温。",
},
"manila": {
"name": "Manila",
"lat": 14.5086,
"lon": 121.0198,
"icao": "RPLL",
"settlement_source": "wunderground",
"settlement_station_code": "RPLL",
"settlement_station_label": "Manila/Ninoy Aquino International Airport Station",
"settlement_url": "https://www.wunderground.com/history/daily/ph/manila/RPLL",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "尼诺伊·阿基诺国际机场",
"distance_km": 10.0,
"warning": "市场现按 Wunderground 尼诺伊·阿基诺国际机场站整度°C口径结算;海风、湿热与午后对流会压缩日最高温窗口。",
},
"karachi": {
"name": "Karachi",
"lat": 24.9065,
"lon": 67.1608,
"icao": "OPKC",
"settlement_source": "wunderground",
"settlement_station_code": "OPKC",
"settlement_station_label": "Karachi/Jinnah International Airport Station",
"settlement_url": "https://www.wunderground.com/history/daily/pk/karachi/OPKC",
"tz_offset": 18000,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "真纳国际机场",
"distance_km": 15.0,
"min_plausible_metar_temp_c": 5.0,
"warning": "市场现按 Wunderground 真纳国际机场站整度°C口径结算;海风锋、干热输送与尘霾会影响峰值兑现时间。",
},
"masroor air base": {
"name": "Masroor Air Base",
"lat": 24.8936,
"lon": 66.9388,
"icao": "OPMR",
"settlement_station_code": "OPMR",
"settlement_station_label": "Masroor Air Base METAR",
"tz_offset": 18000,
"use_fahrenheit": False,
"is_major": False,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "Masroor Air Base",
"distance_km": 0.0,
"min_plausible_metar_temp_c": 5.0,
"warning": "军事机场 METAR 锚点;公开报文可能不连续,若 OPMR 缺报则不要自动替换为 OPKC 结算口径。",
},
"tokyo": {
"name": "Tokyo",
"lat": 35.5523,
@@ -777,6 +831,9 @@ ALIASES = {
"sg": "singapore", "tok": "tokyo", "tyo": "tokyo",
"kul": "kuala lumpur", "wmkk": "kuala lumpur", "sepang": "kuala lumpur",
"jkt": "jakarta", "wihh": "jakarta", "halim": "jakarta",
"mnl": "manila", "manila": "manila", "rpll": "manila",
"khi": "karachi", "karachi": "karachi", "opkc": "karachi", "jinnah": "karachi",
"opmr": "masroor air base", "masroor": "masroor air base", "masroor airbase": "masroor air base",
"hel": "helsinki", "efhk": "helsinki", "vantaa": "helsinki",
"ams": "amsterdam", "eham": "amsterdam", "schiphol": "amsterdam",
"pty": "panama city", "mpmg": "panama city", "panamacity": "panama city",
@@ -820,6 +877,13 @@ ALIASES = {
"吉隆坡": "kuala lumpur",
"雅加达": "jakarta",
"雅加達": "jakarta",
"马尼拉": "manila",
"馬尼拉": "manila",
"卡拉奇": "karachi",
"马斯鲁尔": "masroor air base",
"馬斯魯爾": "masroor air base",
"马斯鲁尔空军基地": "masroor air base",
"馬斯魯爾空軍基地": "masroor air base",
"东京": "tokyo",
"東京": "tokyo",
"特拉维夫": "tel aviv",
+93 -17
View File
@@ -12,6 +12,45 @@ from src.utils.metrics import record_source_call
class MetarSourceMixin:
def _get_min_plausible_metar_temp_c(
self, city: str, icao: Optional[str] = None
) -> Optional[float]:
normalized = str(city or "").strip().lower()
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
if not city_meta and icao:
icao_upper = str(icao or "").strip().upper()
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
city_meta = candidate
break
raw = city_meta.get("min_plausible_metar_temp_c")
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if value > -80 else None
def _is_plausible_metar_temp_c(
self, temp_c: object, city: str, icao: Optional[str] = None
) -> bool:
try:
value = float(temp_c)
except (TypeError, ValueError):
return False
minimum = self._get_min_plausible_metar_temp_c(city, icao)
if minimum is None:
return True
if value < minimum:
logger.warning(
"Discarding implausible METAR temperature city={} icao={} temp_c={} min_c={}",
city,
icao,
value,
minimum,
)
return False
return True
def get_icao_code(self, city: str) -> Optional[str]:
"""根据城市名获取对应的 ICAO 机场代码"""
normalized = city.lower().strip()
@@ -44,26 +83,52 @@ class MetarSourceMixin:
try:
url = "https://aviationweather.gov/api/data/metar"
params = {
"ids": icao,
"format": "json",
"hours": 24,
"_t": int(time.time()),
}
response = self.session.get(url, params=params, timeout=self.timeout)
response.raise_for_status()
history_hours = 24
def _request_metar_records(hours: int, timeout: float) -> list:
response = self._http_get(
url,
params={
"ids": icao,
"format": "json",
"hours": hours,
},
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
try:
data = _request_metar_records(
history_hours,
getattr(self, "metar_timeout_sec", self.timeout),
)
except httpx.HTTPError as primary_exc:
history_hours = 2
logger.warning(
f"METAR {icao} 24h 请求失败,尝试 latest fallback: {primary_exc}"
)
try:
data = _request_metar_records(
history_hours,
getattr(self, "metar_latest_timeout_sec", 2.5),
)
except httpx.HTTPError:
raise primary_exc
data = response.json()
if not data:
return None
latest = data[0]
temp_c = latest.get("temp")
if temp_c is not None and not self._is_plausible_metar_temp_c(temp_c, city, icao):
temp_c = None
dewp_c = latest.get("dewp")
def _parse_rawob_time(obs):
raw = obs.get("rawOb", "")
match = re.search(r"(\d{2})(\d{2})(\d{2})Z", raw)
match = re.search(r"\b(\d{2})(\d{2})(\d{2})Z\b", raw)
if match:
_day, hour, minute = (
int(match.group(1)),
@@ -112,7 +177,11 @@ class MetarSourceMixin:
try:
if obs_dt_iter >= utc_midnight:
temp_value = obs.get("temp")
if temp_value is not None and temp_value > max_so_far_c:
if (
temp_value is not None
and self._is_plausible_metar_temp_c(temp_value, city, icao)
and temp_value > max_so_far_c
):
max_so_far_c = temp_value
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
max_temp_time = local_report.strftime("%H:%M")
@@ -133,7 +202,11 @@ class MetarSourceMixin:
for index, obs in enumerate(data):
obs_temp = obs.get("temp")
obs_dt_iter = _parse_rawob_time(obs)
if obs_temp is not None and obs_dt_iter:
if (
obs_temp is not None
and obs_dt_iter
and self._is_plausible_metar_temp_c(obs_temp, city, icao)
):
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
time_str = local_rt.strftime("%H:%M")
if obs_dt_iter >= utc_midnight:
@@ -199,11 +272,15 @@ class MetarSourceMixin:
"today_obs": today_obs,
"recent_obs": recent_obs_raw,
"unit": unit,
"history_hours": history_hours,
}
logger.info(
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
)
if temp is not None:
logger.info(
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
)
else:
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
with self._metar_cache_lock:
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
@@ -246,9 +323,8 @@ class MetarSourceMixin:
"ids": icao,
"format": "json",
"hours": 24,
"_t": int(time.time()),
}
response = self.session.get(
response = self._http_get(
url,
params=params,
timeout=getattr(self, "metar_timeout_sec", self.timeout),
+2 -3
View File
@@ -7,7 +7,6 @@ import time
from loguru import logger
from src.database.runtime_state import (
OpenMeteoCacheRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
@@ -77,7 +76,7 @@ class OpenMeteoCacheMixin:
self._disk_cache_last_mtime = current_mtime
if loaded:
logger.info(f"✅ 从磁盘加载 Open-Meteo 缓存 {loaded} 条 ({self._disk_cache_path})")
if mode == STATE_STORAGE_DUAL:
if mode == STATE_STORAGE_SQLITE:
try:
_open_meteo_cache_repo.replace_payload(saved, self._disk_cache_max_age_sec)
except Exception as exc:
@@ -124,7 +123,7 @@ class OpenMeteoCacheMixin:
"multi_model": multi_model_snapshot,
"saved_at": time.time(),
}
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode == STATE_STORAGE_SQLITE:
_open_meteo_cache_repo.replace_payload(payload, self._disk_cache_max_age_sec)
self._disk_cache_last_mtime = _open_meteo_cache_repo.latest_updated_at()
if mode != STATE_STORAGE_SQLITE:
+1 -2
View File
@@ -12,7 +12,6 @@ from loguru import logger
from src.database.runtime_state import (
OfficialIntradayObservationRepository,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
@@ -183,7 +182,7 @@ class SettlementSourceMixin:
date_str = local_dt.strftime("%Y-%m-%d")
time_str = local_dt.strftime("%H:%M")
mode = get_state_storage_mode()
if mode not in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode != STATE_STORAGE_SQLITE:
return [{"time": time_str, "temp": round(float(current_temp), 1)}]
lock = self._get_settlement_series_lock()
+26
View File
@@ -61,6 +61,9 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"singapore": ["WSSS", "WSAP", "WMKK"],
"kuala lumpur": ["WMKK", "WMSA"],
"jakarta": ["WIHH", "WIII"],
"manila": ["RPLL"],
"karachi": ["OPKC", "OPMR"],
"masroor air base": ["OPMR", "OPKC"],
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
"tel aviv": ["LLBG"],
"milan": ["LIMC", "LIML", "LIME", "LIPO"],
@@ -130,12 +133,22 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
self.metar_timeout_sec = max(
2.0, float(os.getenv("POLYWEATHER_METAR_TIMEOUT_SEC", "4"))
)
self.metar_latest_timeout_sec = max(
1.0, float(os.getenv("POLYWEATHER_METAR_LATEST_TIMEOUT_SEC", "2.5"))
)
self.metar_cluster_timeout_sec = max(
2.0, float(os.getenv("POLYWEATHER_METAR_CLUSTER_TIMEOUT_SEC", "3.5"))
)
self.user_agent = str(
os.getenv(
"POLYWEATHER_USER_AGENT",
"PolyWeather/1.0 (+https://polyweather-pro.vercel.app)",
)
).strip()
self.session = httpx.Client(
timeout=self.timeout,
follow_redirects=True,
headers={"User-Agent": self.user_agent},
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
self.open_meteo_cache_ttl_sec = int(
@@ -235,6 +248,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
timeout=self.timeout,
follow_redirects=True,
proxy=proxy,
headers={"User-Agent": self.user_agent},
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
logger.info(f"正在使用天气数据代理: {proxy}")
@@ -597,6 +611,18 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
"jakarta": "Jakarta",
"雅加达": "Jakarta",
"雅加達": "Jakarta",
"manila": "Manila",
"rpll": "Manila",
"马尼拉": "Manila",
"馬尼拉": "Manila",
"karachi": "Karachi",
"opkc": "Karachi",
"卡拉奇": "Karachi",
"masroor": "Masroor Air Base",
"masroor air base": "Masroor Air Base",
"opmr": "Masroor Air Base",
"马斯鲁尔": "Masroor Air Base",
"馬斯魯爾": "Masroor Air Base",
"helsinki": "Helsinki",
"vantaa": "Helsinki",
"赫尔辛基": "Helsinki",
+203
View File
@@ -182,6 +182,64 @@ class DBManager:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_summary_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_panel_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_nearby_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_market_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS city_history_preview_cache (
city TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_at_ts REAL NOT NULL,
version TEXT,
source_fingerprint TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS cache_refresh_locks (
cache_key TEXT PRIMARY KEY,
locked_until_ts REAL NOT NULL,
owner TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -247,6 +305,151 @@ class DBManager:
conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}")
def _cache_table_name(self, kind: str) -> Optional[str]:
normalized = str(kind or "").strip().lower()
if normalized == "summary":
return "city_summary_cache"
if normalized == "panel":
return "city_panel_cache"
if normalized == "nearby":
return "city_nearby_cache"
if normalized == "market":
return "city_market_cache"
if normalized == "history_preview":
return "city_history_preview_cache"
return None
def get_city_cache(self, kind: str, city: str) -> Optional[Dict[str, Any]]:
table = self._cache_table_name(kind)
normalized_city = str(city or "").strip().lower()
if not table or not normalized_city:
return None
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
f"""
SELECT city, payload_json, updated_at, updated_at_ts, version, source_fingerprint
FROM {table}
WHERE city = ?
LIMIT 1
""",
(normalized_city,),
).fetchone()
if not row:
return None
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
return None
if not isinstance(payload, dict):
return None
return {
"city": str(row["city"] or normalized_city),
"payload": payload,
"updated_at": str(row["updated_at"] or ""),
"updated_at_ts": float(row["updated_at_ts"] or 0.0),
"version": str(row["version"] or ""),
"source_fingerprint": str(row["source_fingerprint"] or ""),
}
def set_city_cache(
self,
kind: str,
city: str,
payload: Dict[str, Any],
*,
version: str = "v1",
source_fingerprint: Optional[str] = None,
) -> None:
table = self._cache_table_name(kind)
normalized_city = str(city or "").strip().lower()
if not table or not normalized_city or not isinstance(payload, dict):
return
now = datetime.now().isoformat()
now_ts = datetime.now().timestamp()
with self._get_connection() as conn:
conn.execute(
f"""
INSERT INTO {table} (
city,
payload_json,
updated_at,
updated_at_ts,
version,
source_fingerprint
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(city) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at,
updated_at_ts = excluded.updated_at_ts,
version = excluded.version,
source_fingerprint = excluded.source_fingerprint
""",
(
normalized_city,
json.dumps(payload, ensure_ascii=False),
now,
now_ts,
str(version or "v1"),
str(source_fingerprint or ""),
),
)
conn.commit()
def acquire_cache_refresh_lock(
self,
cache_key: str,
*,
ttl_sec: int = 120,
owner: Optional[str] = None,
) -> Optional[str]:
normalized_key = str(cache_key or "").strip().lower()
if not normalized_key:
return None
lock_owner = str(owner or "").strip() or hashlib.sha1(
f"{normalized_key}:{datetime.now().timestamp()}".encode("utf-8")
).hexdigest()[:12]
now_ts = datetime.now().timestamp()
locked_until_ts = now_ts + max(15, int(ttl_sec or 120))
updated_at = datetime.now().isoformat()
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO cache_refresh_locks (cache_key, locked_until_ts, owner, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
locked_until_ts = excluded.locked_until_ts,
owner = excluded.owner,
updated_at = excluded.updated_at
WHERE cache_refresh_locks.locked_until_ts < ?
""",
(
normalized_key,
locked_until_ts,
lock_owner,
updated_at,
now_ts,
),
)
conn.commit()
return lock_owner if int(cursor.rowcount or 0) > 0 else None
def release_cache_refresh_lock(self, cache_key: str, owner: str) -> None:
normalized_key = str(cache_key or "").strip().lower()
normalized_owner = str(owner or "").strip()
if not normalized_key or not normalized_owner:
return
with self._get_connection() as conn:
conn.execute(
"""
DELETE FROM cache_refresh_locks
WHERE cache_key = ? AND owner = ?
""",
(normalized_key, normalized_owner),
)
conn.commit()
def get_payment_runtime_state(self, state_key: str) -> Optional[Dict[str, Any]]:
key = str(state_key or "").strip()
if not key:
+9 -4
View File
@@ -16,9 +16,9 @@ from src.database.db_manager import DBManager
STATE_STORAGE_FILE = "file"
STATE_STORAGE_DUAL = "dual"
STATE_STORAGE_SQLITE = "sqlite"
DEFAULT_STATE_STORAGE_MODE = STATE_STORAGE_SQLITE
VALID_STATE_STORAGE_MODES = {
STATE_STORAGE_FILE,
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
}
@@ -26,12 +26,17 @@ _LOGGED_MODES: set[str] = set()
def get_state_storage_mode() -> str:
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or STATE_STORAGE_DUAL).strip().lower()
raw = str(os.getenv("POLYWEATHER_STATE_STORAGE_MODE") or DEFAULT_STATE_STORAGE_MODE).strip().lower()
if raw == STATE_STORAGE_DUAL:
logger.warning(
f"POLYWEATHER_STATE_STORAGE_MODE={STATE_STORAGE_DUAL!r} is deprecated, normalize to {STATE_STORAGE_SQLITE}"
)
raw = STATE_STORAGE_SQLITE
if raw not in VALID_STATE_STORAGE_MODES:
logger.warning(
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {STATE_STORAGE_DUAL}"
f"invalid POLYWEATHER_STATE_STORAGE_MODE={raw!r}, fallback to {DEFAULT_STATE_STORAGE_MODE}"
)
raw = STATE_STORAGE_DUAL
raw = DEFAULT_STATE_STORAGE_MODE
if raw not in _LOGGED_MODES:
logger.info(f"runtime state storage mode={raw}")
_LOGGED_MODES.add(raw)
+6 -2
View File
@@ -289,13 +289,17 @@ def build_training_samples(
history_data: Optional[Dict[str, Any]] = None,
snapshot_index: Optional[Dict[Tuple[str, str], Dict[str, Any]]] = None,
) -> List[Dict[str, Any]]:
mode = get_state_storage_mode()
if isinstance(history_data, dict):
runtime_history = history_data
elif get_state_storage_mode() == STATE_STORAGE_SQLITE:
elif mode == STATE_STORAGE_SQLITE:
runtime_history = DailyRecordRepository().load_all()
else:
runtime_history = load_history(_history_file_path())
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
if isinstance(history_data, dict):
truth_history = runtime_history
training_feature_history = {}
elif mode == STATE_STORAGE_SQLITE:
truth_history = TruthRecordRepository().load_all()
training_feature_history = TrainingFeatureRecordRepository().load_all()
else:
+2 -2
View File
@@ -208,7 +208,7 @@ def get_prewarm_runtime_summary() -> Dict[str, Any]:
"interval_sec": interval_sec,
"jitter_sec": jitter_sec,
"include_detail": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_DETAIL", True),
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", True),
"include_market": _truthy_env("POLYWEATHER_PREWARM_INCLUDE_MARKET", False),
"force_refresh": _truthy_env("POLYWEATHER_PREWARM_FORCE_REFRESH", False),
"thread_alive": bool(_WORKER_THREAD and _WORKER_THREAD.is_alive()) or shared_alive,
"heartbeat_age_sec": None if heartbeat_age_sec is None else round(heartbeat_age_sec, 2),
@@ -380,7 +380,7 @@ def start_prewarm_worker_thread() -> Optional[threading.Thread]:
jitter_sec = int(os.getenv("POLYWEATHER_PREWARM_JITTER_SEC", "20"))
force_refresh = str(os.getenv("POLYWEATHER_PREWARM_FORCE_REFRESH") or "").strip().lower() in {"1", "true", "yes", "on"}
include_detail = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_DETAIL", "true")).strip().lower() in {"1", "true", "yes", "on"}
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "true")).strip().lower() in {"1", "true", "yes", "on"}
include_market = str(os.getenv("POLYWEATHER_PREWARM_INCLUDE_MARKET", "false")).strip().lower() in {"1", "true", "yes", "on"}
thread = threading.Thread(
target=run_worker_loop,
+1 -7
View File
@@ -10,7 +10,6 @@ from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
from src.database.runtime_state import (
STATE_STORAGE_DUAL,
STATE_STORAGE_SQLITE,
TelegramAlertStateRepository,
get_state_storage_mode,
@@ -208,11 +207,6 @@ def _load_state(path: str) -> Dict[str, Any]:
except Exception as exc:
logger.error(f"failed to load telegram push state from sqlite: {exc}")
if not os.path.exists(path):
if mode == STATE_STORAGE_DUAL:
try:
return _telegram_state_repo.load_state()
except Exception:
return {"last_by_city": {}, "by_signature": {}}
return {"last_by_city": {}, "by_signature": {}}
try:
with open(path, "r", encoding="utf-8") as fh:
@@ -228,7 +222,7 @@ def _load_state(path: str) -> Dict[str, Any]:
def _save_state(path: str, state: Dict[str, Any]) -> None:
mode = get_state_storage_mode()
if mode in {STATE_STORAGE_DUAL, STATE_STORAGE_SQLITE}:
if mode == STATE_STORAGE_SQLITE:
_telegram_state_repo.save_state(state)
if mode == STATE_STORAGE_SQLITE:
return
+105 -1
View File
@@ -1,5 +1,21 @@
from src.data_collection.country_networks import build_country_network_snapshot
from web.analysis_service import _build_city_detail_payload
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
from web.core import CITIES
def test_new_south_asia_city_registry_entries_are_wired():
assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground"
assert CITY_REGISTRY["manila"]["settlement_station_code"] == "RPLL"
assert CITY_REGISTRY["karachi"]["settlement_source"] == "wunderground"
assert CITY_REGISTRY["karachi"]["settlement_station_code"] == "OPKC"
assert CITY_REGISTRY["masroor air base"]["icao"] == "OPMR"
assert ALIASES["rpll"] == "manila"
assert ALIASES["opkc"] == "karachi"
assert ALIASES["opmr"] == "masroor air base"
assert CITIES["manila"]["lat"] == CITY_REGISTRY["manila"]["lat"]
assert CITIES["karachi"]["lon"] == CITY_REGISTRY["karachi"]["lon"]
assert CITIES["masroor air base"]["settlement_source"] == "metar"
def test_turkey_mgm_provider_returns_official_nearby_rows():
@@ -186,3 +202,91 @@ def test_city_detail_payload_exposes_airport_and_official_network_layers():
assert payload["official"]["airport_primary"]["source_code"] == "metar"
assert payload["official"]["official_nearby"][0]["source_code"] == "mgm"
assert payload["settlement_station"]["settlement_station_code"] == "LTAC"
def test_intraday_meteorology_supportive_heating_case():
payload = _build_intraday_meteorology(
{
"local_time": "12:04",
"temp_symbol": "°C",
"current": {"temp": 38.2, "max_so_far": 38.2},
"deb": {"prediction": 40.4},
"probabilities": {"distribution": [{"value": 40, "probability": 0.42}]},
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
"deviation_monitor": {"direction": "hot", "severity": "strong", "current_delta": 1.9},
"vertical_profile_signal": {
"heating_setup": "supportive",
"summary_zh": "混合层偏深,仍支持午后继续升温。",
},
"taf": {"signal": {"available": True, "suppression_level": "low", "summary_zh": "TAF 暂未提示强云雨压温。"}},
}
)
assert "上修空间" in payload["headline"]
assert "upside" in payload["headline_en"].lower()
assert payload["confidence"] == "high"
assert payload["base_case_bucket"] == "40°C"
assert payload["next_observation_time"] == "12:30"
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
assert all(item.get("summary_en") for item in payload["signal_contributions"])
def test_intraday_meteorology_suppressed_cloud_rain_case():
payload = _build_intraday_meteorology(
{
"local_time": "13:10",
"temp_symbol": "°C",
"current": {"temp": 36.0, "max_so_far": 36.5},
"deb": {"prediction": 40.2},
"probabilities": {"distribution": [{"value": 40, "probability": 0.35}]},
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
"deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -2.0},
"vertical_profile_signal": {"heating_setup": "suppressed", "suppression_risk": "high"},
"taf": {"signal": {"available": True, "suppression_level": "high", "disruption_level": "high"}},
}
)
assert "压制" in payload["headline"]
assert "capping" in payload["headline_en"].lower()
assert payload["confidence"] == "high"
assert any("云雨" in rule for rule in payload["invalidation_rules"])
assert any("cloud" in rule.lower() for rule in payload["invalidation_rules_en"])
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
def test_intraday_meteorology_handles_sparse_observations():
payload = _build_intraday_meteorology(
{
"local_time": "08:00",
"temp_symbol": "°C",
"current": {},
"probabilities": {"distribution": []},
"peak": {},
"taf": {},
"vertical_profile_signal": {},
}
)
assert payload["confidence"] == "low"
assert payload["next_observation_time"] == "08:30"
assert payload["signal_contributions"][0]["label"] == "数据完整性"
assert payload["signal_contributions"][0]["label_en"] == "Data completeness"
def test_intraday_meteorology_past_peak_case():
payload = _build_intraday_meteorology(
{
"local_time": "17:20",
"temp_symbol": "°C",
"current": {"temp": 33.0, "max_so_far": 39.0},
"probabilities": {"distribution": [{"value": 39, "probability": 0.5}]},
"peak": {"first_h": 13, "last_h": 15, "status": "past"},
"deviation_monitor": {"direction": "normal", "severity": "normal", "current_delta": 0.1},
}
)
assert "峰值窗口已过" in payload["headline"]
assert "passed" in payload["headline_en"].lower()
assert payload["base_case_bucket"] == "39°C"
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
assert any("final" in rule.lower() for rule in payload["invalidation_rules_en"])
+9 -4
View File
@@ -134,7 +134,7 @@ def test_payment_runtime_endpoint_returns_shape():
assert 'recent_audit_events' in payload
def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
def test_auth_me_does_not_reconcile_on_status_probe(monkeypatch):
monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None)
def _bind_identity(request):
@@ -147,11 +147,10 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
monkeypatch.setattr(routes.SUPABASE_ENTITLEMENT, "enabled", True)
calls = {"count": 0}
reconcile_calls = {"count": 0}
def _latest_subscription(user_id, respect_requirement=False):
calls["count"] += 1
if calls["count"] == 1:
return None
return {
"plan_code": "pro_monthly",
"starts_at": "2026-03-22T00:00:00+00:00",
@@ -164,10 +163,15 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
_latest_subscription,
)
monkeypatch.setattr(routes.PAYMENT_CHECKOUT, "enabled", True)
def _reconcile_latest_intent(user_id):
reconcile_calls["count"] += 1
return {"ok": True, "action": "reconciled_confirmed_intent"}
monkeypatch.setattr(
routes.PAYMENT_CHECKOUT,
"reconcile_latest_intent",
lambda user_id: {"ok": True, "action": "reconciled_confirmed_intent"},
_reconcile_latest_intent,
)
response = client.get("/api/auth/me")
@@ -176,6 +180,7 @@ def test_auth_me_auto_reconciles_missing_subscription(monkeypatch):
payload = response.json()
assert payload["subscription_active"] is True
assert payload["subscription_plan_code"] == "pro_monthly"
assert reconcile_calls["count"] == 0
def test_ops_memberships_prefers_supabase_auth_email(monkeypatch):
+460 -7
View File
@@ -30,6 +30,7 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.country_networks import build_country_network_snapshot
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
from src.data_collection.nmc_sources import NMC_CITY_REFERENCES
from src.models.lgbm_daily_high import predict_lgbm_daily_high
TURKISH_MGM_CITIES = {"ankara", "istanbul"}
@@ -52,6 +53,51 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
)
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
raw = str(value or "").strip()
if not raw:
return ""
if "T" in raw:
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone(timedelta(seconds=utc_offset))).strftime("%H:%M")
except Exception:
pass
match = re.search(r"(\d{1,2}):(\d{2})", raw)
if match:
return f"{int(match.group(1)):02d}:{match.group(2)}"
return raw[:16]
def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str, Any]:
city_key = str(city or "").strip().lower()
if city_key not in NMC_CITY_REFERENCES:
return {}
try:
payload = _weather.fetch_nmc_region_current(
city_key,
use_fahrenheit=use_fahrenheit,
)
return payload if isinstance(payload, dict) else {}
except Exception as exc:
logger.debug("NMC current fallback failed city={}: {}", city_key, exc)
return {}
def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C") -> bool:
temp = _sf(value)
if temp is None:
return False
meta = CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {}
min_c = _sf(meta.get("min_plausible_metar_temp_c"))
if min_c is None:
return True
min_value = min_c * 9 / 5 + 32 if str(unit or "").upper().endswith("F") else min_c
return temp >= min_value
def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None:
now = datetime.now(timezone.utc).isoformat()
with _ANALYSIS_CACHE_STATS_LOCK:
@@ -1084,6 +1130,338 @@ def _build_taf_signal(
}
def _clock_minutes(value: Any) -> Optional[int]:
text = str(value or "").strip()
match = re.search(r"\b(\d{1,2}):(\d{2})\b", text)
if not match:
return None
hour = int(match.group(1))
minute = int(match.group(2))
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
return None
return hour * 60 + minute
def _format_clock_minutes(value: int) -> str:
value = max(0, min(23 * 60 + 59, int(value)))
return f"{value // 60:02d}:{value % 60:02d}"
def _next_observation_clock(local_time: Any) -> str:
minutes = _clock_minutes(local_time)
if minutes is None:
return "--"
next_slot = ((minutes // 30) + 1) * 30
if next_slot > 23 * 60 + 59:
return "23:59"
return _format_clock_minutes(next_slot)
def _bucket_label_from_value(value: Optional[float], unit: str) -> Optional[str]:
if value is None:
return None
try:
return f"{int(round(float(value)))}{unit or '°C'}"
except Exception:
return None
def _top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]:
if not isinstance(distribution, list):
return None
candidates = [row for row in distribution if isinstance(row, dict)]
if not candidates:
return None
return max(candidates, key=lambda row: _sf(row.get("probability")) or -1.0)
def _bucket_label(row: Optional[Dict[str, Any]], unit: str) -> Optional[str]:
if not isinstance(row, dict):
return None
for key in ("label", "bucket", "range"):
raw = str(row.get(key) or "").strip()
if raw:
return raw
return _bucket_label_from_value(_sf(row.get("value")), unit)
def _add_signal(
signals: list,
*,
label: str,
direction: str,
strength: str,
summary: str,
label_en: Optional[str] = None,
summary_en: Optional[str] = None,
) -> None:
signals.append(
{
"label": label,
"label_en": label_en or label,
"direction": direction,
"strength": strength,
"summary": summary,
"summary_en": summary_en or summary,
}
)
def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
"""Build a paid-product intraday meteorology read from existing layers."""
current = data.get("current") or {}
probabilities = data.get("probabilities") or {}
distribution = probabilities.get("distribution") or []
top_bucket = _top_probability_bucket(distribution)
unit = str(data.get("temp_symbol") or "°C")
deb = data.get("deb") or {}
peak = data.get("peak") or {}
deviation = data.get("deviation_monitor") or {}
taf_signal = (
((data.get("taf") or {}).get("signal") or {})
if isinstance(data.get("taf"), dict)
else {}
)
vertical = data.get("vertical_profile_signal") or {}
current_temp = _sf(current.get("temp"))
max_so_far = _sf(current.get("max_so_far"))
deb_prediction = _sf(deb.get("prediction"))
base_value = _sf(top_bucket.get("value")) if isinstance(top_bucket, dict) else None
if base_value is None:
base_value = deb_prediction
if base_value is None:
base_value = max_so_far if max_so_far is not None else current_temp
base_case_bucket = _bucket_label(top_bucket, unit) or _bucket_label_from_value(base_value, unit)
upside_bucket = _bucket_label_from_value(base_value + 1.0, unit) if base_value is not None else None
downside_bucket = _bucket_label_from_value(base_value - 1.0, unit) if base_value is not None else None
signals: list = []
support_score = 0
suppress_score = 0
available_layers = 0
direction = str(deviation.get("direction") or "").lower()
severity = str(deviation.get("severity") or "normal").lower()
delta = _sf(deviation.get("current_delta"))
if direction:
available_layers += 1
strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak")
if direction == "hot":
support_score += 2 if strength == "strong" else 1
_add_signal(
signals,
label="日内节奏",
label_en="Intraday pace",
direction="support",
strength=strength,
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.",
)
elif direction == "cold":
suppress_score += 2 if strength == "strong" else 1
_add_signal(
signals,
label="日内节奏",
label_en="Intraday pace",
direction="suppress",
strength=strength,
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.",
)
else:
_add_signal(
signals,
label="日内节奏",
label_en="Intraday pace",
direction="neutral",
strength="weak",
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.",
)
heating_setup = str(vertical.get("heating_setup") or "").lower()
suppression_risk = str(vertical.get("suppression_risk") or "").lower()
if heating_setup or suppression_risk:
available_layers += 1
if heating_setup == "supportive":
support_score += 2
_add_signal(
signals,
label="边界层结构",
label_en="Boundary-layer setup",
direction="support",
strength="strong",
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."),
)
elif heating_setup == "suppressed" or suppression_risk == "high":
suppress_score += 2
_add_signal(
signals,
label="边界层结构",
label_en="Boundary-layer setup",
direction="suppress",
strength="strong",
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."),
)
else:
_add_signal(
signals,
label="边界层结构",
label_en="Boundary-layer setup",
direction="neutral",
strength="medium",
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."),
)
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
taf_disruption = str(taf_signal.get("disruption_level") or "").lower()
if taf_signal.get("available") or taf_suppression:
available_layers += 1
if taf_suppression == "high" or taf_disruption == "high":
suppress_score += 2
direction_value = "suppress"
strength = "strong"
elif taf_suppression == "medium" or taf_disruption == "medium":
suppress_score += 1
direction_value = "suppress"
strength = "medium"
else:
support_score += 1
direction_value = "support"
strength = "weak"
_add_signal(
signals,
label="TAF 云雨扰动",
label_en="TAF cloud/rain disruption",
direction=direction_value,
strength=strength,
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."),
)
airport_delta = _sf(data.get("airport_vs_network_delta"))
lead_signal = data.get("network_lead_signal") or {}
if airport_delta is not None:
available_layers += 1
leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip()
if airport_delta <= -0.4:
support_score += 1
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="support",
strength="medium",
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}",
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}.",
)
elif airport_delta >= 0.4:
suppress_score += 1
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="suppress",
strength="medium",
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports.",
)
else:
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="neutral",
strength="weak",
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.",
)
peak_status = str(peak.get("status") or "").lower()
first_h = _sf(peak.get("first_h"))
last_h = _sf(peak.get("last_h"))
peak_window = (
f"{int(first_h):02d}:00-{int(last_h):02d}:59"
if first_h is not None and last_h is not None
else "--"
)
if peak_status == "past":
headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
confidence = "high" if available_layers >= 2 else "medium"
elif suppress_score >= support_score + 2:
headline = "峰值存在云雨或结构压制,当前更偏防守高温上修。"
headline_en = "Cloud/rain or structural suppression is capping the peak; defend against aggressive high-temperature upside for now."
confidence = "high" if available_layers >= 3 else "medium"
elif support_score >= suppress_score + 2:
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
confidence = "high" if available_layers >= 3 else "medium"
elif available_layers == 0:
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
confidence = "low"
else:
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
confidence = "medium" if available_layers >= 2 else "low"
next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
threshold = base_value
invalidation_rules = []
invalidation_rules_en = []
confirmation_rules = []
confirmation_rules_en = []
if peak_status == "past":
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.")
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.")
else:
watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
if threshold is not None:
invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.")
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.")
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.")
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.")
if not signals:
_add_signal(
signals,
label="数据完整性",
label_en="Data completeness",
direction="neutral",
strength="weak",
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.",
)
return {
"headline": headline,
"headline_en": headline_en,
"confidence": confidence,
"base_case_bucket": base_case_bucket,
"upside_bucket": upside_bucket,
"downside_bucket": downside_bucket,
"next_observation_time": next_observation,
"peak_window": peak_window,
"invalidation_rules": invalidation_rules[:4],
"invalidation_rules_en": invalidation_rules_en[:4],
"confirmation_rules": confirmation_rules[:3],
"confirmation_rules_en": confirmation_rules_en[:3],
"signal_contributions": signals[:5],
}
def _analyze(
city: str,
force_refresh: bool = False,
@@ -1176,17 +1554,45 @@ def _analyze(
sc_cur = settlement_current.get("current", {}) if settlement_current else {}
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
primary_current = sc_cur if use_settlement_current else mc
current_source = settlement_source
current_source_label = settlement_source_label
current_station_code = settlement_current.get("station_code")
current_station_name = settlement_current.get("station_name")
cur_temp = _sf(primary_current.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
cur_temp = _sf(mc.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
cur_temp = _sf(mg_cur.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
nmc_cur = nmc_fallback.get("current") or {}
nmc_temp = _sf(nmc_cur.get("temp"))
if nmc_temp is not None:
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
current_station_code = nmc_fallback.get("station_code")
current_station_name = nmc_fallback.get("station_name")
max_so_far = _sf(primary_current.get("max_temp_so_far"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = _sf(mc.get("max_temp_so_far"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = cur_temp
max_temp_time = primary_current.get("max_temp_time")
if not max_temp_time and not use_settlement_current:
@@ -1237,6 +1643,12 @@ def _analyze(
)
except Exception:
obs_time_str = str(obs_t)[:16]
if not obs_time_str and current_source == "nmc":
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
obs_time_str = _format_observation_time_local(
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
int(utc_offset or 0),
)
settlement_today_obs = []
if use_settlement_current:
@@ -1269,8 +1681,14 @@ def _analyze(
metar_today_obs_payload = [
{"time": t, "temp": v}
for t, v in (metar.get("today_obs", []) if metar else [])
if _is_plausible_city_temp(city, v, sym)
]
metar_recent_obs_payload = [
point
for point in (metar.get("recent_obs", []) if metar else [])
if isinstance(point, dict)
and _is_plausible_city_temp(city, point.get("temp"), sym)
]
metar_recent_obs_payload = metar.get("recent_obs", []) if metar else []
airport_max_so_far = None
airport_max_temp_time = None
for point in metar_today_obs_payload:
@@ -1826,10 +2244,10 @@ def _analyze(
"max_temp_time": max_temp_time,
"raw_max_so_far": raw_settlement_max,
"wu_settlement": wu_settle,
"settlement_source": settlement_source,
"settlement_source_label": settlement_source_label,
"station_code": settlement_current.get("station_code"),
"station_name": settlement_current.get("station_name"),
"settlement_source": current_source,
"settlement_source_label": current_source_label,
"station_code": current_station_code,
"station_name": current_station_name,
"obs_time": obs_time_str,
"obs_age_min": None if use_settlement_current else metar_age_min,
"report_time": primary_current.get("report_time"),
@@ -1927,6 +2345,7 @@ def _analyze(
"ai_analysis": "",
"updated_at": datetime.now(timezone.utc).isoformat(),
}
result["intraday_meteorology"] = _build_intraday_meteorology(result)
if include_llm_commentary:
result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq(
@@ -2033,17 +2452,42 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur)
primary_current = sc_cur if use_settlement_current else mc
current_source = settlement_source
current_source_label = settlement_source_label
nmc_fallback: Dict[str, Any] = {}
cur_temp = _sf(primary_current.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
cur_temp = _sf(mc.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
cur_temp = _sf(mg_cur.get("temp"))
if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym):
cur_temp = None
if cur_temp is None:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
nmc_cur = nmc_fallback.get("current") or {}
nmc_temp = _sf(nmc_cur.get("temp"))
if nmc_temp is not None:
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
max_so_far = _sf(primary_current.get("max_temp_so_far"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = _sf(mc.get("max_temp_so_far"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = _sf(mg_cur.get("mgm_max_temp"))
if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym):
max_so_far = None
if max_so_far is None:
max_so_far = cur_temp
max_temp_time = primary_current.get("max_temp_time")
if not max_temp_time and not use_settlement_current:
@@ -2084,6 +2528,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
)
except Exception:
obs_time_str = str(obs_t)[:16]
if not obs_time_str and current_source == "nmc":
if not nmc_fallback:
nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f)
obs_time_str = _format_observation_time_local(
nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"),
int(utc_offset or 0),
)
om_daily = (open_meteo.get("daily") or {}) if isinstance(open_meteo, dict) else {}
om_hourly = (open_meteo.get("hourly") or {}) if isinstance(open_meteo, dict) else {}
@@ -2200,8 +2651,8 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"max_so_far": _sf(display_settlement_max),
"max_temp_time": max_temp_time,
"wu_settlement": _sf(wu_settle),
"settlement_source": settlement_source,
"settlement_source_label": settlement_source_label,
"settlement_source": current_source,
"settlement_source_label": current_source_label,
"obs_time": obs_time_str or None,
"obs_age_min": obs_age_min,
},
@@ -2407,6 +2858,8 @@ def _build_city_detail_payload(
},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
"dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
"intraday_meteorology": data.get("intraday_meteorology")
or _build_intraday_meteorology(data),
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
"taf": data.get("taf") or {},
"market_scan": market_scan,
+429 -121
View File
@@ -6,12 +6,13 @@ from datetime import datetime, timedelta
from typing import Optional
from fastapi.concurrency import run_in_threadpool
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import PlainTextResponse
from loguru import logger
from src.analysis.deb_algorithm import load_history
from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day
from src.database.db_manager import DBManager
from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository
from src.analysis.settlement_rounding import apply_city_settlement
from src.data_collection.country_networks import get_country_network_provider
@@ -55,6 +56,7 @@ from web.core import (
)
router = APIRouter()
_CACHE_DB = DBManager()
_DEB_RECENT_LOOKBACK = 7
_DEB_RECENT_MIN_SAMPLES = 3
@@ -91,6 +93,278 @@ DEFAULT_PREWARM_CITIES = [
"madrid",
]
HISTORY_PREVIEW_DAY_LIMIT = 21
ASIA_CORE_CITIES = [
"hong kong",
"taipei",
"tokyo",
"seoul",
"busan",
"shanghai",
"beijing",
"guangzhou",
"shenzhen",
"chongqing",
"chengdu",
"singapore",
"kuala lumpur",
"jakarta",
]
EUROPE_CORE_CITIES = [
"istanbul",
"ankara",
"moscow",
"tel aviv",
"london",
"paris",
"madrid",
"milan",
"warsaw",
"amsterdam",
"helsinki",
]
US_CORE_CITIES = [
"new york",
"los angeles",
"san francisco",
"austin",
"houston",
"chicago",
"dallas",
"miami",
"atlanta",
"seattle",
]
CITY_SUMMARY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_SUMMARY_CACHE_TTL_SEC", "180")))
CITY_PANEL_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_PANEL_CACHE_TTL_SEC", "300")))
CITY_NEARBY_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_NEARBY_CACHE_TTL_SEC", "480")))
CITY_MARKET_CACHE_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CITY_MARKET_CACHE_TTL_SEC", "900")))
CITY_HISTORY_PREVIEW_CACHE_TTL_SEC = max(
60,
int(os.getenv("POLYWEATHER_CITY_HISTORY_PREVIEW_CACHE_TTL_SEC", "1800")),
)
CACHE_REFRESH_LOCK_TTL_SEC = max(30, int(os.getenv("POLYWEATHER_CACHE_REFRESH_LOCK_TTL_SEC", "120")))
def _city_cache_is_fresh(entry: Optional[dict], ttl_sec: int) -> bool:
if not isinstance(entry, dict):
return False
updated_at_ts = float(entry.get("updated_at_ts") or 0.0)
if updated_at_ts <= 0:
return False
return (time.time() - updated_at_ts) < float(ttl_sec)
def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict:
data = _analyze_summary(city, force_refresh=force_refresh)
payload = _build_city_summary_payload(data)
_CACHE_DB.set_city_cache(
"summary",
city,
payload,
version="v1",
source_fingerprint=f"{city}:summary",
)
return payload
def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict:
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="panel")
_CACHE_DB.set_city_cache(
"panel",
city,
payload,
version="v1",
source_fingerprint=f"{city}:panel",
)
return payload
def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict:
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="nearby")
_CACHE_DB.set_city_cache(
"nearby",
city,
payload,
version="v1",
source_fingerprint=f"{city}:nearby",
)
return payload
def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
payload = _analyze(city, force_refresh=force_refresh, include_llm_commentary=False, detail_mode="market")
_CACHE_DB.set_city_cache(
"market",
city,
payload,
version="v1",
source_fingerprint=f"{city}:market",
)
return payload
def _build_city_history_payload(city: str, include_records: bool = False) -> dict:
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
truth_rows = _truth_record_repo.load_city(city)
feature_rows = _training_feature_repo.load_city(city)
if not truth_rows and not feature_rows:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
history_file = os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
else:
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
city_data = {}
for day in all_dates:
record: dict[str, object] = {}
truth = truth_rows.get(day) or {}
features = feature_rows.get(day) or {}
if truth.get("actual_high") is not None:
record["actual_high"] = truth.get("actual_high")
record["settlement_source"] = truth.get("settlement_source")
record["settlement_station_code"] = truth.get("settlement_station_code")
record["settlement_station_label"] = truth.get("settlement_station_label")
record["truth_version"] = truth.get("truth_version")
record["updated_by"] = truth.get("updated_by")
record["truth_updated_at"] = truth.get("truth_updated_at")
if isinstance(features, dict):
if features.get("deb_prediction") is not None:
record["deb_prediction"] = features.get("deb_prediction")
if features.get("mu") is not None:
record["mu"] = features.get("mu")
if isinstance(features.get("forecasts"), dict):
record["forecasts"] = features.get("forecasts")
city_data[day] = record
if not city_data:
return {
"history": [],
"mode": "full" if include_records else "preview",
"has_more": False,
"full_count": 0,
"preview_count": 0,
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
}
all_days = sorted(city_data.keys())
selected_days = all_days if include_records else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
out = []
for day in selected_days:
rec = city_data.get(day, {})
if not isinstance(rec, dict):
rec = {}
act = rec.get("actual_high")
deb = rec.get("deb_prediction")
mu = rec.get("mu")
snapshots = load_snapshot_rows_for_day(city, day)
peak_ref = _build_peak_minus_12h_reference(
actual_high=act,
snapshots=snapshots,
)
forecasts_raw = rec.get("forecasts", {}) or {}
forecasts = {}
if isinstance(forecasts_raw, dict):
for model_name, model_value in forecasts_raw.items():
if _is_excluded_model_name(str(model_name)):
continue
fv = _sf(model_value)
forecasts[str(model_name)] = fv if fv is not None else None
forecasts = _merge_missing_history_forecasts_from_snapshots(
forecasts,
snapshots,
)
mgm = forecasts.get("MGM")
out.append(
{
"date": day,
"actual": float(act) if act is not None else None,
"deb": float(deb) if deb is not None else None,
"mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts,
"settlement_source": rec.get("settlement_source"),
"settlement_station_code": rec.get("settlement_station_code"),
"settlement_station_label": rec.get("settlement_station_label"),
"truth_version": rec.get("truth_version"),
"updated_by": rec.get("updated_by"),
"truth_updated_at": rec.get("truth_updated_at"),
"actual_peak_time": peak_ref.get("actual_peak_time"),
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
}
)
return {
"history": out,
"mode": "full" if include_records else "preview",
"has_more": len(all_days) > len(selected_days),
"full_count": len(all_days),
"preview_count": len(out),
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
}
def _refresh_city_history_preview_cache(city: str) -> dict:
payload = _build_city_history_payload(city, include_records=False)
_CACHE_DB.set_city_cache(
"history_preview",
city,
payload,
version="v1",
source_fingerprint=f"{city}:history_preview",
)
return payload
def _schedule_cache_refresh(
background_tasks: BackgroundTasks,
*,
kind: str,
city: str,
force_refresh: bool = False,
) -> bool:
normalized_kind = str(kind or "").strip().lower()
normalized_city = str(city or "").strip().lower()
if normalized_kind not in {"summary", "panel", "nearby", "market", "history_preview"} or not normalized_city:
return False
cache_key = f"city:{normalized_kind}:{normalized_city}"
owner = _CACHE_DB.acquire_cache_refresh_lock(
cache_key,
ttl_sec=CACHE_REFRESH_LOCK_TTL_SEC,
)
if not owner:
return False
def _runner() -> None:
try:
if normalized_kind == "summary":
_refresh_city_summary_cache(normalized_city, force_refresh=force_refresh)
elif normalized_kind == "panel":
_refresh_city_panel_cache(normalized_city, force_refresh=force_refresh)
elif normalized_kind == "nearby":
_refresh_city_nearby_cache(normalized_city, force_refresh=force_refresh)
elif normalized_kind == "history_preview":
_refresh_city_history_preview_cache(normalized_city)
else:
_refresh_city_market_cache(normalized_city, force_refresh=force_refresh)
except Exception as exc:
logger.warning(
"cache refresh failed kind={} city={} force_refresh={}: {}",
normalized_kind,
normalized_city,
force_refresh,
exc,
)
finally:
_CACHE_DB.release_cache_refresh_lock(cache_key, owner)
background_tasks.add_task(_runner)
return True
def _parse_snapshot_dt(value: object) -> Optional[datetime]:
@@ -218,6 +492,33 @@ def _normalize_city_list(raw: Optional[str]) -> list[str]:
return out
def _select_priority_city_batches(client_timezone: Optional[str]) -> dict[str, object]:
tz = str(client_timezone or "").strip()
normalized = tz.lower()
if normalized.startswith("america/"):
primary = list(US_CORE_CITIES)
secondary = []
region = "america"
elif normalized.startswith("europe/"):
primary = list(EUROPE_CORE_CITIES)
secondary = list(ASIA_CORE_CITIES)
region = "europe"
elif normalized.startswith("asia/") or normalized.startswith("australia/") or normalized.startswith("pacific/"):
primary = list(ASIA_CORE_CITIES)
secondary = list(EUROPE_CORE_CITIES)
region = "asia"
else:
primary = list(ASIA_CORE_CITIES)
secondary = list(EUROPE_CORE_CITIES)
region = "default"
return {
"region": region,
"timezone": tz or None,
"primary": primary,
"secondary": secondary,
}
def _history_file_path() -> str:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(project_root, "data", "daily_records.json")
@@ -317,7 +618,7 @@ async def system_prewarm(
for city in selected:
city_started = time.perf_counter()
try:
data = _analyze(city, force_refresh=force_refresh)
_refresh_city_summary_cache(city, force_refresh=force_refresh)
entry: dict[str, object] = {
"city": city,
"summary": True,
@@ -325,20 +626,11 @@ async def system_prewarm(
}
summary_ok += 1
if include_detail:
_build_city_summary_payload(data)
_build_city_detail_payload(
data,
target_date=str(data.get("local_date") or "").strip() or None,
)
_refresh_city_panel_cache(city, force_refresh=force_refresh)
entry["detail"] = True
detail_ok += 1
if include_market:
_build_city_detail_payload(
data,
target_date=str(data.get("local_date") or "").strip() or None,
)
entry["market"] = True
market_ok += 1
entry["market"] = False
warmed.append(entry)
except Exception as exc:
failed.append(
@@ -365,6 +657,7 @@ async def system_prewarm(
"warmed": warmed,
"failed": failed,
"summary_ok": summary_ok,
"panel_ok": detail_ok,
"detail_ok": detail_ok,
"market_ok": market_ok,
"failed_count": len(failed),
@@ -431,6 +724,7 @@ async def list_cities(request: Request):
@router.get("/api/city/{name}")
async def city_detail(
request: Request,
background_tasks: BackgroundTasks,
name: str,
force_refresh: bool = False,
depth: str = "panel",
@@ -446,128 +740,121 @@ async def city_detail(
detail_mode = "nearby"
else:
detail_mode = "panel"
if detail_mode == "panel":
if force_refresh:
return await run_in_threadpool(_refresh_city_panel_cache, city, True)
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "panel", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_PANEL_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="panel", city=city)
return cached_entry.get("payload") or {}
return await run_in_threadpool(_refresh_city_panel_cache, city, False)
if detail_mode == "nearby":
if force_refresh:
return await run_in_threadpool(_refresh_city_nearby_cache, city, True)
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "nearby", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_NEARBY_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="nearby", city=city)
return cached_entry.get("payload") or {}
return await run_in_threadpool(_refresh_city_nearby_cache, city, False)
if detail_mode == "market":
if force_refresh:
return await run_in_threadpool(_refresh_city_market_cache, city, True)
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="market", city=city)
return cached_entry.get("payload") or {}
return await run_in_threadpool(_refresh_city_market_cache, city, False)
return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)
@router.get("/api/history/{name}")
async def city_history(
request: Request,
background_tasks: BackgroundTasks,
name: str,
include_records: bool = False,
):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
if include_records:
return await run_in_threadpool(_build_city_history_payload, city, True)
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "history_preview", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_HISTORY_PREVIEW_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="history_preview", city=city)
return cached_entry.get("payload") or {}
return await run_in_threadpool(_refresh_city_history_preview_cache, city)
def _build_history_payload():
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
truth_rows = _truth_record_repo.load_city(city)
feature_rows = _training_feature_repo.load_city(city)
if not truth_rows and not feature_rows:
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
history_file = os.path.join(project_root, "data", "daily_records.json")
data = load_history(history_file)
city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {}
else:
all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys()))
city_data = {}
for day in all_dates:
record: dict[str, object] = {}
truth = truth_rows.get(day) or {}
features = feature_rows.get(day) or {}
if truth.get("actual_high") is not None:
record["actual_high"] = truth.get("actual_high")
record["settlement_source"] = truth.get("settlement_source")
record["settlement_station_code"] = truth.get("settlement_station_code")
record["settlement_station_label"] = truth.get("settlement_station_label")
record["truth_version"] = truth.get("truth_version")
record["updated_by"] = truth.get("updated_by")
record["truth_updated_at"] = truth.get("truth_updated_at")
if isinstance(features, dict):
if features.get("deb_prediction") is not None:
record["deb_prediction"] = features.get("deb_prediction")
if features.get("mu") is not None:
record["mu"] = features.get("mu")
if isinstance(features.get("forecasts"), dict):
record["forecasts"] = features.get("forecasts")
city_data[day] = record
if not city_data:
return {
"history": [],
"mode": "full" if include_records else "preview",
"has_more": False,
"full_count": 0,
"preview_count": 0,
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
@router.get("/api/system/cache-status")
async def system_cache_status(request: Request, cities: Optional[str] = None):
_assert_entitlement(request)
selected = _normalize_city_list(cities)
if not selected:
selected = list(DEFAULT_PREWARM_CITIES)
kinds = {
"summary": CITY_SUMMARY_CACHE_TTL_SEC,
"panel": CITY_PANEL_CACHE_TTL_SEC,
"nearby": CITY_NEARBY_CACHE_TTL_SEC,
"market": CITY_MARKET_CACHE_TTL_SEC,
"history_preview": CITY_HISTORY_PREVIEW_CACHE_TTL_SEC,
}
items = []
for city in selected:
row = {"city": city}
for kind, ttl_sec in kinds.items():
entry = _CACHE_DB.get_city_cache(kind, city)
row[kind] = {
"exists": bool(entry),
"fresh": _city_cache_is_fresh(entry, ttl_sec),
"updated_at": entry.get("updated_at") if entry else None,
"age_sec": round(max(0.0, time.time() - float(entry.get("updated_at_ts") or 0.0)), 1)
if entry
else None,
"ttl_sec": ttl_sec,
}
items.append(row)
return {"cities": items}
all_days = sorted(city_data.keys())
selected_days = (
all_days
if include_records
else all_days[-HISTORY_PREVIEW_DAY_LIMIT:]
)
out = []
for day in selected_days:
rec = city_data.get(day, {})
if not isinstance(rec, dict):
rec = {}
act = rec.get("actual_high")
deb = rec.get("deb_prediction")
mu = rec.get("mu")
snapshots = load_snapshot_rows_for_day(city, day)
peak_ref = _build_peak_minus_12h_reference(
actual_high=act,
snapshots=snapshots,
)
forecasts_raw = rec.get("forecasts", {}) or {}
forecasts = {}
if isinstance(forecasts_raw, dict):
for model_name, model_value in forecasts_raw.items():
if _is_excluded_model_name(str(model_name)):
continue
fv = _sf(model_value)
forecasts[str(model_name)] = fv if fv is not None else None
forecasts = _merge_missing_history_forecasts_from_snapshots(
forecasts,
snapshots,
)
mgm = forecasts.get("MGM")
out.append(
{
"date": day,
"actual": float(act) if act is not None else None,
"deb": float(deb) if deb is not None else None,
"mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts,
"settlement_source": rec.get("settlement_source"),
"settlement_station_code": rec.get("settlement_station_code"),
"settlement_station_label": rec.get("settlement_station_label"),
"truth_version": rec.get("truth_version"),
"updated_by": rec.get("updated_by"),
"truth_updated_at": rec.get("truth_updated_at"),
"actual_peak_time": peak_ref.get("actual_peak_time"),
"deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"),
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
"deb_at_peak_minus_12h_error": peak_ref.get("deb_at_peak_minus_12h_error"),
}
)
@router.post("/api/system/priority-warm")
async def system_priority_warm(
request: Request,
background_tasks: BackgroundTasks,
timezone: Optional[str] = None,
):
_assert_entitlement(request)
batches = _select_priority_city_batches(timezone)
primary = list(batches.get("primary") or [])
secondary = list(batches.get("secondary") or [])
return {
"history": out,
"mode": "full" if include_records else "preview",
"has_more": len(all_days) > len(selected_days),
"full_count": len(all_days),
"preview_count": len(out),
"settlement_source": source,
"settlement_source_label": SETTLEMENT_SOURCE_LABELS.get(source, source.upper()),
}
def _runner() -> None:
for city in primary:
try:
_refresh_city_summary_cache(city, force_refresh=False)
_refresh_city_panel_cache(city, force_refresh=False)
_refresh_city_nearby_cache(city, force_refresh=False)
_refresh_city_market_cache(city, force_refresh=False)
except Exception as exc:
logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc)
for city in secondary:
try:
_refresh_city_summary_cache(city, force_refresh=False)
_refresh_city_panel_cache(city, force_refresh=False)
except Exception as exc:
logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc)
return await run_in_threadpool(_build_history_payload)
background_tasks.add_task(_runner)
return {
"ok": True,
"region": batches.get("region"),
"timezone": batches.get("timezone"),
"primary": primary,
"secondary": secondary,
}
@router.get("/api/auth/me")
@@ -1063,10 +1350,21 @@ async def payment_reconcile_latest(request: Request):
@router.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False):
async def city_summary(
request: Request,
background_tasks: BackgroundTasks,
name: str,
force_refresh: bool = False,
):
city = _normalize_city_or_404(name)
data = await run_in_threadpool(_analyze_summary, city, force_refresh)
return await run_in_threadpool(_build_city_summary_payload, data)
if force_refresh:
return await run_in_threadpool(_refresh_city_summary_cache, city, True)
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "summary", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_SUMMARY_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="summary", city=city)
return cached_entry.get("payload") or {}
return await run_in_threadpool(_refresh_city_summary_cache, city, False)
@router.get("/api/city/{name}/detail")
@@ -1091,6 +1389,7 @@ async def city_detail_aggregate(
@router.get("/api/city/{name}/market-scan")
async def city_market_scan(
request: Request,
background_tasks: BackgroundTasks,
name: str,
force_refresh: bool = False,
market_slug: Optional[str] = None,
@@ -1098,7 +1397,16 @@ async def city_market_scan(
):
_assert_entitlement(request)
city = _normalize_city_or_404(name)
data = await run_in_threadpool(_analyze, city, force_refresh, False, "market")
if force_refresh:
data = await run_in_threadpool(_refresh_city_market_cache, city, True)
else:
cached_entry = await run_in_threadpool(_CACHE_DB.get_city_cache, "market", city)
if cached_entry:
if not _city_cache_is_fresh(cached_entry, CITY_MARKET_CACHE_TTL_SEC):
_schedule_cache_refresh(background_tasks, kind="market", city=city)
data = cached_entry.get("payload") or {}
else:
data = await run_in_threadpool(_refresh_city_market_cache, city, False)
return await run_in_threadpool(
_build_city_market_scan_payload,
data,