Include UTC offsets in city and nearby observation times

This commit is contained in:
2569718930@qq.com
2026-04-19 14:47:18 +08:00
parent 717106eec6
commit 7e2209e928
5 changed files with 97 additions and 3 deletions
@@ -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;
+88 -2
View File
@@ -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)")
+4
View File
@@ -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;
+3
View File
@@ -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": {
+1
View File
@@ -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", ""),