diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.module.css b/frontend/components/dashboard/monitoring/MonitorPanel.module.css index 5e8c4558..0b4318fb 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.module.css +++ b/frontend/components/dashboard/monitoring/MonitorPanel.module.css @@ -43,13 +43,34 @@ color: var(--color-text-muted); } +.root :global(.monitor-load-progress) { + margin-top: 3px; + font-size: 12px; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +.root :global(.monitor-syncing-badge) { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--color-accent-primary); + background: rgba(77, 163, 255, 0.08); + border: 1px solid rgba(77, 163, 255, 0.18); + border-radius: 999px; + padding: 2px 8px; + font-variant-numeric: tabular-nums; +} + .root :global(.monitor-loading-dot) { display: inline-block; - width: 8px; - height: 8px; + width: 7px; + height: 7px; border-radius: 50%; background: var(--color-accent-primary); animation: monitorPulse 1.2s ease-in-out infinite; + flex-shrink: 0; } .root :global(.monitor-notify-btn) { @@ -240,7 +261,200 @@ font-size: 13px; } -/* ── Runway sub-section ── */ +/* ── Freshness legend ── */ +.root :global(.monitor-legend) { + display: flex; + gap: 16px; + margin-bottom: 16px; + margin-top: -12px; +} + +.root :global(.monitor-legend-item) { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--color-text-muted); +} + +/* ── Freshness dot ── */ +.root :global(.monitor-freshness-dot) { + display: inline-block; + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + cursor: help; +} + +.root :global(.monitor-freshness-dot.fresh) { + background: var(--color-signal-success); + box-shadow: 0 0 5px rgba(34, 197, 94, 0.5); +} + +.root :global(.monitor-freshness-dot.aging) { + background: var(--color-signal-warning); + box-shadow: 0 0 5px rgba(245, 158, 11, 0.4); +} + +.root :global(.monitor-freshness-dot.stale) { + background: var(--color-signal-danger); + box-shadow: 0 0 5px rgba(239, 68, 68, 0.4); +} + +.root :global(.monitor-freshness-dot.unknown) { + background: var(--color-text-muted); + opacity: 0.5; +} + +/* ── Obs-age with freshness colour ── */ +.root :global(.monitor-obs-age) { + font-size: 13px; + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); +} + +.root :global(.monitor-obs-age.fresh) { color: var(--color-signal-success); } +.root :global(.monitor-obs-age.aging) { color: var(--color-signal-warning); } +.root :global(.monitor-obs-age.stale) { color: var(--color-signal-danger); } +.root :global(.monitor-obs-age.unknown) { color: var(--color-text-muted); } + +/* ── Refresh progress bar (top edge of card while fetching) ── */ +.root :global(.monitor-refresh-bar) { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + border-radius: 12px 12px 0 0; + background: linear-gradient( + 90deg, + transparent 0%, + var(--color-accent-primary) 50%, + transparent 100% + ); + background-size: 200% 100%; + animation: monitorSweep 1.4s linear infinite; +} + +/* ── Card refreshing state ── */ +.root :global(.monitor-card.refreshing) { + position: relative; + overflow: hidden; +} + +/* Slight dim on stale cards so fresh ones stand out */ +.root :global(.monitor-card:not(.refreshing)) { + position: relative; +} + +/* ══════════════════════════════════════════════════════════════ + LIGHT THEME OVERRIDES + ══════════════════════════════════════════════════════════════ */ + +.root :global(.scan-terminal.light .monitor-panel) { + background: #F7F9FC; + color: #0F172A; +} + +.root :global(.scan-terminal.light .monitor-header) { + border-bottom-color: #E2E8F0; +} + +.root :global(.scan-terminal.light .monitor-title) { + color: #0F172A; +} + +.root :global(.scan-terminal.light .monitor-legend-item) { + color: #64748B; +} + +.root :global(.scan-terminal.light .monitor-time), +.root :global(.scan-terminal.light .monitor-stat-label), +.root :global(.scan-terminal.light .monitor-obs-time), +.root :global(.scan-terminal.light .monitor-rw-label), +.root :global(.scan-terminal.light .monitor-high-time), +.root :global(.scan-terminal.light .monitor-load-progress) { + color: #475569; +} + +.root :global(.scan-terminal.light .monitor-card) { + background: #FFFFFF; + border-color: #E2E8F0; +} + +.root :global(.scan-terminal.light .monitor-card.new-high) { + border-color: rgba(124, 58, 237, 0.24); +} + +.root :global(.scan-terminal.light .monitor-skeleton-card) { + background: #FFFFFF; + border-color: #E2E8F0; +} + +.root :global(.scan-terminal.light .monitor-skeleton-line) { + background: #E2E8F0; +} + +.root :global(.scan-terminal.light .monitor-city-name) { color: #0F172A; } + +.root :global(.scan-terminal.light .monitor-airport-name), +.root :global(.scan-terminal.light .monitor-high-value), +.root :global(.scan-terminal.light .monitor-rw-temp) { + color: #334155; +} + +.root :global(.scan-terminal.light .monitor-temp-value) { color: #0F172A; } + +.root :global(.scan-terminal.light .monitor-temp-unit), +.root :global(.scan-terminal.light .monitor-temp-missing), +.root :global(.scan-terminal.light .monitor-stat-missing) { + color: #94A3B8; +} + +.root :global(.scan-terminal.light .monitor-divider) { background: #E2E8F0; } + +.root :global(.scan-terminal.light .monitor-notify-btn) { + border-color: rgba(148, 163, 184, 0.3); + color: #334155; +} + +.root :global(.scan-terminal.light .monitor-notify-btn:hover) { + border-color: rgba(37, 99, 235, 0.38); + background: #EEF2F7; +} + +.root :global(.scan-terminal.light .monitor-trend.rising) { color: #16a34a; } +.root :global(.scan-terminal.light .monitor-trend.falling) { color: #2563EB; } + +.root :global(.scan-terminal.light .monitor-syncing-badge) { + color: #2563EB; + background: #EFF6FF; + border-color: rgba(37, 99, 235, 0.22); +} + +/* obs-age colours stay semantic in light mode */ +.root :global(.scan-terminal.light .monitor-obs-age.fresh) { color: #16a34a; } +.root :global(.scan-terminal.light .monitor-obs-age.aging) { color: #d97706; } +.root :global(.scan-terminal.light .monitor-obs-age.stale) { color: #dc2626; } +.root :global(.scan-terminal.light .monitor-obs-age.unknown) { color: #64748b; } + +/* ── Animations ── */ +@keyframes monitorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +@keyframes monitorShimmer { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +@keyframes monitorSweep { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + .root :global(.monitor-divider) { height: 1px; background: var(--color-border-subtle); diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index 1b22d267..982898bc 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -2,8 +2,10 @@ import { useMemo, useState, useEffect, useRef, useCallback } from "react"; import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useI18n } from "@/hooks/useI18n"; import type { CityDetail } from "@/lib/dashboard-types"; +/* ── Constants ───────────────────────────────────────────────── */ const MONITOR_KEYS = [ "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", @@ -11,14 +13,35 @@ const MONITOR_KEYS = [ "miami", "san francisco", "houston", "dallas", "austin", "seattle", ] as const; -const MONITOR_FETCH_CONCURRENCY = 6; -const MONITOR_REFRESH_INTERVAL_MS = 60_000; -const MONITOR_FRESHNESS_TTL_MS = 45_000; +type MonitorKey = (typeof MONITOR_KEYS)[number]; -type MonitorCity = { - key: string; - detail: CityDetail | undefined; -}; +const CONCURRENCY = 6; +const REFRESH_INTERVAL_MS = 60_000; + +/* ── Helpers ─────────────────────────────────────────────────── */ +type Lang = { isEn: boolean }; +function t(en: string, zh: string, { isEn }: Lang) { return isEn ? en : zh; } + +type Freshness = "fresh" | "aging" | "stale" | "unknown"; + +function freshnessLevel(ageMin: number | null | undefined): Freshness { + if (ageMin == null) return "unknown"; + if (ageMin < 20) return "fresh"; + if (ageMin < 45) return "aging"; + return "stale"; +} + +function freshnessDotTitle(level: Freshness, ageMin: number | null | undefined, isEn: boolean): string { + const age = ageMin != null ? (isEn ? `${ageMin} min ago` : `${ageMin} 分钟前`) : "--"; + if (isEn) { + return level === "fresh" ? `Fresh · ${age}` : + level === "aging" ? `Aging · ${age}` : + level === "stale" ? `Stale · ${age}` : "Unknown age"; + } + return level === "fresh" ? `数据新鲜 · ${age}` : + level === "aging" ? `数据变旧 · ${age}` : + level === "stale" ? `数据陈旧 · ${age}` : "更新时间未知"; +} function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" { if (!detail?.airport_current) return "flat"; @@ -30,64 +53,84 @@ function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "fla return "flat"; } -function trendSymbol(t: "rising" | "falling" | "flat"): string { - return t === "rising" ? "↑" : t === "falling" ? "↓" : "→"; +function trendSymbol(tr: "rising" | "falling" | "flat") { + return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→"; } -async function runConcurrent( - tasks: Array<() => Promise>, - concurrency: number, - cancelled: () => boolean, -): Promise { - const queue = [...tasks]; - const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { - while (queue.length > 0) { - if (cancelled()) return; - const task = queue.shift(); - if (!task) break; - try { await task(); } catch { /* individual city failures show "--" */ } - } - }); - await Promise.allSettled(workers); +/* ── Airport names ───────────────────────────────────────────── */ +const AIRPORT_NAMES: Record = { + seoul: { en: "Incheon", zh: "仁川" }, + busan: { en: "Gimhae", zh: "金海" }, + tokyo: { en: "Haneda", zh: "羽田" }, + ankara: { en: "Esenboğa", zh: "埃森博阿" }, + helsinki: { en: "Vantaa", zh: "万塔" }, + amsterdam: { en: "Schiphol", zh: "史基浦" }, + istanbul: { en: "Airport", zh: "机场站" }, + paris: { en: "Le Bourget", zh: "勒布尔热" }, + "hong kong": { en: "Observatory", zh: "天文台" }, + "lau fau shan": { en: "Lau Fau Shan",zh: "流浮山" }, + taipei: { en: "Songshan", zh: "松山" }, + "new york": { en: "LaGuardia", zh: "拉瓜迪亚" }, + "los angeles": { en: "LAX", zh: "洛杉矶" }, + chicago: { en: "O'Hare", zh: "奥黑尔" }, + denver: { en: "Buckley", zh: "巴克利" }, + atlanta: { en: "Hartsfield", zh: "哈茨菲尔德" }, + miami: { en: "MIA", zh: "迈阿密" }, + "san francisco": { en: "SFO", zh: "旧金山" }, + houston: { en: "Hobby", zh: "霍比" }, + dallas: { en: "Love Field", zh: "勒芙机场" }, + austin: { en: "Bergstrom", zh: "伯格斯特罗姆" }, + seattle: { en: "SeaTac", zh: "西塔克" }, +}; + +function airportLabel(key: string, isEn: boolean) { + const e = AIRPORT_NAMES[key]; + return e ? (isEn ? e.en : e.zh) : ""; } -let lastRefreshCompletedAt = 0; - -function SkeletonCard() { +/* ── Skeleton Card ───────────────────────────────────────────── */ +function SkeletonCard({ label }: { label: string }) { return ( -
-
-
-
-
+
+
+
+
+
); } -const AIRPORT_NAMES: 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", "new york": "LaGuardia", - "los angeles": "LAX", chicago: "O'Hare", denver: "Buckley", - atlanta: "Hartsfield", miami: "MIA", "san francisco": "SFO", - houston: "Hobby", dallas: "Love Field", austin: "Bergstrom", - seattle: "SeaTac", -}; +/* ── Freshness dot ───────────────────────────────────────────── */ +function FreshnessDot({ level, title }: { level: Freshness; title: string }) { + return ( + + ); +} +/* ── Main component ──────────────────────────────────────────── */ export default function MonitorPanel() { const store = useDashboardStore(); + const { locale } = useI18n(); + const isEn = locale === "en-US"; + const lang: Lang = { isEn }; + const details = store.cityDetailsByName; + const detailsRef = useRef(details); + detailsRef.current = details; + const [time, setTime] = useState(""); - const [initialLoadDone, setInitialLoadDone] = useState(false); + const [fetchingKeys, setFetchingKeys] = useState>(new Set()); const cancelledRef = useRef(false); - const fetchingRef = useRef(false); + const globalFetchingRef = useRef(false); useEffect(() => { setTime(new Date().toLocaleTimeString()); - const t = setInterval(() => setTime(new Date().toLocaleTimeString()), 60_000); - return () => clearInterval(t); + const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000); + return () => clearInterval(timer); }, []); const [notify, setNotify] = useState(() => { @@ -95,60 +138,88 @@ export default function MonitorPanel() { return localStorage.getItem("monitor_notify") !== "off"; }); - const refreshAll = useCallback( - async (force: boolean) => { - if (fetchingRef.current) return; - if ( - !force && - lastRefreshCompletedAt > 0 && - Date.now() - lastRefreshCompletedAt < MONITOR_FRESHNESS_TTL_MS - ) { - setInitialLoadDone(true); - return; + /* Per-city fetch with loading-key tracking */ + const fetchCity = useCallback( + async (key: MonitorKey, force: boolean) => { + setFetchingKeys((prev) => new Set([...prev, key])); + try { + await store.ensureCityDetail(key, force, "panel"); + } catch { + /* individual city errors are shown as "--" in the card */ + } finally { + setFetchingKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); } - fetchingRef.current = true; - const tasks = MONITOR_KEYS.map((k) => () => - store.ensureCityDetail(k, force, "panel"), - ); - await runConcurrent(tasks, MONITOR_FETCH_CONCURRENCY, () => cancelledRef.current); - if (!cancelledRef.current) { - lastRefreshCompletedAt = Date.now(); - setInitialLoadDone(true); - } - fetchingRef.current = false; }, [store.ensureCityDetail], ); + /* Refresh all cities, sorted by staleness (most stale first). */ + const refreshAll = useCallback( + async (force: boolean) => { + if (globalFetchingRef.current) return; + globalFetchingRef.current = true; + + /* Sort keys: cities with no data first, then by obs_age_min descending */ + const sorted = [...MONITOR_KEYS].sort((a, b) => { + const d = detailsRef.current; + const ageA = d[a]?.airport_current?.obs_age_min ?? Infinity; + const ageB = d[b]?.airport_current?.obs_age_min ?? Infinity; + return ageB - ageA; // stale first + }); + + const queue = sorted as MonitorKey[]; + const workers = Array.from({ length: CONCURRENCY }, async () => { + while (queue.length > 0) { + if (cancelledRef.current) return; + const key = queue.shift(); + if (!key) break; + await fetchCity(key, force); + } + }); + await Promise.allSettled(workers); + + globalFetchingRef.current = false; + }, + [fetchCity], + ); + useEffect(() => { cancelledRef.current = false; void refreshAll(false); - const t = setInterval(() => { + const timer = setInterval(() => { if (!document.hidden) void refreshAll(true); - }, MONITOR_REFRESH_INTERVAL_MS); + }, REFRESH_INTERVAL_MS); return () => { cancelledRef.current = true; - clearInterval(t); + clearInterval(timer); }; }, [refreshAll]); - const cities: MonitorCity[] = useMemo( - () => MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })), - [details], - ); - - const sorted = useMemo( - () => - [...cities].sort((a, b) => { + /* City list sorted by current temp descending */ + const sorted = useMemo(() => { + return [...MONITOR_KEYS] + .map((k) => ({ key: k, detail: details[k] })) + .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], + }); + }, [details]); + + /* Summary counts for the header */ + const loadedCount = useMemo( + () => MONITOR_KEYS.filter((k) => details[k] != null).length, + [details], ); + const totalCount = MONITOR_KEYS.length; + const allLoaded = loadedCount === totalCount; const toggleNotify = () => { const next = !notify; @@ -159,6 +230,7 @@ export default function MonitorPanel() { } }; + /* Browser notifications for new highs */ useEffect(() => { if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; const today = new Date().toDateString(); @@ -166,41 +238,56 @@ export default function MonitorPanel() { try { notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}"); } catch {} if (notified._day !== today) notified = { _day: today }; - for (const c of sorted) { - const ac = c.detail?.airport_current; - const cur = ac?.temp ?? c.detail?.current?.temp ?? null; + for (const { key, detail } of sorted) { + 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) { - const key = `${c.key}|${cur}`; - if (!notified[key]) { - notified[key] = true; + const id = `${key}|${cur}`; + if (!notified[id]) { + notified[id] = 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 · New daily high.`, - tag: key, + const name = detail?.display_name || key; + new Notification(isEn ? `🔴 New High — ${name}` : `🔴 新高 — ${name}`, { + body: isEn ? `${cur}°C · New daily high.` : `${cur}°C · 今日新高。`, + tag: id, requireInteraction: true, }); } } } - }, [sorted, notify]); + }, [sorted, notify, isEn]); + /* ── Render ── */ return (
+ {/* Header */}
-

