Include UTC offsets in city and nearby observation times
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
Reference in New Issue
Block a user