diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index dd45fb3f..de6472fa 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2163,7 +2163,7 @@ align-items: center; gap: 4px; margin-top: 3px; - font-size: 10px; + font-size: 11px; font-weight: 700; color: rgba(203, 213, 225, 0.95); white-space: nowrap; diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index f38761d2..e54e6d00 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -128,6 +128,77 @@ function getMarkerSignature( ].join("|"); } +function formatCityLocalDateTime( + epochMs: number, + utcOffsetSeconds: number, + cityLocalDate?: string | null, + isEnglishUi = false, +) { + if (!Number.isFinite(epochMs) || !Number.isFinite(utcOffsetSeconds)) return ""; + const shifted = new Date(epochMs + utcOffsetSeconds * 1000); + const year = shifted.getUTCFullYear(); + const month = shifted.getUTCMonth() + 1; + const day = shifted.getUTCDate(); + const dateText = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const timeText = `${String(shifted.getUTCHours()).padStart(2, "0")}:${String( + shifted.getUTCMinutes(), + ).padStart(2, "0")}`; + const anchorDate = String(cityLocalDate || "").slice(0, 10); + if (!anchorDate || dateText === anchorDate) return timeText; + + const anchorMs = Date.UTC( + Number(anchorDate.slice(0, 4)), + Number(anchorDate.slice(5, 7)) - 1, + Number(anchorDate.slice(8, 10)), + ); + const stationDateMs = Date.UTC(year, month - 1, day); + if (Number.isFinite(anchorMs) && anchorMs - stationDateMs === 86_400_000) { + return isEnglishUi ? `Yesterday ${timeText}` : `昨日 ${timeText}`; + } + return `${String(month).padStart(2, "0")}/${String(day).padStart(2, "0")} ${timeText}`; +} + +function parseIsoLikeTime(value: string, treatNaiveAsUtc: boolean) { + const raw = String(value || "").trim(); + if (!raw) return null; + const hasDate = raw.includes("T") || /^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(raw); + if (!hasDate) return null; + + const normalized = raw.replace(" ", "T"); + const hasExplicitTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized); + if (!hasExplicitTz && !treatNaiveAsUtc) return null; + const parseTarget = hasExplicitTz || !treatNaiveAsUtc ? normalized : `${normalized}Z`; + const parsed = new Date(parseTarget); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.getTime(); +} + +function getDetailUtcOffsetSeconds(detail: CityDetail) { + const explicit = Number(detail.utc_offset_seconds); + if (Number.isFinite(explicit)) return explicit; + + const localDate = String(detail.local_date || "").slice(0, 10); + const localTime = String(detail.local_time || "").match(/(\d{1,2}):(\d{2})/); + const updatedAt = String(detail.updated_at || "").trim(); + const updatedEpochMs = parseIsoLikeTime(updatedAt, false); + if (localDate && localTime && updatedEpochMs != null) { + const localEpochMs = Date.UTC( + Number(localDate.slice(0, 4)), + Number(localDate.slice(5, 7)) - 1, + Number(localDate.slice(8, 10)), + Number(localTime[1]), + Number(localTime[2]), + ); + const rawOffsetSeconds = (localEpochMs - updatedEpochMs) / 1000; + if (Number.isFinite(rawOffsetSeconds)) { + // Use 15-minute buckets so half-hour/quarter-hour time zones also survive. + const rounded = Math.round(rawOffsetSeconds / 900) * 900; + if (rounded >= -43_200 && rounded <= 50_400) return rounded; + } + } + return null; +} + function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) { const sanitizeWindText = (value?: string | null) => { const text = String(value || "").trim(); @@ -138,10 +209,25 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) { typeof document !== "undefined" && String(document.documentElement.lang || "").toLowerCase().startsWith("en"); const symbol = detail.temp_symbol || "°C"; + const sourceCode = String(station.source_code || station.source_label || "") + .trim() + .toLowerCase(); + const isMgmStation = sourceCode === "mgm" || sourceCode.includes("mgm"); + const utcOffsetSeconds = getDetailUtcOffsetSeconds(detail); const formatObsTime = () => { + const raw = String(station.obs_time || "").trim(); + const epochRaw = Number(station.obs_time_epoch); + const epochMs = Number.isFinite(epochRaw) + ? epochRaw > 1_000_000_000_000 + ? epochRaw + : epochRaw * 1000 + : parseIsoLikeTime(raw, isMgmStation); + if (epochMs != null && Number.isFinite(epochMs) && utcOffsetSeconds != null) { + const localOffsetSeconds = utcOffsetSeconds; + return formatCityLocalDateTime(epochMs, localOffsetSeconds, detail.local_date, isEnglishUi); + } const label = String(station.obs_time_label || "").trim(); if (label) return label.replace(/Z$/i, ""); - const raw = String(station.obs_time || "").trim(); if (!raw) return ""; if (raw.endsWith("Z") || raw.includes("+00:00")) { const parsed = new Date(raw); @@ -194,7 +280,7 @@ function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) { station.icao || "实测 (OBS)"; const label = - String(station.source_code || station.source_label || "").trim().toLowerCase() === "nmc" && + sourceCode === "nmc" && /\(NMC\)$/i.test(String(rawLabel)) && !String(rawLabel).includes("区域实况") ? String(rawLabel).replace(/\s*\(NMC\)$/i, "区域实况 (NMC)") diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index d23acac4..efae154a 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -5,6 +5,7 @@ export interface CityListItem { display_name: string; lat: number; lon: number; + utc_offset_seconds?: number; risk_level: RiskLevel; deb_recent_tier?: RiskLevel; deb_recent_hit_rate?: number | null; @@ -118,6 +119,7 @@ export interface NearbyStation { is_airport_station?: boolean; is_settlement_anchor?: boolean; obs_time?: string | null; + obs_time_epoch?: number | string | null; obs_time_label?: string | null; age_minutes?: number | null; time_delta_vs_anchor_minutes?: number | null; @@ -180,6 +182,7 @@ export interface CitySummary { name: string; display_name?: string | null; icao?: string | null; + utc_offset_seconds?: number | null; local_time?: string | null; temp_symbol?: string | null; current?: { @@ -380,6 +383,7 @@ export interface CityDetail { detail_depth?: "panel" | "market" | "nearby" | "full"; lat: number; lon: number; + utc_offset_seconds?: number; temp_symbol: string; local_time: string; local_date: string; diff --git a/web/analysis_service.py b/web/analysis_service.py index 70e15f1e..3632ddb6 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2263,6 +2263,7 @@ def _analyze( "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), "lat": lat, "lon": lon, + "utc_offset_seconds": int(utc_offset or 0), "temp_symbol": sym, "local_time": local_time_str, "local_date": local_date_str, @@ -2683,6 +2684,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: "name": city, "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), "temp_symbol": sym, + "utc_offset_seconds": int(utc_offset or 0), "local_time": local_time_str, "local_date": local_date_str, "risk": { @@ -2713,6 +2715,7 @@ def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: "name": data.get("name"), "display_name": data.get("display_name"), "icao": data.get("risk", {}).get("icao"), + "utc_offset_seconds": data.get("utc_offset_seconds"), "local_time": data.get("local_time"), "temp_symbol": data.get("temp_symbol"), "current": { diff --git a/web/routes.py b/web/routes.py index 5ee19d06..5f9b67f6 100644 --- a/web/routes.py +++ b/web/routes.py @@ -742,6 +742,7 @@ async def list_cities(request: Request): "display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()), "lat": info["lat"], "lon": info["lon"], + "utc_offset_seconds": info.get("tz", 0), "risk_level": risk.get("risk_level", "low"), "risk_emoji": risk.get("risk_emoji", "🟢"), "airport": risk.get("airport_name", ""),