🔥 市场监控

+
+

+ {t("🔥 Market Monitor", "🔥 市场监控", lang)} +

+ {!allLoaded && ( +

+ {isEn + ? `Loading ${loadedCount} / ${totalCount} cities…` + : `正在加载 ${loadedCount} / ${totalCount} 个城市…`} +

+ )} +
- {!initialLoadDone && ( - + {fetchingKeys.size > 0 && ( + - 加载中… + {isEn ? `Syncing ${fetchingKeys.size}` : `同步中 ${fetchingKeys.size}`} )} @@ -208,92 +295,130 @@ export default function MonitorPanel() {
+ {/* Freshness legend */} +
+ + + {t("< 20 min", "< 20 分", lang)} + + + + {t("20–45 min", "20–45 分", lang)} + + + + {t("> 45 min", "> 45 分", lang)} + +
+ + {/* Card grid — progressive: show skeleton only for cities not yet loaded */}
- {!initialLoadDone - ? MONITOR_KEYS.map((k) => ) - : 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 = !newHigh && cur != null && cur >= 30; - const tr = trendClass(c.detail); - const rwPairs = c.detail?.amos?.runway_obs?.runway_pairs ?? []; - const rwTemps = c.detail?.amos?.runway_obs?.temperatures ?? []; + {sorted.map(({ key, detail }) => { + const isRefreshing = fetchingKeys.has(key); - return ( -
-
- - {c.detail?.display_name || c.key} + /* City not yet loaded at all → skeleton */ + if (!detail) { + return ; + } + + const ac = detail.airport_current; + const cur = ac?.temp ?? detail.current?.temp ?? null; + const max = ac?.max_so_far ?? null; + const mtt = ac?.max_temp_time ?? null; + const obs = ac?.obs_time ?? detail.local_time ?? ""; + const age = ac?.obs_age_min ?? null; + const freshness = freshnessLevel(age); + const newHigh = cur != null && max != null && cur >= max + 0.3; + const warm = !newHigh && cur != null && cur >= 30; + const tr = trendClass(detail); + const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? []; + const rwTemps = detail.amos?.runway_obs?.temperatures ?? []; + + return ( +
+ {/* Refresh progress bar */} + {isRefreshing &&
} + + {/* Card header */} +
+ {detail.display_name || key} + / {airportLabel(key, isEn)} + + {newHigh && ( + + {t("◆ New High", "◆新高", lang)} + + )} + {obs} +
+ + {/* Temperature */} +
+ {cur != null ? ( + <> + + {cur.toFixed(1)} - / {AIRPORT_NAMES[c.key]} - {obs} - {newHigh && ( - ◆新高 - )} -
+ °C + + ) : ( + -- + )} +
-
- {cur != null ? ( - <> - - {cur.toFixed(1)} - - °C - - ) : ( - -- - )} -
- -
-
- High - {max != null ? ( - <> - {max.toFixed(1)}°C - {mtt && {mtt}} - - ) : ( - -- - )} - {trendSymbol(tr)} -
-
- Obs - {age != null ? ( - {age} min ago - ) : ( - -- - )} -
-
- - {rwPairs.length > 0 && rwTemps.length > 0 && ( + {/* Stats */} +
+
+ {t("High", "高温", lang)} + {max != null ? ( <> -
- {rwPairs.map((p, i) => { - const t = rwTemps[i]?.[0]; - if (t == null) return null; - return ( -
- {p[0]}/{p[1]} - {t.toFixed(1)}°C -
- ); - })} + {max.toFixed(1)}°C + {mtt && {mtt}} + ) : ( + -- )} + {trendSymbol(tr)}
- ); - })} +
+ {t("Obs", "观测", lang)} + + {age != null + ? (isEn ? `${age} min ago` : `${age} 分钟前`) + : --} + +
+
+ + {/* Runway temps */} + {rwPairs.length > 0 && rwTemps.length > 0 && ( + <> +
+ {rwPairs.map((p, i) => { + const temp = rwTemps[i]?.[0]; + if (temp == null) return null; + return ( +
+ {p[0]}/{p[1]} + {temp.toFixed(1)}°C +
+ ); + })} + + )} +
+ ); + })}
);