diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 1a1cdf65..50aa93b8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -90,6 +90,27 @@ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1); + --transition: var(--transition-base); + + /* ── Legacy Variable Aliases (bridge for older CSS modules) ── */ + /* These map old shorthand names → current design token names. */ + --accent-cyan: var(--color-accent-primary); + --accent-blue: var(--color-accent-secondary); + --accent-green: var(--color-signal-success); + --bg-primary: var(--color-bg-base); + --bg-secondary: var(--color-bg-raised); + --bg-card: var(--color-bg-card); + --bg-glass: var(--color-bg-card); + --border-glass: var(--color-border-default); + --border-subtle: var(--color-border-subtle); + --text-primary: var(--color-text-primary); + --text-secondary: var(--color-text-secondary); + --text-muted: var(--color-text-muted); + --risk-high: var(--color-risk-high); + --risk-medium: var(--color-risk-medium); + --risk-low: var(--color-risk-low); + --shadow-lg: var(--shadow-elevation-2); + --glass-blur: 10px; /* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */ --background: 223 53% 4%; @@ -139,6 +160,20 @@ --glass-opacity-1: 0.86; --glass-opacity-2: 0.92; --glass-opacity-3: 0.96; + + /* ── Legacy alias overrides for light theme ── */ + --accent-cyan: var(--color-accent-primary); + --accent-blue: var(--color-accent-secondary); + --bg-primary: var(--color-bg-base); + --bg-secondary: var(--color-bg-raised); + --bg-card: var(--color-bg-card); + --bg-glass: var(--color-bg-card); + --border-glass: var(--color-border-default); + --border-subtle: var(--color-border-subtle); + --text-primary: var(--color-text-primary); + --text-secondary: var(--color-text-secondary); + --text-muted: var(--color-text-muted); + --shadow-lg: var(--shadow-elevation-2); } * { diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.module.css b/frontend/components/dashboard/monitoring/MonitorPanel.module.css new file mode 100644 index 00000000..5e8c4558 --- /dev/null +++ b/frontend/components/dashboard/monitoring/MonitorPanel.module.css @@ -0,0 +1,364 @@ +/* ══════════════════════════════════════════════════════════════ + MONITOR PANEL — Market Monitor board + All colours reference design tokens so dark/light theme works. + ══════════════════════════════════════════════════════════════ */ + +.root :global(.monitor-panel) { + background: var(--color-bg-base); + height: 100%; + overflow: auto; + padding: 20px 24px; + color: var(--color-text-primary); +} + +/* ── Header ── */ +.root :global(.monitor-header) { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border-subtle); + flex-wrap: wrap; + gap: 8px; +} + +.root :global(.monitor-title) { + font-size: 20px; + font-weight: 600; + color: var(--color-text-primary); +} + +.root :global(.monitor-controls) { + display: flex; + align-items: center; + gap: 12px; +} + +.root :global(.monitor-loading-indicator) { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-text-muted); +} + +.root :global(.monitor-loading-dot) { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-accent-primary); + animation: monitorPulse 1.2s ease-in-out infinite; +} + +.root :global(.monitor-notify-btn) { + background: none; + border: 1px solid var(--color-border-default); + border-radius: 6px; + padding: 2px 8px; + font-size: 16px; + cursor: pointer; + transition: var(--transition-fast); + color: inherit; +} + +.root :global(.monitor-notify-btn:hover) { + border-color: var(--color-border-hover); + background: var(--color-bg-raised); +} + +.root :global(.monitor-notify-btn.muted) { + opacity: 0.4; +} + +.root :global(.monitor-time) { + font-size: 13px; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Grid ── */ +.root :global(.monitor-grid) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 14px; +} + +/* ── Skeleton card ── */ +.root :global(.monitor-skeleton-card) { + background: var(--color-bg-raised); + border: 1px solid var(--color-border-subtle); + border-radius: 12px; + padding: 20px 24px; + min-height: 148px; + animation: monitorShimmer 1.6s ease-in-out infinite; +} + +.root :global(.monitor-skeleton-line) { + background: var(--color-border-default); + border-radius: 4px; + margin-bottom: 8px; +} + +/* ── City card ── */ +.root :global(.monitor-card) { + background: var(--color-bg-raised); + border: 1px solid var(--color-border-default); + border-radius: 12px; + padding: 20px 24px; + transition: border-color var(--transition-base); +} + +.root :global(.monitor-card.new-high) { + border-color: rgba(124, 58, 237, 0.3); +} + +/* ── Card header ── */ +.root :global(.monitor-card-head) { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + font-size: 15px; + flex-wrap: wrap; +} + +.root :global(.monitor-city-name) { + color: var(--color-text-primary); + font-weight: 700; +} + +.root :global(.monitor-airport-name) { + color: var(--color-text-secondary); +} + +.root :global(.monitor-obs-time) { + margin-left: auto; + color: var(--color-text-muted); + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.root :global(.monitor-new-high-badge) { + font-size: 12px; + padding: 1px 6px; + border-radius: 4px; + background: rgba(124, 58, 237, 0.18); + color: #a78bfa; + font-weight: 600; +} + +/* ── Temperature display ── */ +.root :global(.monitor-temp-display) { + margin: 8px 0 10px; + line-height: 1.15; +} + +.root :global(.monitor-temp-value) { + font-size: 48px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text-primary); +} + +.root :global(.monitor-temp-value.new-high) { + color: #c084fc; +} + +.root :global(.monitor-temp-value.warm) { + color: var(--color-signal-warning); +} + +.root :global(.monitor-temp-unit) { + font-size: 20px; + color: var(--color-text-muted); + margin-left: 3px; +} + +.root :global(.monitor-temp-missing) { + font-size: 30px; + color: var(--color-border-default); +} + +/* ── Stats row ── */ +.root :global(.monitor-stats) { + font-size: 14px; +} + +.root :global(.monitor-high-row) { + display: flex; + align-items: baseline; + gap: 8px; +} + +.root :global(.monitor-stat-label) { + color: var(--color-text-muted); +} + +.root :global(.monitor-high-value) { + color: var(--color-text-secondary); +} + +.root :global(.monitor-high-time) { + font-size: 12px; + color: var(--color-text-muted); + margin-left: 2px; + font-variant-numeric: tabular-nums; +} + +.root :global(.monitor-stat-missing) { + color: var(--color-border-default); +} + +.root :global(.monitor-trend) { + margin-left: auto; + font-size: 18px; + font-weight: 700; +} + +.root :global(.monitor-trend.rising) { + color: var(--color-signal-success); +} + +.root :global(.monitor-trend.falling) { + color: var(--color-accent-primary); +} + +.root :global(.monitor-trend.flat) { + color: var(--color-text-muted); +} + +.root :global(.monitor-obs-row) { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.root :global(.monitor-obs-age) { + color: var(--color-text-muted); + font-size: 13px; +} + +/* ── Runway sub-section ── */ +.root :global(.monitor-divider) { + height: 1px; + background: var(--color-border-subtle); + margin: 10px 0 8px; +} + +.root :global(.monitor-rw-row) { + display: flex; + justify-content: space-between; + font-size: 13px; + margin-bottom: 2px; +} + +.root :global(.monitor-rw-label) { + color: var(--color-text-muted); +} + +.root :global(.monitor-rw-temp) { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +/* ══════════════════════════════════════════════════════════════ + LIGHT THEME OVERRIDES + Scoped to .scan-terminal.light so the panel responds to the + terminal's built-in theme toggle (not html.light). + ══════════════════════════════════════════════════════════════ */ + +.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-time), +.root :global(.scan-terminal.light .monitor-stat-label), +.root :global(.scan-terminal.light .monitor-obs-time), +.root :global(.scan-terminal.light .monitor-obs-age), +.root :global(.scan-terminal.light .monitor-rw-label), +.root :global(.scan-terminal.light .monitor-high-time), +.root :global(.scan-terminal.light .monitor-loading-indicator) { + 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; +} + +/* ── Animations ── */ +@keyframes monitorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +@keyframes monitorShimmer { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index d84be7b9..1b22d267 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -11,13 +11,8 @@ const MONITOR_KEYS = [ "miami", "san francisco", "houston", "dallas", "austin", "seattle", ] as const; -/** Max concurrent city-detail requests while loading the monitor panel. */ const MONITOR_FETCH_CONCURRENCY = 6; - -/** Minimum elapsed time (ms) before triggering another auto-refresh cycle. */ const MONITOR_REFRESH_INTERVAL_MS = 60_000; - -/** How long (ms) fresh data is considered valid — avoids re-fetch on tab switch. */ const MONITOR_FRESHNESS_TTL_MS = 45_000; type MonitorCity = { @@ -25,24 +20,20 @@ type MonitorCity = { detail: CityDetail | undefined; }; -function fmt(v: number | null | undefined): string { - return v != null ? v.toFixed(1) : "--"; -} - -function trendIcon(detail: CityDetail | undefined): { s: string; c: string } { - if (!detail?.airport_current) return { s: "→", c: "flat" }; +function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" { + if (!detail?.airport_current) return "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" }; + if (cur != null && max != null && cur >= max + 0.3) return "rising"; + if (cur != null && max != null && cur < max - 1.0) return "falling"; + return "flat"; +} + +function trendSymbol(t: "rising" | "falling" | "flat"): string { + return t === "rising" ? "↑" : t === "falling" ? "↓" : "→"; } -/** - * Run an array of async tasks with capped concurrency. - * Resolves when every task has settled (success or error). - */ async function runConcurrent( tasks: Array<() => Promise>, concurrency: number, @@ -54,36 +45,37 @@ async function runConcurrent( if (cancelled()) return; const task = queue.shift(); if (!task) break; - try { - await task(); - } catch { - // Ignore individual city failures; the panel shows "--" gracefully. - } + try { await task(); } catch { /* individual city failures show "--" */ } } }); await Promise.allSettled(workers); } -// Module-level timestamp — survives tab switches so we don't re-fetch stale data. let lastRefreshCompletedAt = 0; function SkeletonCard() { 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", +}; + export default function MonitorPanel() { const store = useDashboardStore(); const details = store.cityDetailsByName; @@ -103,12 +95,9 @@ export default function MonitorPanel() { return localStorage.getItem("monitor_notify") !== "off"; }); - /** Fetch all MONITOR_KEYS cities concurrently in batches. */ const refreshAll = useCallback( async (force: boolean) => { if (fetchingRef.current) return; - - // Skip if data is still fresh (e.g. after a tab switch). if ( !force && lastRefreshCompletedAt > 0 && @@ -117,55 +106,49 @@ export default function MonitorPanel() { setInitialLoadDone(true); return; } - 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], ); - // Initial load (non-force, honours freshness TTL) + periodic auto-refresh. useEffect(() => { cancelledRef.current = false; void refreshAll(false); - const t = setInterval(() => { if (!document.hidden) void refreshAll(true); }, MONITOR_REFRESH_INTERVAL_MS); - return () => { cancelledRef.current = true; clearInterval(t); }; }, [refreshAll]); - const cities: MonitorCity[] = useMemo(() => { - return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })); - }, [details]); + const cities: MonitorCity[] = useMemo( + () => MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })), + [details], + ); - // Sort by current 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 sorted = useMemo( + () => + [...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; @@ -176,27 +159,25 @@ export default function MonitorPanel() { } }; - // Check for new highs and fire browser notifications. useEffect(() => { if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return; + 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 }; + 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.`, + body: `${cur}°C · New daily high.`, tag: key, requireInteraction: true, }); @@ -205,67 +186,29 @@ export default function MonitorPanel() { } }, [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", - "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", - }; - return m[key] || ""; - }; - return ( -
-
-

🔥 市场监控

-
+
+
+

🔥 市场监控

+
{!initialLoadDone && ( - - + + 加载中… )} - {time} + {time}
- - -
+
{!initialLoadDone ? MONITOR_KEYS.map((k) => ) : sorted.map((c) => { @@ -276,83 +219,77 @@ export default function MonitorPanel() { 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 || []; + 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 ?? []; return ( -
-
- {c.detail?.display_name || c.key} - / {airportName(c.key)} - {obs} +
+
+ + {c.detail?.display_name || c.key} + + / {AIRPORT_NAMES[c.key]} + {obs} {newHigh && ( - - ◆新高 - + ◆新高 )}
-
+
{cur != null ? ( <> - + {cur.toFixed(1)} - °C + °C ) : ( - -- + -- )}
-
-
- High +
+
+ High {max != null ? ( <> - {max.toFixed(1)}°C - {mtt && {mtt}} + {max.toFixed(1)}°C + {mtt && {mtt}} ) : ( - -- + -- )} - - {tr.s} - + {trendSymbol(tr)}
-
- Obs +
+ Obs {age != null ? ( - {age} min ago + {age} min ago ) : ( - -- + -- )}
{rwPairs.length > 0 && rwTemps.length > 0 && ( -
-
+ <> +
{rwPairs.map((p, i) => { const t = rwTemps[i]?.[0]; if (t == null) return null; return ( -
- {p[0]}/{p[1]} - {t.toFixed(1)}°C +
+ {p[0]}/{p[1]} + {t.toFixed(1)}°C
); })} -
+ )}
); diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts index 4e50af67..40a315a6 100644 --- a/frontend/components/dashboard/scan-root-styles.ts +++ b/frontend/components/dashboard/scan-root-styles.ts @@ -25,6 +25,7 @@ import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css"; import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css"; import scanTerminalShellStyles from "./ScanTerminalShell.module.css"; import scanTerminalStateStyles from "./ScanTerminalState.module.css"; +import monitorPanelStyles from "./monitoring/MonitorPanel.module.css"; export const scanRootClass = clsx( dashboardHomeStyles.root, @@ -48,4 +49,5 @@ export const scanRootClass = clsx( modalChromeStyles.root, futureForecastModalStyles.root, historyModalStyles.root, + monitorPanelStyles.root, );