feat: implement Telegram push utility and add new dashboard components for scan terminal and paywall management

This commit is contained in:
2569718930@qq.com
2026-05-17 13:59:40 +08:00
parent a83d9e7649
commit 341c6d0a93
17 changed files with 68 additions and 2093 deletions
@@ -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("2045 min", "2045 分", 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);
}
@@ -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",
);
}
+3 -4
View File
@@ -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,
+6
View File
@@ -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