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