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",
);
}