feat: implement Telegram push utility and add new dashboard components for scan terminal and paywall management
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="scan-table-shell empty">
|
||||
@@ -472,24 +455,6 @@ function ScanTerminalScreen() {
|
||||
>
|
||||
{isEn ? "Decision Cards" : "城市决策卡"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolvedView === "monitor"}
|
||||
className={resolvedView === "monitor" ? "active" : ""}
|
||||
onClick={() => setActiveView("monitor")}
|
||||
>
|
||||
🔥 {isEn ? "Monitor" : "市场监控"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolvedView === "runway"}
|
||||
className={resolvedView === "runway" ? "active" : ""}
|
||||
onClick={() => setActiveView("runway")}
|
||||
>
|
||||
🛬 {isEn ? "Runways" : "跑道观测"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="scan-list-status">
|
||||
{terminalData?.generated_at ? (
|
||||
@@ -539,31 +504,6 @@ function ScanTerminalScreen() {
|
||||
) : (
|
||||
renderMainView()
|
||||
)}
|
||||
{resolvedView === "monitor" && (
|
||||
isPro ? (
|
||||
<MonitorPanel
|
||||
onCityClick={(cityName) => {
|
||||
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
||||
if (matchedRow) {
|
||||
setSelectedRowId(matchedRow.id);
|
||||
store.preloadCityFromRow(matchedRow);
|
||||
}
|
||||
addAiPinnedCity(cityName);
|
||||
setActiveView("analysis");
|
||||
void store.selectCity(cityName);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProFeaturePaywall feature="monitor" />
|
||||
)
|
||||
)}
|
||||
{resolvedView === "runway" && (
|
||||
isPro ? (
|
||||
<RunwayObservationsPanel />
|
||||
) : (
|
||||
<ProFeaturePaywall feature="monitor" />
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof getMonitorRefreshRequest>;
|
||||
|
||||
const CONCURRENCY = 6;
|
||||
const KOREA_RUNWAY_MONITOR_KEYS = new Set<MonitorKey>(["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<string, { en: string; zh: string }> = {
|
||||
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<MonitorKey>(["hong kong", "lau fau shan"]);
|
||||
|
||||
const SOURCE_LABELS: Record<string, { en: string; zh: string }> = {
|
||||
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 (
|
||||
<div className="monitor-skeleton-card" aria-label={label}>
|
||||
<div className="monitor-skeleton-line" style={{ height: 13, width: "42%", marginBottom: 14 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 50, width: "52%", marginBottom: 16 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 11, width: "68%" }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 11, width: "38%", marginTop: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Freshness dot ───────────────────────────────────────────── */
|
||||
function FreshnessDot({ level, title }: { level: Freshness; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`monitor-freshness-dot ${level}`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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<ReadonlySet<string>>(new Set());
|
||||
const cancelledRef = useRef(false);
|
||||
const globalFetchingRef = useRef(false);
|
||||
|
||||
/* Flash state: tracks cities whose temperature just changed */
|
||||
const [flashingKeys, setFlashingKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
|
||||
const prevTempsRef = useRef<Partial<Record<MonitorKey, number>>>({});
|
||||
const flashTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
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<string, unknown> = {};
|
||||
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 (
|
||||
<div className="monitor-panel">
|
||||
{/* Header */}
|
||||
<div className="monitor-header">
|
||||
<div>
|
||||
<h2 className="monitor-title">
|
||||
{t("🔥 Market Monitor", "🔥 市场监控", lang)}
|
||||
</h2>
|
||||
{!allLoaded && (
|
||||
<p className="monitor-load-progress">
|
||||
{isEn
|
||||
? `Loading ${loadedCount} / ${totalCount} cities…`
|
||||
: `正在加载 ${loadedCount} / ${totalCount} 个城市…`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="monitor-controls">
|
||||
{fetchingKeys.size > 0 && (
|
||||
<span className="monitor-syncing-badge">
|
||||
<span className="monitor-loading-dot" />
|
||||
{isEn ? `Syncing ${fetchingKeys.size}` : `同步中 ${fetchingKeys.size}`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`monitor-notify-btn${notify ? "" : " muted"}`}
|
||||
onClick={toggleNotify}
|
||||
title={notify
|
||||
? t("Disable new-high alert", "关闭新高提醒", lang)
|
||||
: t("Enable new-high alert", "开启新高提醒", lang)}
|
||||
>
|
||||
{notify ? "🔔" : "🔕"}
|
||||
</button>
|
||||
{lastRefreshed && (
|
||||
<span className="monitor-last-refreshed" title={isEn ? "Last data refresh" : "上次数据刷新"}>
|
||||
{isEn ? `↻ ${lastRefreshed}` : `↻ ${lastRefreshed}`}
|
||||
</span>
|
||||
)}
|
||||
<span className="monitor-time">{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freshness legend */}
|
||||
<div className="monitor-legend">
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot fresh" />
|
||||
{t("< 20 min", "< 20 分", lang)}
|
||||
</span>
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot aging" />
|
||||
{t("20–45 min", "20–45 分", lang)}
|
||||
</span>
|
||||
<span className="monitor-legend-item">
|
||||
<span className="monitor-freshness-dot stale" />
|
||||
{t("> 45 min", "> 45 分", lang)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card grid — progressive: show skeleton only for cities not yet loaded */}
|
||||
<div className="monitor-grid">
|
||||
{sorted.map(({ key, detail }) => {
|
||||
const isRefreshing = fetchingKeys.has(key);
|
||||
|
||||
/* City not yet loaded at all → skeleton */
|
||||
if (!detail) {
|
||||
return <SkeletonCard key={key} label={key} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
key={key}
|
||||
className={[
|
||||
"monitor-card",
|
||||
newHigh ? "new-high" : "",
|
||||
isRefreshing ? "refreshing" : "",
|
||||
onCityClick ? "clickable" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
role={onCityClick ? "button" : undefined}
|
||||
tabIndex={onCityClick ? 0 : undefined}
|
||||
onClick={onCityClick ? () => 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 && <div className="monitor-refresh-bar" />}
|
||||
|
||||
{/* Card header */}
|
||||
<div className={`monitor-card-head${isFlashing ? " flashed" : ""}`}>
|
||||
<span className="monitor-city-name">{detail.display_name || key}</span>
|
||||
<span className="monitor-airport-name">/ {airportLabel(key, isEn)}</span>
|
||||
<FreshnessDot
|
||||
level={freshness}
|
||||
title={freshnessDotTitle(freshness, age, isEn, freshnessInfo)}
|
||||
/>
|
||||
{newHigh && (
|
||||
<span className="monitor-new-high-badge">
|
||||
{t("◆ New High", "◆新高", lang)}
|
||||
</span>
|
||||
)}
|
||||
<span className="monitor-obs-time">{obs}</span>
|
||||
</div>
|
||||
|
||||
{/* Temperature — runway cities skip the large value (runway surface ≠ air temp) */}
|
||||
<div className="monitor-temp-display">
|
||||
{isRunwayTemp ? (
|
||||
<span className="monitor-temp-runway">
|
||||
{t("Runway Temp", "跑道温度", lang)}
|
||||
</span>
|
||||
) : cur != null ? (
|
||||
<>
|
||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}${isFlashing ? " flashed" : ""}`}>
|
||||
{Number.isInteger(cur) ? cur.toFixed(0) : cur.toFixed(1)}
|
||||
</span>
|
||||
<span className="monitor-temp-unit">{tempSymbol}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-temp-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="monitor-stats">
|
||||
<div className="monitor-high-row">
|
||||
<span className="monitor-stat-label">{t("Today's High", "今日实测高温", lang)}</span>
|
||||
{max != null ? (
|
||||
<>
|
||||
<span className="monitor-high-value">{Number.isInteger(max) ? max.toFixed(0) : max.toFixed(1)}{tempSymbol}</span>
|
||||
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
||||
</div>
|
||||
<div className="monitor-obs-row">
|
||||
<span className="monitor-stat-label">
|
||||
{resolveSourceLabel(detail, key, isEn)}
|
||||
</span>
|
||||
<span className={`monitor-obs-age ${freshness}`}>
|
||||
{age != null ? (
|
||||
age < 60
|
||||
? (isEn ? `${age} min ago` : `${age} 分钟未更新`)
|
||||
: (isEn
|
||||
? `⚠ last ${obs || "?"}`
|
||||
: `⚠ 最后报文 ${obs || "--"}`)
|
||||
) : (
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runway temps */}
|
||||
{showRunwayRows && rwPairs.length > 0 && rwTemps.length > 0 && (
|
||||
<>
|
||||
<div className="monitor-divider" />
|
||||
{rwPairs.map((p, i) => {
|
||||
const temp = rwTemps[i]?.[0];
|
||||
if (temp == null) return null;
|
||||
return (
|
||||
<div key={i} className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{p[0]}/{p[1]}</span>
|
||||
<span className="monitor-rw-temp">{temp.toFixed(1)}°C</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detail?.name === "seoul" || detail?.name === "busan") ? (
|
||||
<AmosRunwayPanel
|
||||
amos={detail?.amos}
|
||||
isEn={isEn}
|
||||
tempSymbol={tempSymbol}
|
||||
airportCurrent={detail?.airport_current ?? null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
|
||||
@@ -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 (
|
||||
<div className="scan-amos-runway-panel">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Airport Observation" : "机场观测"} · {sourceLabel}{staleNote}
|
||||
</div>
|
||||
<div className="scan-amos-runway-grid scan-amos-single">
|
||||
<div className="scan-amos-runway-card">
|
||||
<div className={`scan-amos-runway-temp ${runwayTempClass(airportCurrent.temp)}`}>
|
||||
{airportCurrent.temp != null ? `${airportCurrent.temp.toFixed(1)}${tempSymbol}` : "--"}
|
||||
</div>
|
||||
{airportCurrent.wind_speed_kt != null ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
{isEn ? "Wind " : "风 "}
|
||||
{airportCurrent.wind_dir != null ? `${airportCurrent.wind_dir}° ` : ""}
|
||||
{airportCurrent.wind_speed_kt}kt
|
||||
</div>
|
||||
) : null}
|
||||
{airportCurrent.pressure_hpa != null ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
QNH {airportCurrent.pressure_hpa.toFixed(1)} hPa
|
||||
</div>
|
||||
) : null}
|
||||
{airportCurrent.visibility_mi != null ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
{isEn ? "Vis " : "能见度 "}
|
||||
{(airportCurrent.visibility_mi * 1609).toFixed(0)}m
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="scan-amos-runway-panel">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Runway Observations" : "跑道实测"} · {amos.station_label || amos.icao || ""}
|
||||
<span className="scan-amos-source-tag">
|
||||
{amos.temp_source === "metar"
|
||||
? isEn ? "Official METAR" : "官方 METAR"
|
||||
: isEn ? "Runway median" : "跑道中位数"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-amos-runway-grid">
|
||||
{pairs.map(([rwyA, rwyB], idx) => {
|
||||
const temps = runwayTemps?.[idx];
|
||||
const wind = runwayWinds?.[idx];
|
||||
const vis = runwayVis?.[idx];
|
||||
const rvr = runwayRvr?.[idx];
|
||||
return (
|
||||
<div key={`${rwyA}/${rwyB}`} className="scan-amos-runway-card">
|
||||
<div className="scan-amos-runway-label">
|
||||
{rwyA}/{rwyB}
|
||||
</div>
|
||||
<div className={`scan-amos-runway-temp ${runwayTempClass(temps?.[0])}`}>
|
||||
{temps?.[0] != null ? `${temps[0].toFixed(1)}${tempSymbol}` : "--"}
|
||||
{temps?.[1] != null ? (
|
||||
<small>
|
||||
{isEn ? " Dew " : " 露点 "}
|
||||
{temps[1].toFixed(1)}{tempSymbol}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
{vis != null && vis > 0 ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
{isEn ? "Vis " : "能见度 "}
|
||||
{vis >= 10000 ? (isEn ? "≥10km" : "≥10公里") : `${vis}m`}
|
||||
</div>
|
||||
) : null}
|
||||
{rvr != null && rvr > 0 ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
RVR {rvr >= 2000 ? "≥2000m" : `${rvr}m`}
|
||||
</div>
|
||||
) : null}
|
||||
{wind ? (
|
||||
<div className="scan-amos-runway-detail">
|
||||
{wind[0].toFixed(1)}kt
|
||||
{wind[1] != null ? ` (${wind[1].toFixed(1)}–${wind[2].toFixed(1)})` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<article className="monitor-card">
|
||||
<div className="monitor-card-head">
|
||||
<span className="monitor-city-name">{isEn ? label.en : label.zh}</span>
|
||||
<span className="monitor-airport-name">/ {label.icao}</span>
|
||||
<span className="monitor-obs-time">{obsLocal || "--"}</span>
|
||||
</div>
|
||||
|
||||
<div className="monitor-stats">
|
||||
<div className="monitor-high-row">
|
||||
<span className="monitor-stat-label">{label.sourceLabel}</span>
|
||||
<span className="monitor-high-value">
|
||||
{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
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRunwayData ? (
|
||||
<>
|
||||
<div className="monitor-divider" />
|
||||
{hasPointRows ? (
|
||||
<>
|
||||
<div className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
|
||||
<span className="monitor-rw-temp">TDZ / MID / END</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.runway || `${row.tdz_temp}-${row.end_temp}`}
|
||||
className="monitor-rw-row"
|
||||
>
|
||||
<span className="monitor-rw-label">{row.runway || "--"}</span>
|
||||
<span className="monitor-rw-temp">
|
||||
{formatTemp(row.tdz_temp, tempSymbol)} /{" "}
|
||||
{formatTemp(row.mid_temp, tempSymbol)} /{" "}
|
||||
{formatTemp(row.end_temp, tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
|
||||
<span className="monitor-rw-temp">{isEn ? "Temp / Dew" : "温度 / 露点"}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasPairRows &&
|
||||
pairRows.map((row) => (
|
||||
<div key={row.label} className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{row.label}</span>
|
||||
<span className="monitor-rw-temp">
|
||||
{formatTemp(row.temp, tempSymbol)} / {formatTemp(row.dew, tempSymbol)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="scan-empty-state compact">
|
||||
{isEn
|
||||
? `No ${label.sourceLabel} runway observation loaded yet.`
|
||||
: `暂无 ${label.sourceLabel} 跑道观测。`}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="monitor-panel runway-observations-panel">
|
||||
<div className="monitor-toolbar">
|
||||
<div className="monitor-title">
|
||||
{isEn ? "Runway Observations" : "跑道观测"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="monitor-grid">
|
||||
{cards.map((city) => (
|
||||
<RunwayCityCard
|
||||
key={city.key}
|
||||
detail={city.detail}
|
||||
isEn={isEn}
|
||||
label={city}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>): 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);
|
||||
}
|
||||
+54
@@ -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");
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user