diff --git a/frontend/app/api/m/route.ts b/frontend/app/api/m/route.ts deleted file mode 100644 index f7e42727..00000000 --- a/frontend/app/api/m/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { buildBackendRequestHeaders } from "@/lib/backend-auth"; - -const API_BASE = process.env.POLYWEATHER_API_BASE_URL; - -export async function GET(req: NextRequest) { - if (!API_BASE) { - return NextResponse.json({ error: "API not configured" }, { status: 500 }); - } - const auth = await buildBackendRequestHeaders(req, { includeSupabaseIdentity: false }); - const res = await fetch(`${API_BASE}/m/json`, { headers: auth.headers }); - return NextResponse.json(await res.json()); -} diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index 572bc664..17eafff7 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -1,71 +1,116 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useMemo, useState, useEffect } from "react"; +import { useDashboardStore } from "@/hooks/useDashboardStore"; +import type { CityDetail } from "@/lib/dashboard-types"; -interface RunwayPair { - label: string; - temp: number; +const MONITOR_KEYS = [ + "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", + "istanbul", "paris", "hong kong", "lau fau shan", "taipei", +] as const; + +type MonitorCity = { + key: string; + detail: CityDetail | undefined; +}; + +function fmt(v: number | null | undefined): string { + return v != null ? v.toFixed(1) : "--"; } -interface CitySnapshot { - en_name: string; - airport: string; - obs_time: string; - current_temp: number | null; - max_so_far: number | null; - max_temp_time: string | null; - trend_sym: string; - trend_css: string; - obs_age_min: number | null; - new_high: boolean; - temp_warm: boolean; - runway_pairs?: string; // HTML from backend -} - -function parseRunway(html: string): RunwayPair[] { - const pairs: RunwayPair[] = []; - const re = /runway-label">([^<]+)<.*?runway-temp">([\d.]+)/g; - let m; - while ((m = re.exec(html)) !== null) { - pairs.push({ label: m[1], temp: parseFloat(m[2]) }); - } - return pairs; +function trendIcon(detail: CityDetail | undefined): { s: string; c: string } { + if (!detail?.airport_current) return { s: "→", c: "flat" }; + const ac = detail.airport_current; + const cur = ac.temp ?? detail.current?.temp ?? null; + const max = ac.max_so_far ?? null; + if (cur != null && max != null && cur >= max + 0.3) return { s: "↑", c: "rising" }; + if (cur != null && max != null && cur < max - 1.0) return { s: "↓", c: "falling" }; + return { s: "→", c: "flat" }; } export default function MonitorPanel() { - const [cities, setCities] = useState(null); + const store = useDashboardStore(); + const details = store.cityDetailsByName; const [time, setTime] = useState(""); - const [notify, setNotify] = useState( - () => typeof window !== "undefined" && localStorage.getItem("monitor_notify") !== "off" - ); - - const fetchData = useCallback(async () => { - try { - const res = await fetch("/api/m"); - const data = await res.json(); - setCities(data); - setTime(new Date().toLocaleTimeString()); - } catch {} - }, []); useEffect(() => { - fetchData(); - const t = setInterval(fetchData, 60_000); + setTime(new Date().toLocaleTimeString()); + const t = setInterval(() => setTime(new Date().toLocaleTimeString()), 60_000); return () => clearInterval(t); - }, [fetchData]); + }, []); + + const [notify, setNotify] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("monitor_notify") !== "off"; + }); + + const cities: MonitorCity[] = useMemo(() => { + return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })); + }, [details]); + + // Sort by temp descending + const sorted = useMemo(() => { + return [...cities].sort((a, b) => { + const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null; + const tb = b.detail?.airport_current?.temp ?? b.detail?.current?.temp ?? null; + if (ta == null && tb == null) return 0; + if (ta == null) return 1; + if (tb == null) return -1; + return tb - ta; + }); + }, [cities]); const toggleNotify = () => { const next = !notify; setNotify(next); localStorage.setItem("monitor_notify", next ? "on" : "off"); - if (next && "Notification" in window && Notification.permission === "default") { + if (next && typeof Notification !== "undefined" && Notification.permission === "default") { Notification.requestPermission(); } }; + // Check for new highs and fire notifications + useEffect(() => { + if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; + for (const c of sorted) { + const ac = c.detail?.airport_current; + const cur = ac?.temp ?? c.detail?.current?.temp ?? null; + const max = ac?.max_so_far ?? null; + if (cur != null && max != null && cur >= max + 0.3) { + const key = `${c.key}|${cur}`; + const today = new Date().toDateString(); + let notified: Record = {}; + try { + notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}"); + } catch {} + if (notified._day !== today) notified = { _day: today }; + if (!notified[key]) { + notified[key] = true; + localStorage.setItem("monitor_notified_highs", JSON.stringify(notified)); + const name = c.detail?.display_name || c.key; + new Notification(`🔴 New High — ${name}`, { + body: `${cur}°C\nNew daily high.`, + tag: key, + requireInteraction: true, + }); + } + } + } + }, [sorted, notify]); + + const airportName = (key: string): string => { + const m: Record = { + seoul: "Incheon", busan: "Gimhae", tokyo: "Haneda", + ankara: "Esenboğa", helsinki: "Vantaa", amsterdam: "Schiphol", + istanbul: "Airport", paris: "Le Bourget", + "hong kong": "Observatory", "lau fau shan": "Lau Fau Shan", + taipei: "Songshan", + }; + return m[key] || ""; + }; + return (
- {/* Header */}
{notify ? "🔔" : "🔕"} @@ -86,30 +130,34 @@ export default function MonitorPanel() {
- {/* Card Grid */} - {cities === null ? ( -
- Loading… -
- ) : (
- {cities.map((c) => { - const cn = c.new_high ? " new-high-card" : ""; - const wc = c.temp_warm ? " warm" : ""; - const nv = c.new_high ? " new-high-val" : ""; - const rw = c.runway_pairs ? parseRunway(c.runway_pairs) : []; + {sorted.map((c) => { + const ac = c.detail?.airport_current; + const cur = ac?.temp ?? c.detail?.current?.temp ?? null; + const max = ac?.max_so_far ?? null; + const mtt = ac?.max_temp_time ?? null; + const obs = ac?.obs_time ?? c.detail?.local_time ?? ""; + const age = ac?.obs_age_min ?? null; + const newHigh = cur != null && max != null && cur >= max + 0.3; + const warm = cur != null && cur >= 30; + const tr = trendIcon(c.detail); + const rw = c.detail?.amos?.runway_obs; + const rwPairs = rw?.runway_pairs || []; + const rwTemps = rw?.temperatures || []; return ( -
- {/* Top */} -
- {c.en_name} - / {c.airport} - {c.obs_time} - {c.new_high && ( +
+
+ {c.detail?.display_name || c.key} + / {airportName(c.key)} + {obs} + {newHigh && ( ◆新高 @@ -117,13 +165,12 @@ export default function MonitorPanel() { )}
- {/* Temp */}
- {c.current_temp != null ? ( + {cur != null ? ( <> - {c.current_temp.toFixed(1)} + color: newHigh ? "#c084fc" : warm ? "#f59e0b" : "#e8eaed" }}> + {cur.toFixed(1)} °C @@ -132,63 +179,51 @@ export default function MonitorPanel() { )}
- {/* Meta */}
High - {c.max_so_far != null ? ( + {max != null ? ( <> - {c.max_so_far.toFixed(1)}°C - {c.max_temp_time && ( - {c.max_temp_time} - )} + {max.toFixed(1)}°C + {mtt && {mtt}} ) : ( -- )} - {c.trend_sym} + color: tr.c === "rising" ? "#34d399" : tr.c === "falling" ? "#60a5fa" : "#5a6170" }}> + {tr.s}
Obs - {c.obs_age_min != null ? ( - {c.obs_age_min} min ago + {age != null ? ( + {age} min ago ) : ( -- )}
- {/* Runway */} - {rw.length > 0 && ( + {rwPairs.length > 0 && rwTemps.length > 0 && (
- {rw.map((r, i) => ( -
- {r.label} - {r.temp.toFixed(1)}°C -
- ))} + {rwPairs.map((p, i) => { + const t = rwTemps[i]?.[0]; + if (t == null) return null; + return ( +
+ {p[0]}/{p[1]} + {t.toFixed(1)}°C +
+ ); + })}
)}
); })}
- )}
); } - -const cardStyle: React.CSSProperties = { - background: "#161822", border: "1px solid #1e2130", borderRadius: 12, - padding: "20px 24px", position: "relative", -}; - -const cardTopStyle: React.CSSProperties = { - display: "flex", alignItems: "center", gap: 8, - marginBottom: 14, fontSize: 15, flexWrap: "wrap", -}; diff --git a/web/app.py b/web/app.py index 603c225e..3914a87b 100644 --- a/web/app.py +++ b/web/app.py @@ -22,10 +22,8 @@ from web.analysis_service import ( # noqa: E402 ) from web.core import app # noqa: E402 from web.routes import router # noqa: E402 -from web.monitor_routes import router as monitor_router # noqa: E402 app.include_router(router) -app.include_router(monitor_router) __all__ = [ "app", diff --git a/web/monitor_routes.py b/web/monitor_routes.py deleted file mode 100644 index a6efe7ac..00000000 --- a/web/monitor_routes.py +++ /dev/null @@ -1,133 +0,0 @@ -"""市场监控 — JSON API for frontend React component.""" - -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional - -from fastapi import APIRouter -from loguru import logger - -from web.analysis_service import _analyze - -router = APIRouter() - -_CITIES: List[Dict[str, Any]] = [ - {"key": "seoul", "en_name": "Seoul", "icao": "RKSI", "airport": "Incheon", "tz": 9, "tz_abbr": "KST", "rw": True}, - {"key": "busan", "en_name": "Busan", "icao": "RKPK", "airport": "Gimhae", "tz": 9, "tz_abbr": "KST", "rw": True}, - {"key": "tokyo", "en_name": "Tokyo", "icao": "44166", "airport": "Haneda", "tz": 9, "tz_abbr": "JST", "rw": False}, - {"key": "ankara", "en_name": "Ankara", "icao": "17128", "airport": "Esenboğa", "tz": 3, "tz_abbr": "TRT", "rw": False}, - {"key": "helsinki", "en_name": "Helsinki", "icao": "EFHK", "airport": "Vantaa", "tz": 3, "tz_abbr": "EEST", "rw": False}, - {"key": "amsterdam", "en_name": "Amsterdam", "icao": "EHAM", "airport": "Schiphol", "tz": 2, "tz_abbr": "CEST", "rw": False}, - {"key": "istanbul", "en_name": "Istanbul", "icao": "17058", "airport": "Airport", "tz": 3, "tz_abbr": "TRT", "rw": False}, - {"key": "paris", "en_name": "Paris", "icao": "LFPB", "airport": "Le Bourget", "tz": 2, "tz_abbr": "CEST", "rw": False}, - {"key": "hong kong", "en_name": "Hong Kong", "icao": "HKO", "airport": "Observatory", "tz": 8, "tz_abbr": "HKT", "rw": False}, - {"key": "lau fau shan","en_name": "Lau Fau Shan","icao": "LFS", "airport": "Lau Fau Shan", "tz": 8, "tz_abbr": "HKT", "rw": False}, - {"key": "taipei", "en_name": "Taipei", "icao": "466920", "airport": "Songshan", "tz": 8, "tz_abbr": "TST", "rw": False}, -] - - -def _sf(v: Any) -> Optional[float]: - if v is None: - return None - try: - return round(float(v), 1) - except (ValueError, TypeError): - return None - - -def _trend_info(icao: str) -> tuple: - try: - from src.utils.telegram_push import _check_rising_trend - if _check_rising_trend(icao): - return ("↑", "rising") - except Exception: - pass - try: - from src.database.db_manager import DBManager - obs = DBManager().get_airport_obs_recent(icao, minutes=60) - temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None] - if len(temps) >= 4 and temps[-1] < temps[len(temps) // 2]: - return ("↓", "falling") - except Exception: - pass - return ("→", "flat") - - -def _obs_age(obs_time_str: Optional[str]) -> Optional[int]: - if not obs_time_str: - return None - try: - for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): - try: - dt = datetime.strptime(str(obs_time_str)[:26], fmt) - dt = dt.replace(tzinfo=timezone.utc) - age = (datetime.now(timezone.utc) - dt).total_seconds() - return max(0, int(age // 60)) - except (ValueError, TypeError): - continue - ts = float(obs_time_str) - if ts > 1_000_000_000: - dt = datetime.fromtimestamp(ts, tz=timezone.utc) - age = (datetime.now(timezone.utc) - dt).total_seconds() - return max(0, int(age // 60)) - except (ValueError, TypeError, OSError): - pass - return None - - -def _build_cards() -> list: - cards = [] - for cfg in _CITIES: - try: - cw = _analyze(cfg["key"]) - ac = cw.get("airport_current") or {} - cur = cw.get("current") or {} - ct = _sf(ac.get("temp")) or _sf(cur.get("temp")) - msf = ac.get("max_so_far") - mtt = ac.get("max_temp_time") or "" - obs = ac.get("obs_time") or "" - local_time = cw.get("local_time") or "" - new_high = (ct is not None and msf is not None and ct >= msf + 0.3) - tsym, tcss = _trend_info(cfg["icao"]) - age = _obs_age(obs) - - rw_html = "" - if cfg.get("rw"): - amos = cw.get("amos") or {} - rw_obs = (amos.get("runway_obs") or {}) if amos else {} - pairs = rw_obs.get("runway_pairs") or [] - temps = rw_obs.get("temperatures") or [] - for (r1, r2), (t, _d) in zip(pairs, temps): - if t is not None: - rw_html += f'
{r1}/{r2}{round(float(t),1):.1f}°C
\n' - - cards.append({ - "en_name": cfg["en_name"], "airport": cfg["airport"], - "obs_time": obs or local_time, - "current_temp": ct, "max_so_far": _sf(msf), "max_temp_time": mtt, - "trend_sym": tsym, "trend_css": tcss, "obs_age_min": age, - "new_high": new_high, - "temp_warm": ct is not None and ct >= 30, - "runway_pairs": rw_html, - }) - except Exception: - logger.exception("monitor: failed city {}", cfg["key"]) - - cards.sort(key=lambda c: (c["current_temp"] is not None, c["current_temp"] or -999), reverse=True) - return cards - - -_cache = None -_cache_ts = 0 - - -@router.get("/m/json") -async def monitor_json(): - global _cache, _cache_ts - now = __import__("time").time() - if _cache is not None and now - _cache_ts < 30: - return _cache - _cache = _build_cards() - _cache_ts = now - return _cache