diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx index 440dada2..aa4e0405 100644 --- a/frontend/components/dashboard/ProFeaturePaywall.tsx +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -14,7 +14,7 @@ const TELEGRAM_GROUP_URL = String( const SUBSCRIPTION_HELP_HREF = "/subscription-help"; type ProFeaturePaywallProps = { - feature: "today" | "history" | "future" | "assistant" | "scan" | "monitor"; + feature: "today" | "history" | "future" | "assistant" | "scan"; onClose?: () => void; }; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 77de813d..6051f9bf 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -12,7 +12,6 @@ import { } from "react"; import styles from "./Dashboard.module.css"; import { scanRootClass } from "./scan-root-styles"; -import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, useDashboardStore, @@ -47,19 +46,6 @@ import { } from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state"; import { useRelativeTime } from "@/hooks/useRelativeTime"; -const MonitorPanel = dynamic( - () => import("@/components/dashboard/monitoring/MonitorPanel"), - { ssr: false }, -); - -const RunwayObservationsPanel = dynamic( - () => - import( - "@/components/dashboard/scan-terminal/RunwayObservationsPanel" - ).then((module) => module.RunwayObservationsPanel), - { ssr: false }, -); - const CityDetailPanel = dynamic( () => import("@/components/dashboard/DetailPanel").then( @@ -374,9 +360,6 @@ function ScanTerminalScreen() { /> ); } - if (resolvedView === "monitor" || resolvedView === "runway") { - return null; // MonitorPanel is rendered below the main view switch - } if (!isPro) { return (
@@ -472,24 +455,6 @@ function ScanTerminalScreen() { > {isEn ? "Decision Cards" : "城市决策卡"} - -
{terminalData?.generated_at ? ( @@ -539,31 +504,6 @@ function ScanTerminalScreen() { ) : ( renderMainView() )} - {resolvedView === "monitor" && ( - isPro ? ( - { - const matchedRow = findRowForCity(timeSortedRows, cityName); - if (matchedRow) { - setSelectedRowId(matchedRow.id); - store.preloadCityFromRow(matchedRow); - } - addAiPinnedCity(cityName); - setActiveView("analysis"); - void store.selectCity(cityName); - }} - /> - ) : ( - - ) - )} - {resolvedView === "runway" && ( - isPro ? ( - - ) : ( - - ) - )} diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.module.css b/frontend/components/dashboard/monitoring/MonitorPanel.module.css deleted file mode 100644 index 97cc768d..00000000 --- a/frontend/components/dashboard/monitoring/MonitorPanel.module.css +++ /dev/null @@ -1,720 +0,0 @@ -/* ══════════════════════════════════════════════════════════════ - 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-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: 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) { - 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); -} - -.root :global(.monitor-card.clickable) { - cursor: pointer; -} -.root :global(.monitor-card.clickable:hover) { - border-color: var(--color-accent, #7c3aed); - transform: translateY(-2px); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); -} -.root :global(.monitor-card.clickable:focus-visible) { - outline: 2px solid var(--color-accent, #7c3aed); - outline-offset: 2px; -} - -/* ── 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); -} - -.root :global(.monitor-temp-runway) { - font-size: 18px; - font-weight: 600; - color: var(--color-text-muted); - letter-spacing: 0.02em; -} - -/* ── 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; -} - -/* ── 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-temp-runway), -.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); - 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-temp-runway), -.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; -} - -@keyframes monitorPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -@keyframes monitorShimmer { - 0%, 100% { opacity: 0.55; } - 50% { opacity: 1; } -} - -/* ── Value-change flash (green pulse on new temperature) ── */ -@keyframes monitorTempFlash { - 0% { color: inherit; text-shadow: none; } - 25% { color: #4ade80; text-shadow: 0 0 12px rgba(74, 222, 128, 0.55); } - 100% { color: inherit; text-shadow: none; } -} - -@keyframes monitorHeadFlash { - 0% { background: transparent; } - 25% { background: rgba(74, 222, 128, 0.10); } - 100% { background: transparent; } -} - -.root :global(.monitor-temp-value.flashed) { - animation: monitorTempFlash 0.9s ease-out; -} - -.root :global(.monitor-card-head.flashed) { - animation: monitorHeadFlash 0.9s ease-out; - border-radius: 6px; -} - -/* ── Last-refreshed timestamp ── */ -.root :global(.monitor-last-refreshed) { - font-size: 12px; - color: var(--color-accent-primary, #4da3ff); - font-variant-numeric: tabular-nums; - opacity: 0.85; - letter-spacing: 0.01em; -} - -/* ══════════════════════════════════════════════════════════════ - MOBILE — max-width 768px - ══════════════════════════════════════════════════════════════ */ -@media (max-width: 768px) { - .root :global(.monitor-panel) { - padding: 12px 16px; - } - - .root :global(.monitor-title) { - font-size: 17px; - } - - .root :global(.monitor-grid) { - grid-template-columns: 1fr; - gap: 10px; - } - - .root :global(.monitor-card) { - padding: 14px 16px; - border-radius: 10px; - } - - .root :global(.monitor-card-head) { - font-size: 14px; - gap: 6px; - margin-bottom: 10px; - } - - .root :global(.monitor-obs-time) { - font-size: 12px; - } - - .root :global(.monitor-high-row) { - font-size: 14px; - } - - .root :global(.monitor-high-value) { - font-size: 20px; - } - - .root :global(.monitor-stat-label) { - font-size: 12px; - } - - .root :global(.monitor-stats) { - gap: 8px; - } - - .root :global(.monitor-rw-row) { - font-size: 12px; - } - - .root :global(.monitor-divider) { - margin: 8px 0 6px; - } - - .root :global(.scan-amos-runway-detail) { - font-size: 11px; - } -} - -@media (max-width: 480px) { - .root :global(.monitor-card) { - padding: 10px 12px; - border-radius: 8px; - } - - .root :global(.monitor-card-head) { - font-size: 13px; - margin-bottom: 8px; - } - - .root :global(.monitor-high-value) { - font-size: 18px; - } - - .root :global(.monitor-rw-row) { - font-size: 11px; - } - - .root :global(.monitor-city-name) { - font-size: 14px; - } - - .root :global(.monitor-obs-time) { - font-size: 11px; - margin-left: 0; - } -} diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx deleted file mode 100644 index 73889220..00000000 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ /dev/null @@ -1,672 +0,0 @@ -"use client"; - -import { useMemo, useState, useEffect, useRef, useCallback } from "react"; -import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; -import { useI18n } from "@/hooks/useI18n"; -import type { CityDetail, ObservationFreshness } from "@/lib/dashboard-types"; -import { - getMonitorFreshnessLevel, - getObservationFreshness, - shouldRefreshMonitorCity, - type MonitorFreshnessLevel, -} from "@/lib/source-freshness"; -import { - getMonitorRefreshRequest, - MONITOR_REFRESH_INTERVAL_MS, - type MonitorRefreshTrigger, -} from "./monitor-refresh-policy"; -import { resolveMonitorTemperature } from "./monitor-temperature"; - -/* ── Constants ───────────────────────────────────────────────── */ -const MONITOR_KEYS = [ - "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", - "istanbul", "paris", "hong kong", "lau fau shan", "taipei", - "new york", "los angeles", "chicago", "denver", "atlanta", - "miami", "san francisco", "houston", "dallas", "austin", "seattle", -] as const; - -type MonitorKey = (typeof MONITOR_KEYS)[number]; -type MonitorRefreshRequest = ReturnType; - -const CONCURRENCY = 6; -const KOREA_RUNWAY_MONITOR_KEYS = new Set(["seoul", "busan"]); - -/* ── Helpers ─────────────────────────────────────────────────── */ -type Lang = { isEn: boolean }; -function t(en: string, zh: string, { isEn }: Lang) { return isEn ? en : zh; } - -type Freshness = MonitorFreshnessLevel; - -function freshnessDotTitle( - level: Freshness, - ageMin: number | null | undefined, - isEn: boolean, - freshness?: ObservationFreshness | null, -): string { - const age = ageMin != null ? (isEn ? `${ageMin} min ago` : `${ageMin} 分钟前`) : "--"; - const source = freshness?.source_label ? `${freshness.source_label} · ` : ""; - const cadence = - freshness?.native_update_interval_sec != null - ? Math.round(freshness.native_update_interval_sec / 60) - : null; - const cadenceText = - cadence != null - ? isEn - ? ` · native cadence ${cadence} min` - : ` · 源端约 ${cadence} 分钟更新` - : ""; - if (isEn) { - return level === "fresh" ? `${source}Fresh · ${age}${cadenceText}` : - level === "aging" ? `${source}Waiting / aging · ${age}${cadenceText}` : - level === "stale" ? `${source}Stale · ${age}${cadenceText}` : `${source}Unknown age${cadenceText}`; - } - return level === "fresh" ? `${source}数据新鲜 · ${age}${cadenceText}` : - level === "aging" ? `${source}等待源端更新 / 数据变旧 · ${age}${cadenceText}` : - level === "stale" ? `${source}数据陈旧 · ${age}${cadenceText}` : `${source}更新时间未知${cadenceText}`; -} - -/* ── Audio alert (Web Audio API, no external file needed) ────── */ -function playNewHighBeep(): void { - try { - const ctx = new AudioContext(); - // Two-tone rising beep: signals a new temperature high - const tones = [ - { freq: 880, start: 0, dur: 0.12, peak: 0.40 }, - { freq: 1100, start: 0.15, dur: 0.18, peak: 0.55 }, - ]; - for (const { freq, start, dur, peak } of tones) { - const osc = ctx.createOscillator(); - const g = ctx.createGain(); - osc.type = "sine"; - osc.frequency.value = freq; - osc.connect(g); - g.connect(ctx.destination); - g.gain.setValueAtTime(0, ctx.currentTime + start); - g.gain.linearRampToValueAtTime(peak, ctx.currentTime + start + 0.03); - g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur); - osc.start(ctx.currentTime + start); - osc.stop(ctx.currentTime + start + dur + 0.01); - } - setTimeout(() => ctx.close(), 600); - } catch { - // AudioContext may be blocked before a user gesture — silently ignore - } -} - -function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" { - const ignoreRunway = key === "seoul" || key === "busan"; - const { source } = resolveMonitorTemperature(detail, { ignoreRunway }); - // Runway surface temp vs air temp comparison is meaningless - if ( - source === "amos_runway_median" || - source === "amos_runway" || - source === "amsc_awos_runway_max" || - source === "amsc_awos_runway" - ) return "flat"; - const cur = resolveMonitorTemperature(detail, { ignoreRunway }).value; - const max = resolveMaxSoFar(detail, key); - 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(tr: "rising" | "falling" | "flat") { - return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→"; -} - -/** - * Resolve today's observed high temperature. - * - * HKO cities (Hong Kong / Lau Fau Shan): - * current.max_so_far from HKO observatory IS the settlement anchor. - * airport_current (METAR) is secondary — prefer HKO first. - * - * All other cities: - * airport_current.max_so_far is the authoritative settlement reference. - */ -function resolveMaxSoFar( - detail: CityDetail | undefined, - key?: string, -): number | null { - // HKO: settlement station takes priority over airport METAR - if (key === "hong kong" || key === "lau fau shan") { - const hko = detail?.current?.max_so_far ?? null; - if (hko != null) return Math.round(hko * 10) / 10; - } - - const v = detail?.airport_current?.max_so_far ?? null; - if (v != null) return Math.round(v * 10) / 10; - - // Non-HKO fallback to current.max_so_far - if (key !== "hong kong" && key !== "lau fau shan") { - const cur = detail?.current?.max_so_far ?? null; - if (cur != null) return Math.round(cur * 10) / 10; - } - - return null; -} - -/* ── 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) : ""; -} - -/** - * Cities whose observation data comes from HKO ground stations, - * NOT ICAO airport METAR. Display "天文台观测 / HKO Obs" for these. - */ -const HKO_OBS_CITIES = new Set(["hong kong", "lau fau shan"]); - -const SOURCE_LABELS: Record = { - amsc_awos_runway_max: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, - amsc_awos_runway: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, - amsc_awos: { en: "AMSC AWOS", zh: "AMSC AWOS" }, - amos_runway_median: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, - amos_runway: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, - amos: { en: "AMOS", zh: "AMOS" }, - madis_hfmetar: { en: "NOAA MADIS", zh: "NOAA MADIS" }, - mgm: { en: "MGM", zh: "MGM" }, - jma_amedas: { en: "JMA AMeDAS", zh: "JMA AMeDAS" }, - fmi: { en: "FMI", zh: "FMI" }, - knmi: { en: "KNMI", zh: "KNMI" }, - sg_mss: { en: "Singapore MSS", zh: "新加坡 MSS" }, - airport_current: { en: "Airport METAR", zh: "机场报文" }, - current: { en: "Obs", zh: "观测" }, -}; - -function resolveSourceLabel( - detail: CityDetail | undefined, - key: MonitorKey, - isEn: boolean, -): string { - // Use airport_primary source label from high-freq pipeline - const apSource = detail?.airport_primary?.source_code; - if (apSource && SOURCE_LABELS[apSource]) { - return isEn ? SOURCE_LABELS[apSource].en : SOURCE_LABELS[apSource].zh; - } - // Use temperature resolution source - const { source } = resolveMonitorTemperature(detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), - }); - if (source && SOURCE_LABELS[source]) { - return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh; - } - if (HKO_OBS_CITIES.has(key)) return isEn ? "HKO Obs" : "天文台观测"; - return isEn ? "Airport METAR" : "机场报文"; -} - -/* ── Skeleton Card ───────────────────────────────────────────── */ -function SkeletonCard({ label }: { label: string }) { - return ( -
-
-
-
-
-
- ); -} - -/* ── Freshness dot ───────────────────────────────────────────── */ -function FreshnessDot({ level, title }: { level: Freshness; title: string }) { - return ( - - ); -} - -/* ── Main component ──────────────────────────────────────────── */ -export default function MonitorPanel({ - onCityClick, -}: { - onCityClick?: (cityName: string) => void; -}) { - const { ensureCityDetail } = useDashboardActions(); - const { locale } = useI18n(); - const isEn = locale === "en-US"; - const lang: Lang = { isEn }; - - const { cityDetailsByName: details } = useCityDetails(); - const detailsRef = useRef(details); - detailsRef.current = details; - const ensureCityDetailRef = useRef(ensureCityDetail); - - useEffect(() => { - ensureCityDetailRef.current = ensureCityDetail; - }, [ensureCityDetail]); - - const [time, setTime] = useState(""); - const [fetchingKeys, setFetchingKeys] = useState>(new Set()); - const cancelledRef = useRef(false); - const globalFetchingRef = useRef(false); - - /* Flash state: tracks cities whose temperature just changed */ - const [flashingKeys, setFlashingKeys] = useState>(new Set()); - const [lastRefreshed, setLastRefreshed] = useState(null); - const prevTempsRef = useRef>>({}); - const flashTimersRef = useRef>>({}); - - useEffect(() => { - setTime(new Date().toLocaleTimeString()); - const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000); - return () => clearInterval(timer); - }, []); - - const [notify, setNotify] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("monitor_notify") !== "off"; - }); - - /* Detect temperature changes → trigger per-city flash + update lastRefreshed */ - useEffect(() => { - const changed: MonitorKey[] = []; - for (const key of MONITOR_KEYS) { - const detail = details[key]; - const cur = resolveMonitorTemperature(detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), - }).value; - const prev = prevTempsRef.current[key]; - if (cur != null && prev != null && cur !== prev) changed.push(key); - if (cur != null) prevTempsRef.current[key] = cur; - } - if (changed.length === 0) return; - - setLastRefreshed( - new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - ); - setFlashingKeys((prev) => new Set([...prev, ...changed])); - for (const key of changed) { - clearTimeout(flashTimersRef.current[key]); - flashTimersRef.current[key] = setTimeout(() => { - setFlashingKeys((prev) => { const n = new Set(prev); n.delete(key); return n; }); - }, 900); - } - }, [details]); - - /* Per-city fetch with loading-key tracking */ - const fetchCity = useCallback( - async (key: MonitorKey, request: MonitorRefreshRequest) => { - setFetchingKeys((prev) => new Set([...prev, key])); - try { - await ensureCityDetailRef.current(key, request.force, request.depth); - } catch { - /* individual city errors are shown as "--" in the card */ - } finally { - setFetchingKeys((prev) => { - const next = new Set(prev); - next.delete(key); - return next; - }); - } - }, - [], - ); - - /* Refresh all cities, sorted by staleness (most stale first). */ - const refreshAll = useCallback( - async (trigger: MonitorRefreshTrigger) => { - if (globalFetchingRef.current) return; - globalFetchingRef.current = true; - const request = getMonitorRefreshRequest(trigger); - - const now = new Date(); - /* Sort keys: cities with no data first, then by source-aware freshness/staleness. */ - const dueKeys = [...MONITOR_KEYS].filter((key) => - shouldRefreshMonitorCity({ - detail: detailsRef.current[key], - now, - trigger, - }), - ); - - const sorted = dueKeys.sort((a, b) => { - const d = detailsRef.current; - const freshA = getObservationFreshness(d[a]); - const freshB = getObservationFreshness(d[b]); - const ageA = - freshA?.age_sec != null - ? freshA.age_sec / 60 - : d[a]?.airport_current?.obs_age_min ?? Infinity; - const ageB = - freshB?.age_sec != null - ? freshB.age_sec / 60 - : d[b]?.airport_current?.obs_age_min ?? Infinity; - return ageB - ageA; // stale first - }); - - const queue = sorted as MonitorKey[]; - if (queue.length === 0) { - globalFetchingRef.current = false; - return; - } - 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, request); - } - }); - await Promise.allSettled(workers); - - globalFetchingRef.current = false; - setLastRefreshed( - new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - ); - }, - [fetchCity], - ); - - useEffect(() => { - cancelledRef.current = false; - void refreshAll("initial"); - const timer = setInterval(() => { - if (!document.hidden) void refreshAll("interval"); - }, MONITOR_REFRESH_INTERVAL_MS); - return () => { - cancelledRef.current = true; - clearInterval(timer); - }; - }, [refreshAll]); - - /* 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 = resolveMonitorTemperature(a.detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(a.key), - }).value; - const tb = resolveMonitorTemperature(b.detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(b.key), - }).value; - if (ta == null && tb == null) return 0; - if (ta == null) return 1; - if (tb == null) return -1; - return tb - ta; - }); - }, [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; - setNotify(next); - localStorage.setItem("monitor_notify", next ? "on" : "off"); - if (next && typeof Notification !== "undefined" && Notification.permission === "default") { - Notification.requestPermission(); - } - }; - - /* Audio alert when current temp exceeds today's observed high by ≥ 0.3 °C */ - useEffect(() => { - if (!notify) return; - const today = new Date().toDateString(); - let alerted: Record = {}; - try { alerted = JSON.parse(localStorage.getItem("monitor_alerted_highs") || "{}"); } catch {} - if (alerted._day !== today) alerted = { _day: today }; - - for (const { key, detail } of sorted) { - const { source, value: cur } = resolveMonitorTemperature(detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), - }); - // Skip runway cities: runway surface temp ≠ air temp - if (source === "amos_runway_median" || source === "amos_runway") continue; - const max = resolveMaxSoFar(detail, key); - if (cur != null && max != null && cur >= max + 0.3) { - // Key: city + rounded temp, so we only beep once per 0.1°C step - const id = `${key}|${(Math.round(cur * 10) / 10).toFixed(1)}`; - if (!alerted[id]) { - alerted[id] = true; - localStorage.setItem("monitor_alerted_highs", JSON.stringify(alerted)); - playNewHighBeep(); - } - } - } - }, [sorted, notify]); - - /* ── Render ── */ - return ( -
- {/* Header */} -
-
-

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

- {!allLoaded && ( -

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

- )} -
-
- {fetchingKeys.size > 0 && ( - - - {isEn ? `Syncing ${fetchingKeys.size}` : `同步中 ${fetchingKeys.size}`} - - )} - - {lastRefreshed && ( - - {isEn ? `↻ ${lastRefreshed}` : `↻ ${lastRefreshed}`} - - )} - {time} -
-
- - {/* 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 */} -
- {sorted.map(({ key, detail }) => { - const isRefreshing = fetchingKeys.has(key); - - /* City not yet loaded at all → skeleton */ - if (!detail) { - return ; - } - - const ac = detail.airport_current; - const tempInfo = resolveMonitorTemperature(detail, { - ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), - }); - const cur = tempInfo.value; - const curSource = tempInfo.source; - const isRunwayTemp = - curSource === "amos_runway_median" || - curSource === "amos_runway" || - curSource === "amsc_awos_runway_max" || - curSource === "amsc_awos_runway"; - const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far - const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null; - const freshnessInfo = getObservationFreshness(detail); - const obs = - freshnessInfo?.observed_at_local ?? - ac?.obs_time ?? - detail.current?.obs_time ?? - detail.local_time ?? - ""; - const age = - freshnessInfo?.age_sec != null - ? Math.round(freshnessInfo.age_sec / 60) - : ac?.obs_age_min ?? null; - const freshness = getMonitorFreshnessLevel(freshnessInfo, age); - const tempSymbol = detail.temp_symbol || "°C"; // °F for US cities - // Runway surface temp vs air temp comparison is meaningless - const newHigh = !isRunwayTemp && cur != null && max != null && cur >= max + 0.3; - const warm = !newHigh && !isRunwayTemp && cur != null && cur >= 30; - const tr = trendClass(detail, key); - const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? []; - const rwTemps = detail.amos?.runway_obs?.temperatures ?? []; - const showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key); - const isFlashing = flashingKeys.has(key); - - return ( -
onCityClick(key) : undefined} - onKeyDown={onCityClick ? (e) => { if (e.key === "Enter" || e.key === " ") onCityClick(key); } : undefined} - title={onCityClick ? (isEn ? `Open ${detail.display_name || key} decision card` : `打开 ${detail.display_name || key} 决策卡`) : undefined} - > - {/* Refresh progress bar */} - {isRefreshing &&
} - - {/* Card header */} -
- {detail.display_name || key} - / {airportLabel(key, isEn)} - - {newHigh && ( - - {t("◆ New High", "◆新高", lang)} - - )} - {obs} -
- - {/* Temperature — runway cities skip the large value (runway surface ≠ air temp) */} -
- {isRunwayTemp ? ( - - {t("Runway Temp", "跑道温度", lang)} - - ) : cur != null ? ( - <> - - {Number.isInteger(cur) ? cur.toFixed(0) : cur.toFixed(1)} - - {tempSymbol} - - ) : ( - -- - )} -
- - {/* Stats */} -
-
- {t("Today's High", "今日实测高温", lang)} - {max != null ? ( - <> - {Number.isInteger(max) ? max.toFixed(0) : max.toFixed(1)}{tempSymbol} - {mtt && {mtt}} - - ) : ( - -- - )} - {trendSymbol(tr)} -
-
- - {resolveSourceLabel(detail, key, isEn)} - - - {age != null ? ( - age < 60 - ? (isEn ? `${age} min ago` : `${age} 分钟未更新`) - : (isEn - ? `⚠ last ${obs || "?"}` - : `⚠ 最后报文 ${obs || "--"}`) - ) : ( - -- - )} - -
-
- - {/* Runway temps */} - {showRunwayRows && 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 -
- ); - })} - - )} -
- ); - })} -
-
- ); -} diff --git a/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts b/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts deleted file mode 100644 index e2071915..00000000 --- a/frontend/components/dashboard/monitoring/monitor-refresh-policy.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const MONITOR_CITY_DETAIL_DEPTH = "panel" as const; -export const MONITOR_REFRESH_INTERVAL_MS = 300_000; - -export type MonitorRefreshTrigger = "initial" | "interval"; - -export function getMonitorRefreshRequest(_trigger: MonitorRefreshTrigger) { - return { - depth: MONITOR_CITY_DETAIL_DEPTH, - force: true, - }; -} diff --git a/frontend/components/dashboard/monitoring/monitor-temperature.ts b/frontend/components/dashboard/monitoring/monitor-temperature.ts deleted file mode 100644 index 24f879ef..00000000 --- a/frontend/components/dashboard/monitoring/monitor-temperature.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { CityDetail } from "@/lib/dashboard-types"; - -export type MonitorTemperatureSource = - | "amsc_awos_runway_max" - | "amsc_awos_runway" - | "amos_runway_median" - | "amos_runway" - | "amos" - | "airport_primary" - | "airport_current" - | "current" - | "missing"; - -export type MonitorTemperature = { - source: MonitorTemperatureSource; - value: number | null; -}; - -function finiteNumber(value: unknown): number | null { - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -function median(values: number[]) { - if (!values.length) return null; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; -} - -export function getAmosRunwayTemperature(detail?: CityDetail | null) { - const runwayTemps = - detail?.amos?.runway_obs?.temperatures || - detail?.amos?.runway_temps || - []; - const values = runwayTemps - .map((pair) => finiteNumber(pair?.[0])) - .filter((value): value is number => value != null); - if (detail?.amos?.source === "amsc_awos") { - if (!values.length) return null; - return { - source: values.length > 1 ? "amsc_awos_runway_max" : "amsc_awos_runway", - value: Math.max(...values), - } satisfies MonitorTemperature; - } - const value = median(values); - if (value == null) return null; - return { - source: values.length > 1 ? "amos_runway_median" : "amos_runway", - value, - } satisfies MonitorTemperature; -} - -export function resolveMonitorTemperature( - detail?: CityDetail | null, - options?: { ignoreRunway?: boolean }, -): MonitorTemperature { - if (!options?.ignoreRunway) { - const runway = getAmosRunwayTemperature(detail); - if (runway) return runway; - } - - const amosTemp = finiteNumber(detail?.amos?.temp ?? detail?.amos?.temp_c); - if ( - !options?.ignoreRunway && - amosTemp != null && - detail?.amos?.temp_source === "runway_median" - ) { - return { source: "amos", value: amosTemp }; - } - - const airportPrimary = finiteNumber(detail?.airport_primary?.temp); - if (airportPrimary != null) return { source: "airport_primary", value: airportPrimary }; - - const airport = finiteNumber(detail?.airport_current?.temp); - if (airport != null) return { source: "airport_current", value: airport }; - - const current = finiteNumber(detail?.current?.temp); - if (current != null) return { source: "current", value: current }; - - return { source: "missing", value: null }; -} diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts index 9a9b4451..919b3a69 100644 --- a/frontend/components/dashboard/scan-root-styles.ts +++ b/frontend/components/dashboard/scan-root-styles.ts @@ -23,7 +23,6 @@ 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, @@ -45,5 +44,4 @@ export const scanRootClass = clsx( detailSectionsStyles.root, modalChromeStyles.root, futureForecastModalStyles.root, - monitorPanelStyles.root, ); diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx index 1619f504..0baa0a46 100644 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx @@ -5,7 +5,6 @@ import type { MouseEvent } from "react"; import { useEffect, useState } from "react"; import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart"; import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel"; -import { AmosRunwayPanel } from "@/components/dashboard/scan-terminal/AmosRunwayPanel"; import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader"; import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard"; import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel"; @@ -388,11 +387,9 @@ export function AiPinnedCityCard({ decisionExpectedHighNumber != null ? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 }) : "--"; - const amosRange = detail?.amos?.runway_temp_range; - const observedLabel = amosRange ? (isEn ? "Runway" : "跑道实况") : undefined; - const currentTempText = amosRange - ? `${amosRange[0].toFixed(1)}~${amosRange[1].toFixed(1)}${tempSymbol}` - : currentTempNumber != null + const observedLabel = undefined; + const currentTempText = + currentTempNumber != null ? formatTemperatureValue(currentTempNumber, tempSymbol, { digits: 1 }) : "--"; const debText = @@ -655,15 +652,6 @@ export function AiPinnedCityCard({ />
- {(detail?.name === "seoul" || detail?.name === "busan") ? ( - - ) : null} -
) : !detail ? ( diff --git a/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx b/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx deleted file mode 100644 index 234bab45..00000000 --- a/frontend/components/dashboard/scan-terminal/AmosRunwayPanel.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client"; - -import type { AmosData } from "@/lib/dashboard-types"; - -function runwayTempClass(temp: number | null | undefined): string { - if (temp == null || !Number.isFinite(temp)) return ""; - if (temp >= 40) return "temp-extreme-hot"; - if (temp <= -5) return "temp-extreme-cold"; - return ""; -} - -export function AmosRunwayPanel({ - amos, - isEn, - tempSymbol, - airportCurrent, -}: { - amos?: AmosData | null; - isEn: boolean; - tempSymbol: string; - airportCurrent?: { - temp?: number | null; - wind_speed_kt?: number | null; - wind_dir?: number | null; - pressure_hpa?: number | null; - visibility_mi?: number | null; - raw_metar?: string | null; - source_label?: string | null; - obs_time?: string | null; - stale_for_today?: boolean | null; - } | null; -}) { - const runwayPairs = amos?.runway_obs?.runway_pairs; - const runwayTemps = amos?.runway_obs?.temperatures ?? amos?.runway_temps; - const runwayWinds = amos?.runway_obs?.wind_speeds; - const runwayVis = amos?.runway_obs?.visibility_mor; - const runwayRvr = amos?.runway_obs?.rvr; - const hasAmosRunway = runwayPairs && runwayPairs.length > 0; - - // Fallback: single-card METAR observation for non-AMOS airports - if (!hasAmosRunway) { - if (airportCurrent?.temp == null && airportCurrent?.wind_speed_kt == null) return null; - const sourceLabel = airportCurrent?.source_label || (isEn ? "METAR" : "机场报文"); - const staleNote = airportCurrent?.stale_for_today - ? isEn ? " (stale)" : "(过旧)" - : ""; - return ( -
-
- {isEn ? "Airport Observation" : "机场观测"} · {sourceLabel}{staleNote} -
-
-
-
- {airportCurrent.temp != null ? `${airportCurrent.temp.toFixed(1)}${tempSymbol}` : "--"} -
- {airportCurrent.wind_speed_kt != null ? ( -
- {isEn ? "Wind " : "风 "} - {airportCurrent.wind_dir != null ? `${airportCurrent.wind_dir}° ` : ""} - {airportCurrent.wind_speed_kt}kt -
- ) : null} - {airportCurrent.pressure_hpa != null ? ( -
- QNH {airportCurrent.pressure_hpa.toFixed(1)} hPa -
- ) : null} - {airportCurrent.visibility_mi != null ? ( -
- {isEn ? "Vis " : "能见度 "} - {(airportCurrent.visibility_mi * 1609).toFixed(0)}m -
- ) : null} -
-
-
- ); - } - - if (!runwayPairs || runwayPairs.length === 0) return null; - - const maxItems = Math.max( - runwayPairs.length, - runwayTemps?.length ?? 0, - runwayWinds?.length ?? 0, - ); - const pairs = runwayPairs.slice(0, maxItems); - - return ( -
-
- {isEn ? "Runway Observations" : "跑道实测"} · {amos.station_label || amos.icao || ""} - - {amos.temp_source === "metar" - ? isEn ? "Official METAR" : "官方 METAR" - : isEn ? "Runway median" : "跑道中位数"} - -
-
- {pairs.map(([rwyA, rwyB], idx) => { - const temps = runwayTemps?.[idx]; - const wind = runwayWinds?.[idx]; - const vis = runwayVis?.[idx]; - const rvr = runwayRvr?.[idx]; - return ( -
-
- {rwyA}/{rwyB} -
-
- {temps?.[0] != null ? `${temps[0].toFixed(1)}${tempSymbol}` : "--"} - {temps?.[1] != null ? ( - - {isEn ? " Dew " : " 露点 "} - {temps[1].toFixed(1)}{tempSymbol} - - ) : null} -
- {vis != null && vis > 0 ? ( -
- {isEn ? "Vis " : "能见度 "} - {vis >= 10000 ? (isEn ? "≥10km" : "≥10公里") : `${vis}m`} -
- ) : null} - {rvr != null && rvr > 0 ? ( -
- RVR {rvr >= 2000 ? "≥2000m" : `${rvr}m`} -
- ) : null} - {wind ? ( -
- {wind[0].toFixed(1)}kt - {wind[1] != null ? ` (${wind[1].toFixed(1)}–${wind[2].toFixed(1)})` : ""} -
- ) : null} -
- ); - })} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx b/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx deleted file mode 100644 index 4c60e9dd..00000000 --- a/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx +++ /dev/null @@ -1,203 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; -import { useI18n } from "@/hooks/useI18n"; -import type { CityDetail } from "@/lib/dashboard-types"; - -const RUNWAY_OBSERVATION_CITIES = [ - { key: "seoul", zh: "首尔", en: "Seoul", icao: "RKSI", sourceLabel: "AMOS" }, - { key: "busan", zh: "釜山", en: "Busan", icao: "RKPK", sourceLabel: "AMOS" }, - { key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA", sourceLabel: "AMSC AWOS" }, - { key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD", sourceLabel: "AMSC AWOS" }, - { key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG", sourceLabel: "AMSC AWOS" }, - { key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ", sourceLabel: "AMSC AWOS" }, - { key: "qingdao", zh: "青岛", en: "Qingdao", icao: "ZSQD", sourceLabel: "AMSC AWOS" }, - { key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU", sourceLabel: "AMSC AWOS" }, - { key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK", sourceLabel: "AMSC AWOS" }, - { key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH", sourceLabel: "AMSC AWOS" }, -] as const; - -function formatTemp(value: number | null | undefined, symbol = "°C") { - return value == null || !Number.isFinite(Number(value)) - ? "-" - : `${Number(value).toFixed(1)}${symbol}`; -} - -function getRunwayRows(detail?: CityDetail | null) { - return detail?.amos?.runway_obs?.point_temperatures ?? []; -} - -function getRunwayPairRows(detail?: CityDetail | null) { - const runway_pairs = detail?.amos?.runway_obs?.runway_pairs ?? []; - const runway_temps = - detail?.amos?.runway_obs?.temperatures ?? - detail?.amos?.runway_temps ?? - []; - return runway_pairs.map(([from, to], index) => ({ - label: `${from}/${to}`, - temp: runway_temps[index]?.[0] ?? null, - dew: runway_temps[index]?.[1] ?? null, - })); -} - -function sourceIsAmsc(detail?: CityDetail | null) { - return detail?.amos?.source === "amsc_awos"; -} - -function RunwayCityCard({ - detail, - isEn, - label, -}: { - detail?: CityDetail | null; - isEn: boolean; - label: (typeof RUNWAY_OBSERVATION_CITIES)[number]; -}) { - const rows = getRunwayRows(detail); - const pairRows = getRunwayPairRows(detail); - const tempSymbol = detail?.temp_symbol || "°C"; - const range = detail?.amos?.runway_temp_range; - const obsLocal = - detail?.amos?.observation_time_local || detail?.amos?.observation_time; - const hasPointRows = sourceIsAmsc(detail) && rows.length > 0; - const hasPairRows = pairRows.some((row) => row.temp != null || row.dew != null); - const hasRunwayData = hasPointRows || hasPairRows; - - return ( -
-
- {isEn ? label.en : label.zh} - / {label.icao} - {obsLocal || "--"} -
- -
-
- {label.sourceLabel} - - {range - ? `${range[0].toFixed(1)}–${range[1].toFixed(1)}${tempSymbol}` - : hasPairRows - ? pairRows - .map((row) => row.temp) - .filter((value): value is number => value != null) - .map((value) => value.toFixed(1)) - .join(" / ") + tempSymbol - : "--"} - -
-
- - {hasRunwayData ? ( - <> -
- {hasPointRows ? ( - <> -
- {isEn ? "Runway" : "跑道"} - TDZ / MID / END -
- {rows.map((row) => ( -
- {row.runway || "--"} - - {formatTemp(row.tdz_temp, tempSymbol)} /{" "} - {formatTemp(row.mid_temp, tempSymbol)} /{" "} - {formatTemp(row.end_temp, tempSymbol)} - -
- ))} - - ) : ( -
- {isEn ? "Runway" : "跑道"} - {isEn ? "Temp / Dew" : "温度 / 露点"} -
- )} - {hasPairRows && - pairRows.map((row) => ( -
- {row.label} - - {formatTemp(row.temp, tempSymbol)} / {formatTemp(row.dew, tempSymbol)} - -
- ))} - - ) : ( -
- {isEn - ? `No ${label.sourceLabel} runway observation loaded yet.` - : `暂无 ${label.sourceLabel} 跑道观测。`} -
- )} -
- ); -} - -export function RunwayObservationsPanel() { - const { locale } = useI18n(); - const isEn = locale === "en-US"; - const { cityDetailsByName } = useCityDetails(); - const { ensureCityDetail } = useDashboardActions(); - const loadAll = useCallback( - async () => { - await Promise.allSettled( - RUNWAY_OBSERVATION_CITIES.map((city) => - ensureCityDetail(city.key, true, "panel"), - ), - ); - }, - [ensureCityDetail], - ); - - const intervalRef = useRef | null>(null); - - useEffect(() => { - void loadAll(); - intervalRef.current = setInterval(() => { - void Promise.allSettled( - RUNWAY_OBSERVATION_CITIES.map((city) => - ensureCityDetail(city.key, true, "panel"), - ), - ); - }, 60_000); - return () => { - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [loadAll, ensureCityDetail]); - - const cards = useMemo( - () => - RUNWAY_OBSERVATION_CITIES.map((city) => ({ - ...city, - detail: cityDetailsByName[city.key], - })), - [cityDetailsByName], - ); - - return ( -
-
-
- {isEn ? "Runway Observations" : "跑道观测"} -
-
- -
- {cards.map((city) => ( - - ))} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx index aef73e21..6b49fb14 100644 --- a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx +++ b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx @@ -14,7 +14,7 @@ import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; import type { Locale } from "@/lib/i18n"; -export type ScanTerminalContentView = "analysis" | "map" | "monitor" | "runway"; +export type ScanTerminalContentView = "analysis" | "map"; type ThemeMode = "dark" | "light"; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts deleted file mode 100644 index 2bc55173..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/monitorRefreshPolicy.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import assert from "node:assert/strict"; -import { - getMonitorRefreshRequest, - MONITOR_CITY_DETAIL_DEPTH, -} from "@/components/dashboard/monitoring/monitor-refresh-policy"; - -export function runTests() { - const initial = getMonitorRefreshRequest("initial"); - assert.equal( - initial.force, - true, - "monitor initial load must force refresh instead of showing 30-minute session cache", - ); - assert.equal(initial.depth, MONITOR_CITY_DETAIL_DEPTH); - - const interval = getMonitorRefreshRequest("interval"); - assert.equal(interval.force, true); - assert.equal(interval.depth, MONITOR_CITY_DETAIL_DEPTH); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts deleted file mode 100644 index 0863a63a..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from "node:assert/strict"; -import { resolveMonitorTemperature } from "@/components/dashboard/monitoring/monitor-temperature"; -import type { CityDetail } from "@/lib/dashboard-types"; - -function detail(extra: Partial): CityDetail { - return { - current: { temp: null }, - display_name: "Busan", - lat: 0, - local_date: "2026-05-14", - local_time: "15:00", - lon: 0, - name: "busan", - risk: { level: "low" }, - temp_symbol: "°C", - ...extra, - } as CityDetail; -} - -export function runTests() { - const busan = detail({ - airport_current: { - obs_time: "15:00", - source_label: "METAR", - temp: 26, - }, - amos: { - runway_obs: { - runway_pairs: [["18L", "36R"]], - temperatures: [[25.2, 18.1]], - }, - source: "amos", - temp: 26, - temp_c: 26, - temp_source: "metar", - }, - }); - const busanTemp = resolveMonitorTemperature(busan); - assert.equal(busanTemp.value, 25.2); - assert.equal(busanTemp.source, "amos_runway"); - - const seoul = detail({ - airport_current: { obs_time: "15:00", temp: 27 }, - amos: { - runway_obs: { - runway_pairs: [["15L", "33R"], ["15R", "33L"]], - temperatures: [[25.2, 19], [24.8, 18.8]], - }, - source: "amos", - temp_source: "metar", - }, - }); - const seoulTemp = resolveMonitorTemperature(seoul); - assert.equal(seoulTemp.value, 25); - assert.equal(seoulTemp.source, "amos_runway_median"); - - const beijing = detail({ - display_name: "Beijing", - name: "beijing", - amos: { - runway_obs: { - runway_pairs: [["18R", "36L"], ["18L", "36R"]], - temperatures: [[20.8, null], [21.0, null]], - }, - source: "amsc_awos", - temp_c: 21, - temp_source: "runway_max", - }, - }); - const beijingTemp = resolveMonitorTemperature(beijing); - assert.equal(beijingTemp.value, 21); - assert.equal(beijingTemp.source, "amsc_awos_runway_max"); - - const metarOnly = detail({ - airport_current: { obs_time: "15:00", temp: 30 }, - current: { temp: 29 } as CityDetail["current"], - }); - assert.equal(resolveMonitorTemperature(metarOnly).value, 30); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts new file mode 100644 index 00000000..3bc7a546 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const shellPartsPath = path.join( + projectRoot, + "components", + "dashboard", + "scan-terminal", + "ScanTerminalShellParts.tsx", + ); + const dashboardPath = path.join( + projectRoot, + "components", + "dashboard", + "ScanTerminalDashboard.tsx", + ); + const runwayPanelPath = path.join( + projectRoot, + "components", + "dashboard", + "scan-terminal", + "RunwayObservationsPanel.tsx", + ); + const monitorPanelPath = path.join( + projectRoot, + "components", + "dashboard", + "monitoring", + "MonitorPanel.tsx", + ); + + const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8"); + const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); + + assert( + !shellPartsSource.includes('"monitor"') && !shellPartsSource.includes('"runway"'), + "scan terminal content views must not include market monitor or runway tabs", + ); + assert( + !dashboardSource.includes('setActiveView("monitor")') && + !dashboardSource.includes('setActiveView("runway")') && + !dashboardSource.includes("市场监控") && + !dashboardSource.includes("跑道观测"), + "dashboard must not expose market monitor or runway observation tabs", + ); + assert(!fs.existsSync(runwayPanelPath), "dedicated runway observation panel must be removed"); + assert(!fs.existsSync(monitorPanelPath), "dedicated market monitor panel must be removed"); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts deleted file mode 100644 index f6c33b6b..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -function assert(condition: unknown, message: string) { - if (!condition) throw new Error(message); -} - -export function runTests() { - const projectRoot = process.cwd(); - const shellPartsPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "ScanTerminalShellParts.tsx", - ); - const dashboardPath = path.join( - projectRoot, - "components", - "dashboard", - "ScanTerminalDashboard.tsx", - ); - const panelPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "RunwayObservationsPanel.tsx", - ); - const monitorPath = path.join( - projectRoot, - "components", - "dashboard", - "monitoring", - "MonitorPanel.tsx", - ); - - const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8"); - const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); - assert( - shellPartsSource.includes('"runway"'), - "scan terminal content view must include a runway tab state", - ); - assert( - dashboardSource.includes("跑道观测") && dashboardSource.includes('setActiveView("runway")'), - "dashboard tabs must expose 跑道观测 next to 市场监控", - ); - assert( - fs.existsSync(panelPath), - "RunwayObservationsPanel.tsx must render the dedicated runway tab body", - ); - const panelSource = fs.readFileSync(panelPath, "utf8"); - assert( - panelSource.includes("AMSC AWOS") && - panelSource.includes("TDZ") && - panelSource.includes("MID") && - panelSource.includes("END"), - "runway tab must identify AMSC AWOS and show TDZ/MID/END point temperatures", - ); - assert( - panelSource.includes('key: "qingdao"') && - panelSource.includes("青岛") && - panelSource.includes("ZSQD"), - "runway tab must include Qingdao / ZSQD AMSC AWOS runway observations", - ); - assert( - panelSource.includes("RKSI") && - panelSource.includes("RKPK") && - panelSource.includes("runway_pairs") && - panelSource.includes("runway_temps"), - "runway tab must also own Seoul/Busan AMOS runway-pair observations", - ); - const monitorSource = fs.readFileSync(monitorPath, "utf8"); - assert( - monitorSource.includes("ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key)") && - monitorSource.includes("showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key)"), - "market monitor must not render Seoul/Busan runway data after it moves to the runway tab", - ); -} diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index d686895c..261d567c 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -489,8 +489,8 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str: # ── high-freq airport push loop ── -HIGH_FREQ_AIRPORT_CITIES = {"seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", "beijing", "shanghai", "guangzhou", "shenzhen", "qingdao", "chengdu", "chongqing", "wuhan"} -HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "shenzhen": "ZGSZ", "qingdao": "ZSQD", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH"} +HIGH_FREQ_AIRPORT_CITIES = {"seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan"} +HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH"} FOCUS_RUNWAY_PAIRS = { "chongqing": {("02L", "20R")}, "shanghai": {("17L", "35R")}, @@ -506,7 +506,7 @@ MARKET_MONITOR_CITIES = [ "istanbul", "paris", "hong kong", "lau fau shan", "taipei", "new york", "los angeles", "chicago", "denver", "atlanta", "miami", "san francisco", "houston", "dallas", "austin", "seattle", - "beijing", "shanghai", "guangzhou", "shenzhen", "qingdao", + "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan", ] @@ -960,7 +960,6 @@ _AIRPORT_PUSH_INTERVAL = { "beijing": 60, "shanghai": 60, "guangzhou": 60, - "shenzhen": 60, "qingdao": 60, "chengdu": 60, "chongqing": 60, diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index 5973cafd..33885416 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -110,3 +110,9 @@ def test_singapore_is_in_telegram_push_city_lists(): assert "singapore" not in MARKET_MONITOR_CITIES assert "singapore" in HIGH_FREQ_AIRPORT_CITIES assert HIGH_FREQ_AIRPORT_ICAO["singapore"] == "WSSS" + + +def test_shenzhen_is_removed_from_telegram_push_city_lists(): + assert "shenzhen" not in MARKET_MONITOR_CITIES + assert "shenzhen" not in HIGH_FREQ_AIRPORT_CITIES + assert "shenzhen" not in HIGH_FREQ_AIRPORT_ICAO