清理旧 Dashboard 全栈死代码:useDashboardStore、dashboardClient、22个关联组件

删除 6650+ 行死代码:useDashboardStore.tsx(1666)、dashboardClient.ts(576)、CitySidebar/DetailPanel/PanelSections/ProbabilityDistribution 及关联 CSS、9个 opportunity-* 模块、ModelEvidencePanel/AiPinnedCityCard/MobileDecisionCard/AiPinnedForecastView/useAiPinnedCityWorkspace。scan-root-styles 清理 6 个无用 CSS 导入。
This commit is contained in:
2569718930@qq.com
2026-05-25 17:10:54 +08:00
parent 80729274bc
commit f38f0893df
29 changed files with 1 additions and 9953 deletions
@@ -1,378 +0,0 @@
"use client";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { Clock, Search } from "lucide-react";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { CityListItem, DeviationMonitor } from "@/lib/dashboard-types";
type RiskGroupKey = "high" | "medium" | "low" | "other";
const GROUP_STATE_STORAGE_KEY = "polyWeather_sidebar_groups_v1";
const DEFAULT_EXPANDED_GROUPS: Record<RiskGroupKey, boolean> = {
high: true,
medium: true,
low: false,
other: false,
};
function toRiskGroup(level?: string): RiskGroupKey {
if (level === "high" || level === "medium" || level === "low") return level;
return "other";
}
function toPerformanceGroup(city: CityListItem): RiskGroupKey {
return toRiskGroup(city.deb_recent_tier);
}
function normalizeExpandedGroups(
value: unknown,
): Record<RiskGroupKey, boolean> {
if (!value || typeof value !== "object") {
return DEFAULT_EXPANDED_GROUPS;
}
const candidate = value as Partial<Record<RiskGroupKey, unknown>>;
return {
high:
typeof candidate.high === "boolean"
? candidate.high
: DEFAULT_EXPANDED_GROUPS.high,
medium:
typeof candidate.medium === "boolean"
? candidate.medium
: DEFAULT_EXPANDED_GROUPS.medium,
low:
typeof candidate.low === "boolean"
? candidate.low
: DEFAULT_EXPANDED_GROUPS.low,
other:
typeof candidate.other === "boolean"
? candidate.other
: DEFAULT_EXPANDED_GROUPS.other,
};
}
export function CitySidebar() {
const store = useDashboardStore();
const { locale, t } = useI18n();
const selectedCity = store.selectedCity;
const riskOrder = { high: 0, medium: 1, low: 2, other: 3 };
const [expandedGroups, setExpandedGroups] = useState<
Record<RiskGroupKey, boolean>
>(DEFAULT_EXPANDED_GROUPS);
const [searchQuery, setSearchQuery] = useState("");
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const normalizedQuery = searchQuery.trim().toLowerCase();
const sortedCities = useMemo(
() =>
[...store.cities].sort((a, b) => {
const aGroup = toPerformanceGroup(a);
const bGroup = toPerformanceGroup(b);
const aHitRate = Number(a.deb_recent_hit_rate ?? -1);
const bHitRate = Number(b.deb_recent_hit_rate ?? -1);
const aSamples = Number(a.deb_recent_sample_count ?? 0);
const bSamples = Number(b.deb_recent_sample_count ?? 0);
return (
(riskOrder[aGroup] ?? 3) - (riskOrder[bGroup] ?? 3) ||
bHitRate - aHitRate ||
bSamples - aSamples ||
a.display_name.localeCompare(b.display_name)
);
}),
[store.cities],
);
const groupedCities = useMemo(() => {
const groups: Record<RiskGroupKey, CityListItem[]> = {
high: [],
medium: [],
low: [],
other: [],
};
sortedCities.forEach((city) => {
const summary = store.citySummariesByName[city.name];
const detail = store.cityDetailsByName[city.name];
const localizedName = getLocalizedCityName(
city.name,
summary?.display_name || detail?.display_name || city.display_name,
locale,
);
if (normalizedQuery) {
const searchCorpus = [
city.name,
city.display_name,
city.airport,
city.icao,
localizedName,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!searchCorpus.includes(normalizedQuery)) return;
}
groups[toPerformanceGroup(city)].push(city);
});
return groups;
}, [
locale,
normalizedQuery,
sortedCities,
store.cityDetailsByName,
store.citySummariesByName,
]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toPerformanceGroup(selected);
setExpandedGroups((current) =>
current[groupKey] ? current : { ...current, [groupKey]: true },
);
}, [selectedCity, store.cities]);
useEffect(() => {
if (!selectedCity) return;
const selected = store.cities.find((city) => city.name === selectedCity);
if (!selected) return;
const groupKey = toPerformanceGroup(selected);
if (!expandedGroups[groupKey]) return;
const frameId = window.requestAnimationFrame(() => {
cityItemRefs.current[selectedCity]?.scrollIntoView({
block: "center",
inline: "nearest",
behavior: "smooth",
});
});
return () => window.cancelAnimationFrame(frameId);
}, [expandedGroups, selectedCity, store.cities]);
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(GROUP_STATE_STORAGE_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
setExpandedGroups(normalizeExpandedGroups(parsed));
} catch {}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
GROUP_STATE_STORAGE_KEY,
JSON.stringify(expandedGroups),
);
} catch {}
}, [expandedGroups]);
const formatDeviationText = (monitor?: DeviationMonitor | null) => {
if (!monitor?.available) return "";
const label = locale === "en-US" ? monitor.label_en : monitor.label_zh;
const trendLabel =
locale === "en-US" ? monitor.trend_label_en : monitor.trend_label_zh;
if (!label) return "";
return trendLabel ? `${label} · ${trendLabel}` : label;
};
const groupMeta: Array<{ key: RiskGroupKey; label: string }> = [
{ key: "high", label: t("sidebar.group.high") },
{ key: "medium", label: t("sidebar.group.medium") },
{ key: "low", label: t("sidebar.group.low") },
{ key: "other", label: t("sidebar.group.other") },
];
const syncTime = useMemo(
() =>
new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date()),
[locale],
);
return (
<nav className="city-list">
<div className="city-list-header">
<span>{t("sidebar.title")}</span>
<span className="city-count">{store.cities.length}</span>
</div>
<div className="city-search">
<Search size={14} strokeWidth={2} aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
/>
</div>
<div className="city-list-items">
{groupMeta.map((group) => {
const citiesInGroup = groupedCities[group.key];
if (!citiesInGroup.length) return null;
const expanded = expandedGroups[group.key];
return (
<section
key={group.key}
className={clsx("city-group", !expanded && "collapsed")}
>
<button
type="button"
className="city-group-header"
aria-expanded={expanded}
onClick={() =>
setExpandedGroups((current) => ({
...current,
[group.key]: !current[group.key],
}))
}
>
<span className="city-group-title">
<span
className={clsx("city-group-indicator", group.key)}
aria-hidden="true"
/>
{group.label}
</span>
<span className="city-group-meta">
<span className="city-group-count">
{citiesInGroup.length}
</span>
<span
className={clsx("city-group-arrow", expanded && "expanded")}
>
</span>
</span>
</button>
<div className="city-group-items">
{citiesInGroup.map((city) => {
const detail = store.cityDetailsByName[city.name];
const summary = store.citySummariesByName[city.name];
const snapshot = detail || summary;
const localizedCityName = getLocalizedCityName(
city.name,
snapshot?.display_name || city.display_name,
locale,
);
const isActive = store.selectedCity === city.name;
const tempSymbol = snapshot?.temp_symbol || "°C";
const currentTempText =
snapshot?.current?.temp != null
? t("sidebar.currentTemp", {
temp: `${snapshot.current.temp}${tempSymbol}`,
})
: t("common.na");
const deviationText = formatDeviationText(
snapshot?.deviation_monitor,
);
const peakTempText =
detail?.current?.max_so_far != null &&
detail.current.max_temp_time
? t("sidebar.peakTempAt", {
temp: `${detail.current.max_so_far}${tempSymbol}`,
time: detail.current.max_temp_time,
})
: detail?.current?.max_temp_time
? t("sidebar.peakAt", {
time: detail.current.max_temp_time,
})
: "";
const deviationDirection =
snapshot?.deviation_monitor?.direction || "normal";
const deviationSeverity =
snapshot?.deviation_monitor?.severity || "normal";
const secondaryText = deviationText || peakTempText;
const performanceTier = toPerformanceGroup(city);
return (
<button
key={city.name}
ref={(node) => {
cityItemRefs.current[city.name] = node;
}}
type="button"
className={clsx("city-item", isActive && "active")}
onClick={() =>
startTransition(() => {
void store.focusCity(city.name);
})
}
>
<div className="city-item-main">
<span className={clsx("risk-dot", performanceTier)} />
<span className="city-name-text">
{localizedCityName}
</span>
<span
className={clsx(
"city-temp",
snapshot?.current?.temp != null && "loaded",
)}
>
{currentTempText}
</span>
</div>
<div className="city-item-info">
<span className="city-local-time">
{snapshot?.local_time ? (
<>
<Clock
size={10}
strokeWidth={2}
className="city-clock-icon"
/>
{snapshot.local_time}
</>
) : (
""
)}
</span>
<span
className={clsx(
"city-max-info",
deviationText && "city-deviation-info",
deviationText &&
`city-deviation-${deviationDirection}`,
deviationText &&
deviationSeverity === "strong" &&
"strong",
)}
>
{secondaryText}
</span>
</div>
</button>
);
})}
</div>
</section>
);
})}
</div>
<div className="sidebar-footer">
<div>
{locale === "en-US" ? "Data sync" : "数据更新"} {syncTime}
</div>
<div>
{locale === "en-US"
? "Sources: METAR · Open-Meteo · DEB · Market"
: "数据来源:METAR · Open-Meteo · DEB · 市场"}
</div>
</div>
</nav>
);
}
@@ -1,299 +0,0 @@
.root :global(.scan-select) {
border: none;
border-radius: 8px;
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
color: #000;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
}
.root :global(.scan-cta-button:hover:not(:disabled)) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
filter: brightness(1.1);
}
.root :global(.scan-cta-button:active:not(:disabled)) {
transform: translateY(1px);
}
.root :global(.modal-overlay) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(2, 6, 23, 0.75);
backdrop-filter: blur(12px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.root :global(.modal-content) {
background: #16213A;
border: 1px solid var(--border-subtle);
border-radius: 16px;
width: 100%;
max-width: 700px;
max-height: calc(100vh - 48px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.root :global(.modal-header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.root :global(.modal-header h2) {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
min-width: 0;
}
.root :global(.future-modal-title-with-actions) {
display: flex;
align-items: center;
gap: 12px;
}
.root :global(.future-refresh-btn) {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
}
.root :global(.future-refresh-btn:hover) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.1);
}
.root :global(.future-refresh-btn.spinning svg) {
animation: spin 1s linear infinite;
color: var(--accent-cyan);
}
.root :global(.modal-close) {
background: none;
border: none;
font-size: 20px;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
}
.root :global(.modal-close:hover) {
color: var(--accent-red);
}
.root :global(.modal-body) {
padding: 20px;
overflow-y: auto;
}
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
.root :global(.modal-content.large.future-modal .modal-body) {
-ms-overflow-style: none;
scrollbar-width: none;
}
.root
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
width: 0;
height: 0;
}
@media (max-width: 640px) {
.root :global(.modal-content) {
border-radius: 14px;
max-height: calc(100vh - 16px);
}
.root :global(.modal-header) {
padding: 14px 14px 12px;
align-items: flex-start;
gap: 12px;
}
.root :global(.modal-header h2) {
font-size: 15px;
line-height: 1.35;
}
.root :global(.modal-body) {
padding: 14px;
}
}
/* ── Info Button ── */
.root :global(.account-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(34, 211, 238, 0.08);
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-btn:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.62);
color: #f8fbff;
transform: translateY(-1px);
}
.root :global(.account-renew-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-renew-badge:hover) {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.6);
color: #fff6d5;
transform: translateY(-1px);
}
.root :global(.account-renew-badge.expired) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.root :global(.trial-promo-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.28);
background: rgba(34, 211, 238, 0.1);
color: #a5f3fc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.trial-promo-badge:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.45);
color: #ecfeff;
transform: translateY(-1px);
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
color: var(--accent-blue);
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.root :global(.info-btn:hover) {
background: rgba(99, 102, 241, 0.2);
border-color: var(--accent-blue);
transform: translateY(-1px);
}
.root :global(.info-btn.active) {
background: rgba(34, 211, 238, 0.14);
border-color: rgba(34, 211, 238, 0.42);
color: #e0fbff;
}
/* ── Guide Modal Customizations ── */
.root :global(.modal-content.large) {
max-width: 900px;
}
.root :global(.guide-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 4px;
}
.root :global(.guide-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 16px;
transition: var(--transition);
}
.root :global(.guide-card:hover) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border-glass);
}
.root :global(.guide-card h3) {
font-size: 15px;
font-weight: 700;
color: var(--accent-cyan);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.root :global(.guide-card p) {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 0;
}
.root :global(.guide-card b) {
color: var(--text-primary);
}
.root :global(.guide-footer) {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
@keyframes spin { to { transform: rotate(360deg); } }
@@ -1,821 +0,0 @@
/* ── Header ── */
.root :global(.header) {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 16px;
background: rgba(7, 11, 18, 0.94);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-glass);
}
.root :global(.brand) {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.root :global(.brand-mark) {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.22);
background: rgba(8, 15, 28, 0.78);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
0 0 20px rgba(0, 224, 164, 0.12);
}
.root :global(.brand-mark img) {
width: 24px;
height: 24px;
display: block;
object-fit: contain;
}
.root :global(.brand h1) {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(
135deg,
var(--accent-cyan) 0%,
var(--accent-blue) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.root :global(.subtitle) {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.04em;
opacity: 0.8;
}
.root :global(.header-nav) {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
.root :global(.header-nav-link) {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 12px 18px;
border-radius: 10px;
color: rgba(203, 213, 225, 0.82);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: var(--transition);
position: relative;
}
.root :global(.header-nav-link:hover) {
color: #f8fafc;
background: rgba(30, 41, 59, 0.44);
}
.root :global(.header-nav-link.active) {
color: #f8fafc;
background: transparent;
box-shadow: none;
}
.root :global(.header-nav-link.active::after) {
content: "";
position: absolute;
left: 18px;
right: 18px;
bottom: 4px;
height: 2px;
border-radius: 999px;
background: var(--accent-cyan);
}
.root :global(.header-right) {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.root :global(.lang-switch) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
}
.root :global(.lang-btn) {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
transition: var(--transition);
}
.root :global(.lang-btn:hover) {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.12);
}
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(0, 224, 164, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
background: rgba(0, 224, 164, 0.08);
border: 1px solid rgba(0, 224, 164, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--accent-green);
letter-spacing: 1.2px;
text-transform: uppercase;
}
.root :global(.pulse-dot) {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
}
}
.root :global(.refresh-btn) {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: rgba(13, 19, 33, 0.6);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.refresh-btn:hover) {
background: rgba(0, 224, 164, 0.1);
border-color: rgba(0, 224, 164, 0.3);
color: var(--accent-cyan);
}
.root :global(.refresh-btn.spinning) {
animation: spin 1s linear infinite;
}
.root :global(.locale-switch) {
height: 32px;
padding: 3px;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.2);
background: rgba(8, 15, 27, 0.82);
color: rgba(148, 163, 184, 0.86);
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: var(--transition);
}
.root :global(.locale-switch span) {
min-width: 32px;
height: 24px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.root :global(.locale-switch span.active) {
color: #f8fafc;
background: rgba(0, 224, 164, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
}
.root :global(.locale-switch:hover) {
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn) {
min-width: 32px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(115, 137, 161, 0.18);
background: rgba(8, 15, 27, 0.82);
color: rgba(226, 232, 240, 0.8);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
font-size: 12px;
font-weight: 600;
transition: var(--transition);
}
.root :global(.header-utility-btn:hover) {
color: #f8fafc;
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn.active) {
color: #f8fafc;
border-color: rgba(123, 97, 255, 0.34);
}
.root :global(.header-utility-btn.more) {
padding: 0;
width: 32px;
}
/* ── City List Sidebar ── */
.root :global(.city-list) {
position: fixed;
top: calc(var(--header-height) + 12px);
left: 12px;
width: var(--sidebar-width);
max-height: calc(100vh - var(--header-height) - 24px);
z-index: 900;
background: var(--bg-glass);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--border-glass);
border-radius: 18px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.root :global(.city-list-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.root :global(.city-search) {
display: flex;
align-items: center;
gap: 8px;
margin: 0 14px 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(115, 137, 161, 0.14);
background: rgba(6, 12, 22, 0.82);
color: rgba(148, 163, 184, 0.86);
}
.root :global(.city-search input) {
width: 100%;
border: none;
outline: none;
background: transparent;
color: #f8fafc;
font: inherit;
font-size: 13px;
}
.root :global(.city-search input::placeholder) {
color: rgba(148, 163, 184, 0.72);
}
.root :global(.city-count) {
background: rgba(123, 97, 255, 0.2);
color: var(--accent-blue);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid rgba(123, 97, 255, 0.15);
}
.root :global(.city-list-items) {
overflow-y: auto;
flex: 1;
padding: 0 6px 8px;
}
.root :global(.city-list-items::-webkit-scrollbar) {
width: 4px;
}
.root :global(.city-list-items::-webkit-scrollbar-track) {
background: transparent;
}
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
background: var(--border-glass);
border-radius: 2px;
}
.root :global(.sidebar-footer) {
border-top: 1px solid var(--border-subtle);
padding: 10px 12px;
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
line-height: 1.55;
}
.root :global(.city-group) {
border: 1px solid rgba(115, 137, 161, 0.12);
border-radius: 14px;
background: rgba(10, 17, 30, 0.48);
margin-bottom: 8px;
overflow: hidden;
}
.root :global(.city-group:last-child) {
margin-bottom: 0;
}
.root :global(.city-group-header) {
width: 100%;
border: none;
background: rgba(15, 28, 47, 0.56);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
cursor: pointer;
font-family: inherit;
transition: background 150ms ease;
}
.root :global(.city-group-header:hover) {
background: rgba(18, 34, 55, 0.82);
}
.root :global(.city-group-title) {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.root :global(.city-group-indicator) {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.root :global(.city-group-indicator.high) {
background: var(--risk-high);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
}
.root :global(.city-group-indicator.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
}
.root :global(.city-group-indicator.low) {
background: var(--risk-low);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
.root :global(.city-group-indicator.other) {
background: var(--text-muted);
opacity: 0.5;
}
.root :global(.city-group-meta) {
display: inline-flex;
align-items: center;
gap: 8px;
}
.root :global(.city-group-count) {
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(123, 97, 255, 0.26);
color: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.root :global(.city-group-arrow) {
font-size: 11px;
color: var(--text-muted);
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.root :global(.city-group-arrow.expanded) {
transform: rotate(0deg);
}
.root :global(.city-group-items) {
padding: 4px;
}
.root :global(.city-group.collapsed .city-group-items) {
display: none;
}
.root :global(.city-item) {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
}
.root :global(.city-item:hover) {
background: rgba(10, 26, 44, 0.84);
border-color: rgba(0, 224, 164, 0.18);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
}
.root :global(.city-item.active) {
background:
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
rgba(9, 18, 32, 0.92);
border-color: rgba(0, 224, 164, 0.24);
box-shadow:
0 0 20px rgba(0, 224, 164, 0.08),
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
}
.root :global(.city-item-main) {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.root :global(.city-item .city-name-text) {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.root :global(.city-item-info) {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 20px; /* Align with name text, after the dot */
font-size: 10px;
color: var(--text-muted);
}
.root :global(.city-item .city-max-info) {
color: var(--accent-blue);
font-weight: 500;
}
.root :global(.city-item .city-deviation-info) {
font-weight: 600;
}
.root :global(.city-item .city-deviation-cold) {
color: var(--color-accent-primary);
}
.root :global(.city-item .city-deviation-hot) {
color: var(--color-signal-warning);
}
.root :global(.city-item .city-deviation-normal) {
color: #22d3ee;
}
.root :global(.city-item .city-deviation-info.strong) {
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
}
.root :global(.city-item .city-deviation-hot.strong) {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
}
.root :global(.city-item .risk-dot) {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.root :global(.city-item .risk-dot.high) {
background: var(--risk-high);
box-shadow: 0 0 6px var(--risk-high);
}
.root :global(.city-item .risk-dot.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px var(--risk-medium);
}
.root :global(.city-item .risk-dot.low) {
background: var(--risk-low);
box-shadow: 0 0 6px var(--risk-low);
}
.root :global(.city-clock-icon) {
display: inline-block;
vertical-align: -1px;
margin-right: 3px;
opacity: 0.6;
}
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-cyan);
opacity: 0;
transition: opacity 200ms ease;
}
.root :global(.city-item .city-temp.loaded) {
opacity: 1;
}
/* ── Responsive ── */
@media (max-width: 1024px) {
.root {
--panel-width: 460px;
}
.root :global(.hero-value) {
font-size: 52px;
}
}
@media (max-width: 768px) {
.root :global(.header) {
height: auto;
min-height: var(--header-height);
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
}
.root :global(.brand) {
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.root :global(.header-right) {
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.root :global(.lang-switch) {
order: 1;
}
.root :global(.account-btn),
.root :global(.info-btn) {
padding: 6px 10px;
font-size: 12px;
}
.root :global(.live-badge) {
order: 4;
padding: 4px 10px;
white-space: nowrap;
}
.root :global(.refresh-btn) {
order: 5;
}
.root :global(.city-list) {
display: flex;
top: auto;
left: 8px;
right: 8px;
bottom: 8px;
width: auto;
max-height: min(36vh, 320px);
border-radius: 14px;
z-index: 920;
}
.root :global(.detail-panel) {
width: 100%;
}
.root {
--panel-width: 100%;
}
.root :global(.city-list-header) {
padding: 12px 14px;
font-size: 14px;
}
.root :global(.city-list-items) {
padding: 6px;
}
.root :global(.city-group-header) {
padding: 8px 9px;
}
.root :global(.city-item) {
padding: 8px 9px;
}
.root :global(.city-item .city-name-text),
.root :global(.city-item .city-temp) {
font-size: 12px;
}
.root :global(.city-item-info) {
padding-left: 18px;
flex-wrap: wrap;
row-gap: 4px;
}
.root :global(.panel-header) {
padding: 16px 16px 14px;
}
}
@media (max-width: 640px) {
.root :global(.header) {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 8px 10px;
}
.root :global(.brand) {
justify-content: space-between;
width: 100%;
}
.root :global(.subtitle) {
display: none;
}
.root :global(.brand h1) {
font-size: 16px;
}
.root :global(.header-right) {
width: 100%;
justify-content: flex-start;
gap: 6px;
}
.root :global(.lang-switch) {
margin-right: auto;
}
.root :global(.account-btn),
.root :global(.info-btn) {
min-height: 34px;
padding: 6px 9px;
font-size: 11px;
}
.root :global(.account-btn span),
.root :global(.info-btn span) {
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.live-badge) {
padding: 4px 8px;
font-size: 10px;
letter-spacing: 0.6px;
}
.root :global(.refresh-btn) {
width: 34px;
height: 34px;
font-size: 16px;
}
.root :global(.hero-value) {
font-size: 42px;
}
.root :global(.hero-details) {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.root :global(.hero-item .value) {
font-size: 14px;
}
.root :global(.city-list) {
left: 6px;
right: 6px;
bottom: 6px;
max-height: min(40vh, 300px);
}
.root :global(.city-list-header) {
padding: 10px 12px;
}
.root :global(.city-group-title) {
font-size: 11px;
}
.root :global(.city-item-main) {
gap: 6px;
}
.root :global(.city-item-info) {
font-size: 9px;
}
.root :global(.panel-header) {
padding: 14px 14px 12px;
}
.root :global(.city-marker:hover) {
transform: none;
}
.root :global(.marker-bubble) {
min-width: 48px;
min-height: 32px;
padding: 6px 10px;
}
.root :global(.marker-name) {
font-size: 11px;
}
.root :global(.prob-price-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.prob-price-head) {
align-items: flex-start;
flex-direction: column;
}
}
@keyframes spin { to { transform: rotate(360deg); } }
@@ -1,472 +0,0 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import {
useDashboardModal,
useDashboardStore,
useProAccess,
} from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { useRelativeTime } from "@/hooks/useRelativeTime";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import { getCityProfileStats } from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
const DetailMiniTemperatureChart = dynamic(
() =>
import("@/components/dashboard/DetailMiniTemperatureChart").then(
(module) => module.DetailMiniTemperatureChart,
),
{
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
ssr: false,
},
);
export function DetailPanel({
variant = "overlay",
}: {
variant?: "overlay" | "rail";
} = {}) {
const store = useDashboardStore();
const modal = useDashboardModal();
const { proAccess } = useProAccess();
const { locale, t } = useI18n();
const router = useRouter();
const isRail = variant === "rail";
const detail = store.selectedDetail;
const selectedCityItem = useMemo(
() =>
store.selectedCity
? store.cities.find((city) => city.name === store.selectedCity) || null
: null,
[store.cities, store.selectedCity],
);
const selectedSummary = useMemo(
() =>
store.selectedCity
? store.citySummariesByName[store.selectedCity] || null
: null,
[store.citySummariesByName, store.selectedCity],
);
const isPro = proAccess.subscriptionActive;
const isAuthenticated = proAccess.authenticated;
const panelRef = useRef<HTMLElement | null>(null);
const lastDetailFetchedAtRef = useRef<number>(0);
useEffect(() => {
if (detail?.name) {
lastDetailFetchedAtRef.current = Date.now();
}
}, [detail?.name]);
const detailAgeText = useRelativeTime(
lastDetailFetchedAtRef.current
? new Date(lastDetailFetchedAtRef.current).toISOString()
: null,
);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen =
Boolean(modal.futureModalDate);
const isVisible = isRail
? Boolean(store.selectedCity) && !isOverlayOpen
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem,
);
const panelDisplayName =
detail?.display_name ||
selectedSummary?.display_name ||
selectedCityItem?.display_name ||
store.selectedCity ||
"...";
const panelRiskLevel =
detail?.risk?.level ||
selectedSummary?.risk?.level ||
selectedCityItem?.risk_level ||
"low";
const profileStats = useMemo(
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const officialLinks = useMemo(
() => (detail ? getOfficialSourceLinks(detail) : []),
[detail],
);
const marketUrl = useMemo(
() => getTodayPolymarketUrl(detail, locale),
[detail, locale],
);
const basicSettlementLabel = normalizeObservationSourceLabel(
selectedSummary?.current?.settlement_source_label ||
selectedCityItem?.settlement_source_label ||
selectedCityItem?.settlement_source,
locale === "en-US" ? "Settlement source pending" : "结算口径待确认",
);
const basicAirportLabel =
selectedCityItem?.airport ||
selectedSummary?.icao ||
(locale === "en-US" ? "Airport pending" : "机场待确认");
const heroSettlementLabel = normalizeObservationSourceLabel(
detail?.current?.settlement_source_label,
basicSettlementLabel,
);
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
const isSparsePanelDetail = Boolean(
detail &&
(detail.detail_depth !== "full" ||
(detail.forecast?.daily?.length ?? 0) <= 1),
);
const isPanelSyncing = store.loadingState.cityDetail;
const [panelSyncTimedOut, setPanelSyncTimedOut] = useState(false);
const showPanelSyncing = isPanelSyncing && !panelSyncTimedOut;
const isShowingCachedDetailDuringSync = false;
const blurActiveElement = () => {
if (typeof document === "undefined") return;
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
};
const handleTodayAccess = () => {
blurActiveElement();
if (isPro) {
void modal.openTodayModal();
return;
}
trackAppEvent("paywall_feature_clicked", {
entry: "detail_panel",
feature: "today",
city: store.selectedCity,
user_state: isAuthenticated ? "logged_in" : "guest",
});
router.push("/account");
};
useEffect(() => {
if (!isPanelSyncing || !store.selectedCity) {
setPanelSyncTimedOut(false);
return;
}
setPanelSyncTimedOut(false);
const timeoutId = window.setTimeout(() => {
setPanelSyncTimedOut(true);
}, 12_000);
return () => {
window.clearTimeout(timeoutId);
};
}, [isPanelSyncing, store.selectedCity]);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (!isVisible) {
panel.setAttribute("inert", "");
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
}
return;
}
panel.removeAttribute("inert");
}, [isVisible]);
useEffect(() => {
if (!isVisible || !detail) {
setHeavyContentReady(false);
return;
}
let canceled = false;
let timeoutId: number | null = null;
let idleId: number | null = null;
const win = typeof window !== "undefined" ? (window as any) : null;
const markReady = () => {
if (!canceled) {
setHeavyContentReady(true);
}
};
if (win && typeof win.requestIdleCallback === "function") {
idleId = win.requestIdleCallback(markReady, { timeout: 180 });
} else if (typeof window !== "undefined") {
timeoutId = window.setTimeout(markReady, 80);
} else {
setHeavyContentReady(true);
}
return () => {
canceled = true;
if (
win &&
idleId != null &&
typeof win.cancelIdleCallback === "function"
) {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
window.clearTimeout(timeoutId);
}
};
}, [detail, isVisible]);
return (
<aside
ref={panelRef}
className={clsx(
"detail-panel",
isRail && "scan-city-detail-rail",
(isVisible || isRail) && "visible",
)}
>
<div className="panel-header">
{!isRail ? (
<button
type="button"
className="panel-close"
aria-label={t("detail.closeAria")}
onClick={() => {
blurActiveElement();
store.closePanel();
}}
>
×
</button>
) : null}
<div className="panel-title-area">
<div className="panel-title-stack">
<div className="panel-overline">
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
<span className="panel-overline-sep"></span>
<span>{panelDisplayName.toUpperCase()}</span>
</div>
<h2>{panelDisplayName}</h2>
</div>
{showPanelSyncing && (
<div
className="panel-loading-hint"
role="status"
aria-live="polite"
>
<span className="panel-loading-spinner" aria-hidden="true" />
<span>
{locale === "en-US"
? isSparsePanelDetail
? `Completing ${panelDisplayName} cards...`
: `Syncing ${panelDisplayName}...`
: isSparsePanelDetail
? `正在补齐 ${panelDisplayName} 卡片...`
: `正在同步 ${panelDisplayName}...`}
</span>
</div>
)}
{panelSyncTimedOut && (
<div className="panel-loading-hint timed-out" role="alert">
<span>
{locale === "en-US"
? "Sync is taking longer than expected. Data shown may be incomplete."
: "同步时间较长,当前展示的数据可能不完整。"}
</span>
</div>
)}
<div className="panel-meta">
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
</span>
{detailAgeText ? (
<span
className="panel-meta-chip panel-meta-chip-muted"
title={
locale === "en-US"
? "Time since last data refresh"
: "上次数据更新时间"
}
>
{locale === "en-US" ? "Updated " : "更新于 "}
{detailAgeText}
</span>
) : null}
<span className="panel-meta-chip panel-meta-chip-strong">
{heroSettlementLabel}
</span>
<span className="panel-meta-chip panel-meta-chip-muted">
{heroAirportLabel}
</span>
</div>
<div className="panel-actions">
{marketUrl ? (
<a
className="panel-action-button panel-action-button-ghost"
href={marketUrl}
target="_blank"
rel="noreferrer"
title={
locale === "en-US"
? "Open today's Polymarket market"
: "打开今日 Polymarket 题目页"
}
>
{locale === "en-US" ? "Market" : "市场页"}
</a>
) : null}
</div>
</div>
</div>
<div
className={clsx(
"panel-body",
isShowingCachedDetailDuringSync && "is-syncing",
)}
>
{isShowingCachedDetailDuringSync ? (
<div className="panel-sync-blocker" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" />
<span>
{locale === "en-US"
? `Refreshing ${panelDisplayName}. Current cards are paused until the latest data arrives.`
: `正在刷新 ${panelDisplayName},最新数据返回前暂停展示旧卡片。`}
</span>
</div>
) : null}
<div
className={clsx(
isShowingCachedDetailDuringSync && "panel-content-stale",
)}
>
{!hasBasicPanelContent ? (
<section className="detail-summary-shell detail-empty-state">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>
{locale === "en-US"
? "Pick a city to start."
: "先选择一个城市。"}
</h3>
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{showPanelSyncing
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{showPanelSyncing
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Profile" : "城市画像"}
</div>
<h3>{t("detail.profile")}</h3>
</div>
</div>
<div className="detail-grid">
{profileStats.map((item) => (
<article key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</article>
))}
</div>
</section>
{officialLinks.length > 0 ? (
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
</div>
<h3>
{locale === "en-US"
? "Settlement and observation references"
: "结算与观测参考来源"}
</h3>
</div>
</div>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
</p>
<div className="detail-source-list">
{officialLinks.map((link) => (
<a
key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-label">
{link.label}
</span>
</a>
))}
</div>
</section>
) : null}
<section className="detail-structured-section rounded-2xl">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
</div>
</aside>
);
}
@@ -1,233 +0,0 @@
.root :global(.panel-title-stack) {
display: grid;
gap: 8px;
}
.root :global(.panel-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.panel-overline-sep) {
opacity: 0.45;
}
.root :global(.panel-subtitle) {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.root :global(.panel-weather-chip) {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
}
.root :global(.panel-meta-chip) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.root :global(.panel-meta-chip-strong) {
color: var(--text-primary);
background: rgba(34, 211, 238, 0.1);
border-color: rgba(34, 211, 238, 0.2);
}
.root :global(.panel-meta-chip-muted) {
color: var(--text-muted);
}
.root :global(.panel-actions) {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.root :global(.panel-action-button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-decoration: none;
cursor: pointer;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, color 0.18s ease;
}
.root :global(.panel-action-button:hover) {
transform: translateY(-1px);
}
.root :global(.panel-action-button-primary) {
color: #FFFFFF;
background: linear-gradient(135deg, var(--color-accent-primary), #3B82F6);
border-color: rgba(77, 163, 255, 0.34);
}
.root :global(.panel-action-button-secondary) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.root :global(.panel-action-button-ghost) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-summary-shell) {
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.018));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 16px;
box-shadow: 0 18px 42px rgba(2, 6, 23, 0.18);
}
.root :global(.panel-body section.detail-summary-shell) {
padding: 16px;
margin: 18px 0 0;
}
.root :global(.detail-summary-shell-live) {
background: linear-gradient(180deg, rgba(22, 78, 99, 0.18), rgba(15, 23, 42, 0.3));
}
.root :global(.detail-section-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.root :global(.detail-section-kicker) {
display: inline-block;
margin-bottom: 6px;
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.detail-summary-main) {
display: grid;
gap: 14px;
}
.root :global(.detail-summary-hero) {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.root :global(.detail-summary-temp) {
color: var(--text-primary);
font-size: 38px;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.root :global(.detail-summary-supporting) {
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.root :global(.detail-summary-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 14px;
}
.root :global(.detail-card-hero) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.16);
}
.root :global(.detail-card-wide) {
grid-column: span 2;
}
.root :global(.detail-callout-card) {
background: rgba(255, 255, 255, 0.025);
}
.root :global(.detail-value-muted) {
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
.root :global(.detail-structured-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 18px;
padding: 16px 16px 18px;
}
.root :global(.detail-empty-state) {
color: var(--text-muted);
font-size: 13px;
line-height: 1.7;
}
.root :global(.panel-loading-hint) {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(77, 163, 255, 0.06);
border: 1px solid rgba(77, 163, 255, 0.1);
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 600;
}
.root :global(.panel-loading-hint.timed-out) {
background: rgba(245, 158, 11, 0.06);
border-color: rgba(245, 158, 11, 0.16);
color: var(--color-signal-warning);
font-size: 12px;
}
@@ -1,295 +0,0 @@
/* Detail panel content styles. */
.root :global(.detail-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.detail-source-list) {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.root :global(.detail-source-note) {
margin: 8px 0 12px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.root :global(.detail-source-link) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
text-decoration: none;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.root :global(.detail-source-link:hover) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.28);
transform: translateY(-1px);
}
.root :global(.detail-source-kind) {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent-cyan);
text-transform: uppercase;
flex-shrink: 0;
}
.root :global(.detail-source-label) {
font-size: 13px;
font-weight: 600;
color: var(--text-main);
text-align: right;
}
.root :global(.detail-label) {
display: block;
color: var(--text-muted);
font-size: 11px;
margin-bottom: 6px;
}
.root :global(.detail-value) {
display: block;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
line-height: 1.5;
word-break: break-word;
}
.root :global(.profile-lead) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.root :global(.detail-mini-chart-wrap) {
display: grid;
gap: 10px;
}
.root :global(.detail-mini-chart) {
position: relative;
height: 210px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.root :global(.scan-city-detail-rail .detail-mini-chart) {
height: 230px;
}
.root :global(.detail-mini-chart canvas) {
display: block;
width: 100%;
height: 100%;
}
.root :global(.detail-mini-chart-legend) {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.4;
}
.root :global(.detail-mini-chart-legend span) {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.root :global(.detail-mini-chart-legend i) {
width: 16px;
height: 3px;
border-radius: 999px;
flex: 0 0 auto;
}
.root :global(.detail-mini-chart-legend i.forecast) {
background: rgba(100, 116, 139, 0.72);
}
.root :global(.detail-mini-chart-legend i.forecast.calibrated) {
background: #38bdf8;
}
.root :global(.detail-mini-chart-legend i.observation) {
width: 7px;
height: 7px;
background: #22c55e;
}
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.root :global(.insight-list) {
display: grid;
gap: 10px;
}
.root :global(.insight-item) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.insight-title) {
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
}
.root :global(.insight-text) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.detail-scenery-card) {
position: relative;
min-height: 210px;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
rgba(255, 255, 255, 0.03);
}
.root :global(.detail-scenery-image) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.root :global(.detail-scenery-overlay) {
position: relative;
z-index: 1;
min-height: 210px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(
180deg,
rgba(2, 6, 23, 0.08) 0%,
rgba(2, 6, 23, 0.86) 100%
);
}
.root :global(.detail-scenery-copy),
.root :global(.detail-scenery-fallback) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.detail-scenery-fallback) {
min-height: 210px;
padding: 18px;
justify-content: flex-end;
background:
radial-gradient(
circle at top left,
rgba(34, 211, 238, 0.12),
transparent 34%
),
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
}
.root :global(.detail-scenery-kicker) {
color: rgba(226, 232, 240, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.root :global(.detail-scenery-title) {
color: #fff;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
}
.root :global(.detail-scenery-subtitle) {
max-width: 320px;
color: rgba(226, 232, 240, 0.9);
font-size: 13px;
line-height: 1.6;
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
}
.root :global(.detail-scenery-credit) {
align-self: flex-start;
color: rgba(226, 232, 240, 0.9);
font-size: 11px;
text-decoration: none;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(2, 6, 23, 0.35);
backdrop-filter: blur(10px);
}
.root :global(.detail-scenery-credit:hover) {
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
@media (max-width: 640px) {
.root :global(.detail-grid) {
grid-template-columns: 1fr;
}
.root :global(.detail-mini-chart) {
height: 170px;
}
.root :global(.detail-source-link) {
padding: 10px 12px;
align-items: flex-start;
flex-direction: column;
}
.root :global(.detail-source-label) {
text-align: left;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,82 +0,0 @@
.root :global(.modal-title-stack) {
display: grid;
gap: 8px;
min-width: 0;
}
.root :global(.modal-overline) {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-overline-sep) {
opacity: 0.5;
}
.root :global(.modal-subtitle) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
.root :global(.modal-header-meta) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.root :global(.modal-meta-pill) {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(34, 211, 238, 0.08);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.root :global(.modal-callout) {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.035);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.modal-callout-info) {
border-color: rgba(56, 189, 248, 0.24);
background: rgba(14, 165, 233, 0.08);
}
.root :global(.modal-section-heading) {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.root :global(.modal-section-kicker) {
color: var(--accent-cyan);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.root :global(.modal-section-note) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
@@ -1,450 +0,0 @@
"use client";
function getTempExtremeClass(temp: number | null | undefined, symbol?: string | null) {
if (temp == null || !Number.isFinite(temp)) return "";
const isF = String(symbol || "").toUpperCase().includes("F");
if (isF ? temp >= 104 : temp >= 40) return "temp-extreme-hot";
if (isF ? temp <= 23 : temp <= -5) return "temp-extreme-cold";
return "";
}
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import {
useCityData,
useCityDetails,
useDashboardModal,
} from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { CityDetail } from "@/lib/dashboard-types";
import { getTemperatureChartData } from "@/lib/chart-utils";
import {
normalizeObservationSourceCode,
normalizeObservationSourceLabel,
} from "@/lib/source-labels";
import {
getHeroMetaItems,
getRiskBadgeLabel,
getWeatherSummary,
} from "@/lib/weather-summary-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
if (!data) return null;
const { weatherIcon, weatherText } = getWeatherSummary(data, locale);
const metaItems = getHeroMetaItems(data, locale);
const current = data.current || {};
const settlementSourceCode = normalizeObservationSourceCode(
current.settlement_source || "metar",
);
const settlementIcao = String(current.station_code || data.risk?.icao || "")
.trim()
.toUpperCase();
const settlementSource =
settlementSourceCode === "metar" && settlementIcao
? `${settlementIcao} METAR`
: normalizeObservationSourceLabel(
current.settlement_source_label || current.settlement_source,
"METAR",
).toUpperCase();
const isMax =
current.max_so_far != null &&
current.temp != null &&
current.max_so_far <= current.temp;
const currentObsText =
current.temp != null
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
: data.metar_status?.stale_for_today
? locale === "en-US"
? "No same-day METAR"
: "今日暂无 METAR"
: "--";
return (
<section className="hero-section">
<div className="hero-weather">
<span>
{weatherIcon} {weatherText}
</span>
</div>
<div className={`hero-temp ${getTempExtremeClass(current.temp, data.temp_symbol)}`}>
<span className="hero-value">
{current.temp != null ? current.temp.toFixed(1) : "--"}
</span>
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
</div>
<div className="hero-max-time">
{isMax && current.max_temp_time
? locale === "en-US"
? `Today's peak temperature appeared at local time ${current.max_temp_time}`
: `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
: ""}
</div>
<div className="hero-details">
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"}
</span>
<span className="value">{currentObsText}</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US"
? `${settlementSource} Anchor`
: `${settlementSource} 锚点`}
</span>
<span className="value highlight">
{current.wu_settlement != null
? `${current.wu_settlement}${data.temp_symbol}`
: "--"}
</span>
</div>
<div className="hero-item">
<span className="label">
{locale === "en-US" ? "DEB Forecast" : "DEB 预测"}
</span>
<span className="value">
{data.deb?.prediction != null
? `${data.deb.prediction}${data.temp_symbol}`
: "--"}
</span>
</div>
</div>
<div className="hero-sub">
{metaItems.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</section>
);
}
export function TemperatureChart() {
const { data } = useCityData();
const { locale, t } = useI18n();
const chartData = useMemo(
() => (data ? getTemperatureChartData(data, locale) : null),
[data, locale],
);
const canvasRef = useChart(() => {
if (!data || !chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
if (chartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: chartData.datasets.mgmHourlyPoints,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预报",
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(77, 163, 255, 0.06)",
borderColor: "rgba(77, 163, 255, 0.66)",
borderWidth: 1.5,
data: chartData.datasets.debPast,
fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(77, 163, 255, 0.36)",
borderDash: [5, 3],
borderWidth: 1.5,
data: chartData.datasets.debFuture,
fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预报",
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#4DA3FF",
borderColor: "#4DA3FF",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label:
chartData.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况"),
order: 0,
pointHoverRadius: 7,
pointRadius: 5,
});
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: chartData.datasets.mgmPoints,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!chartData.datasets.hasMgmHourly &&
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(77, 163, 255, 0.22)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
pointRadius: 0,
tension: 0.3,
});
}
return {
data: {
datasets,
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 3 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${data.temp_symbol || "°C"}`,
color: "#6B7A90",
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [data, chartData, locale]);
return (
<section className="chart-section">
<h3>{t("section.todayTempTrend")}</h3>
<div className="chart-wrapper">
<canvas ref={canvasRef} />
</div>
<div className="chart-legend">
{chartData?.legendText || t("section.chartEmpty")}
</div>
</section>
);
}
export { ProbabilityDistribution } from "./ProbabilityDistribution";
export { ModelForecast } from "./ModelForecast";
export function ForecastTable() {
const modal = useDashboardModal();
const { loadingState } = useCityDetails();
const { data } = useCityData();
const { locale, t } = useI18n();
const daily = useMemo(() => {
if (!data) return [];
const rawDaily = Array.isArray(data.forecast?.daily)
? data.forecast?.daily || []
: [];
const seen = new Set<string>();
return rawDaily.filter((day) => {
const date = String(day?.date || "").trim();
if (!date || seen.has(date)) return false;
seen.add(date);
return true;
});
}, [data]);
if (!data) return null;
const isSparseDaily = daily.length <= 1;
const isForecastCompleting =
loadingState.cityDetail &&
(data.detail_depth !== "full" || isSparseDaily);
const resolveForecastTemp = (
date: string,
fallback: number | null | undefined,
) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null;
};
return (
<section className="forecast-section">
<h3>{t("forecast.title")}</h3>
{isSparseDaily && (
<div className="forecast-inline-note">
{isForecastCompleting
? locale === "en-US"
? "Multi-day forecast is syncing. Only the current-day card has arrived."
: "多日预报同步中,当前只到达当日卡片。"
: locale === "en-US"
? "Only the current-day forecast is available right now."
: "当前只收到当日预报,其他日期结果暂未回传。"}
</div>
)}
<div className="forecast-table">
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
) : (
daily
.map((day, index) => {
const isToday = data.local_date
? day.date === data.local_date
: index === 0;
const isSelected =
(isToday &&
modal.forecastModalMode === "today" &&
Boolean(modal.futureModalDate)) ||
(modal.forecastModalMode !== "today" &&
modal.futureModalDate === day.date) ||
modal.selectedForecastDate === day.date;
return (
<button
key={day.date}
type="button"
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
startTransition(() => {
if (isToday) {
modal.openTodayModal();
return;
}
modal.openFutureModal(day.date);
});
}}
>
<div className="f-date">
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{resolveForecastTemp(day.date, day.max_temp)}
{data.temp_symbol}
</div>
</button>
);
})
.concat(
isForecastCompleting
? Array.from({ length: Math.max(0, 5 - daily.length) }).map(
(_, index) => (
<button
key={`forecast-sync-${index}`}
type="button"
className="forecast-day forecast-day-sync"
disabled
>
<div className="f-date">
{locale === "en-US" ? "Syncing" : "同步中"}
</div>
<div className="f-temp">--</div>
</button>
),
)
: [],
)
)}
</div>
</section>
);
}
export function RiskInfo() {
const { data } = useCityData();
const { t } = useI18n();
if (!data) return null;
const risk = data.risk || {};
return (
<section className="risk-section">
<h3>{t("section.risk")}</h3>
<div className="risk-info">
{!risk.airport ? (
<span style={{ color: "var(--text-muted)" }}>
{t("section.noRiskProfile")}
</span>
) : (
<>
<div className="risk-row">
<span className="risk-label">{t("section.airport")}</span>
<span>
{risk.airport} ({risk.icao})
</span>
</div>
<div className="risk-row">
<span className="risk-label">{t("section.distance")}</span>
<span>{risk.distance_km}km</span>
</div>
{risk.warning && (
<div className="risk-row">
<span className="risk-label">{t("section.note")}</span>
<span>{risk.warning}</span>
</div>
)}
</>
)}
</div>
</section>
);
}
@@ -1,955 +0,0 @@
"use client";
import clsx from "clsx";
import { useMemo } from "react";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "")
.toUpperCase()
.includes("F");
}
function displayTempToMarketCelsius(
value: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (value == null || !Number.isFinite(value)) return null;
if (isFahrenheitSymbol(detail.temp_symbol)) {
return ((value - 32) * 5) / 9;
}
return value;
}
function formatBucketDisplayLabel(
bucket: ProbabilityBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (!bucketLabel) return "";
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
const symbol = detail.temp_symbol || "°C";
if (isFahrenheitSymbol(symbol)) {
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
} else {
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
}
str = str.replace(/°?C($|\+|-)/g, "°C$1");
str = str.replace(/°?F($|\+|-)/g, "°F$1");
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
str += symbol;
}
return str;
}
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
return String(bucket?.unit || "").toUpperCase();
}
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("+") ||
text.includes("orhigher") ||
text.includes("or-higher")
);
}
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
);
}
function findMarketBucketForDisplayTemp(
buckets: MarketTopBucket[],
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
let best: MarketTopBucket | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const bucketUnit = String(bucket.unit || "").toUpperCase();
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) continue;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
compareTemp >= lower - 0.01 &&
compareTemp <= upper + 0.01
) {
return bucket;
}
const rawTemp = bucket.temp ?? bucket.value ?? null;
if (rawTemp == null) continue;
const candidateTemp = Number(rawTemp);
if (!Number.isFinite(candidateTemp)) continue;
const delta = Math.abs(candidateTemp - compareTemp);
if (delta < bestDelta) {
best = bucket;
bestDelta = delta;
}
}
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
return best && bestDelta <= tolerance ? best : null;
}
function marketBucketContainsDisplayTemp(
bucket: MarketTopBucket | null,
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
return false;
const bucketUnit = getMarketBucketUnit(bucket);
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) return false;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (lower != null && !Number.isFinite(lower)) return false;
if (upper != null && !Number.isFinite(upper)) return false;
if (lower != null && upper != null) {
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
}
if (lower != null && isMarketBucketAbove(bucket)) {
return compareTemp >= lower - 0.01;
}
if (lower != null && isMarketBucketBelow(bucket)) {
return compareTemp <= lower + 0.01;
}
const reference = bucket.temp ?? bucket.value ?? lower;
const numeric = reference != null ? Number(reference) : null;
if (numeric == null || !Number.isFinite(numeric)) return false;
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
return Math.abs(compareTemp - numeric) <= tolerance;
}
function getAggregatedModelProbabilityForMarketBucket(
probabilities: ProbabilityBucket[],
bucket: MarketTopBucket | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket) return null;
let total = 0;
let matched = 0;
for (const probabilityBucket of probabilities) {
const temp = getBucketTemp(probabilityBucket);
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
const probability = Number(probabilityBucket.probability);
if (!Number.isFinite(probability)) continue;
total += probability;
matched += 1;
}
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
}
type ProbabilityDisplayRow = {
key: string;
label: string;
probability: number;
marketBucket?: MarketTopBucket | null;
};
function formatMarketBucketDisplayLabel(
bucket: MarketTopBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
const label = String(bucket.label || "").trim();
if (label) {
const unit = getMarketBucketUnit(bucket);
let normalized = label.toUpperCase().replace(/\s+/g, "");
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°F")
.replace(/°C/g, "°F")
.replace(/(?<=\d)F/g, "°F");
} else {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°C")
.replace(/°F/g, "°C")
.replace(/(?<=\d)C/g, "°C");
}
return normalized.replace(/\+/g, "+");
}
const unit =
getMarketBucketUnit(bucket) === "F" ||
isFahrenheitSymbol(detail.temp_symbol)
? "°F"
: "°C";
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper)
) {
return `${lower}-${upper}${unit}`;
}
const value = bucket.value ?? bucket.temp ?? lower;
const numeric = value != null ? Number(value) : null;
if (numeric != null && Number.isFinite(numeric)) {
return isMarketBucketAbove(bucket)
? `${numeric}${unit}+`
: `${numeric}${unit}`;
}
return "--";
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function normalizeModelNameForVote(name: string) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s_/-]/g, "");
}
function getModelVoteFamily(name: string) {
const normalized = normalizeModelNameForVote(name);
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
if (normalized === "ecmwf") return "ecmwf_ifs";
return normalized || name;
}
function getModelVotePriority(name: string) {
const normalized = normalizeModelNameForVote(name);
return (
{
icond2: 40,
iconeu: 30,
icon: 20,
hrdps: 40,
rdps: 35,
gdps: 30,
gem: 20,
ecmwfaifs: 30,
ecmwf: 30,
gfs: 30,
jma: 30,
mgm: 45,
nws: 45,
openmeteo: 15,
}[normalized] || 10
);
}
function getRoundedModelVoteDistribution(
detail: CityDetail,
targetDate?: string | null,
) {
const view = getModelView(detail, targetDate);
const representatives = new Map<
string,
{ name: string; priority: number; value: number }
>();
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
const priority = getModelVotePriority(name);
const current = representatives.get(family);
if (!current || priority > current.priority) {
representatives.set(family, { name, priority, value });
}
});
const bucketMap = new Map<number, { count: number; models: string[] }>();
representatives.forEach(({ name, value }) => {
const rounded = Math.round(value);
const row = bucketMap.get(rounded) || { count: 0, models: [] };
row.count += 1;
row.models.push(name);
bucketMap.set(rounded, row);
});
const total = representatives.size;
const rows = Array.from(bucketMap.entries())
.map(([value, row]) => ({
count: row.count,
models: row.models,
percent: total > 0 ? row.count / total : 0,
value,
}))
.sort((a, b) => b.count - a.count || b.value - a.value);
return {
rows,
total,
};
}
function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
function normalizeSignedProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (Math.abs(numeric) > 1) return numeric / 100;
return numeric;
}
function formatSignedPercent(value?: number | null, digits = 1) {
const normalized = normalizeSignedProbability(value);
if (normalized == null) return "--";
const percent = normalized * 100;
const sign = percent > 0 ? "+" : "";
return `${sign}${percent.toFixed(digits)}%`;
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketAllBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.all_buckets)
? scan.all_buckets
: Array.isArray(scan?.top_buckets)
? scan.top_buckets
: [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
const view = getModelView(detail, targetDate);
return Object.keys(view.models || {}).some((name) =>
normalizeModelNameForVote(name).includes("lgbm"),
);
}
function formatProbabilityEngineLabel(
detail: CityDetail,
targetDate: string | null | undefined,
locale: string,
) {
const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
}
const engine = String(view.engine || "")
.trim()
.toLowerCase();
const calibrationMode = String(view.calibrationMode || "")
.trim()
.toLowerCase();
if (engine === "emos" || calibrationMode.includes("emos")) {
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
}
return locale === "en-US" ? "Model probability" : "模型概率";
}
export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const modelView = getModelView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const probabilityEngineLabel = formatProbabilityEngineLabel(
detail,
targetDate,
locale,
);
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
const modelVoteView = useMemo(
() => getRoundedModelVoteDistribution(detail, targetDate),
[detail, targetDate],
);
const modelVoteHint = modelVoteView.rows
.slice(0, 2)
.map(
(row) =>
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
)
.join(" · ");
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
const topProbability = [...(view.probabilities || [])].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
)[0];
const topProbabilityText = toPercent(topProbability?.probability);
const topProbabilityLabel = topProbability
? formatBucketDisplayLabel(topProbability, detail)
: null;
const topProbabilityTemp = topProbability
? getBucketTemp(topProbability)
: null;
const probabilitiesForMarketContracts =
view.probabilitiesAll?.length > 0
? view.probabilitiesAll
: view.probabilities || [];
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
return [];
}
const rows: ProbabilityDisplayRow[] = [];
const seenKeys = new Set<string>();
for (const marketBucket of marketAllBuckets) {
const probability = getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
marketBucket,
detail,
);
const key =
marketBucket.slug ||
marketBucket.label ||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
if (seenKeys.has(key)) continue;
seenKeys.add(key);
rows.push({
key,
label: formatMarketBucketDisplayLabel(marketBucket, detail),
probability: probability ?? 0,
marketBucket,
});
}
return rows;
}, [
detail,
isToday,
marketAllBuckets,
marketScan?.available,
probabilitiesForMarketContracts,
]);
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
() =>
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
const bucketTemp = getBucketTemp(bucket);
return {
key: `${bucket.label || bucket.value || index}`,
label: formatBucketDisplayLabel(bucket, detail),
probability: Number(bucket.probability || 0),
marketBucket: findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
),
};
}),
[detail, marketAllBuckets, view.probabilities],
);
const probabilityRows =
marketContractRows.length > 0
? marketContractRows.slice(0, 8)
: modelProbabilityRows;
const topContractRow =
marketContractRows.length > 0
? marketContractRows.reduce((best, row) =>
row.probability > best.probability ? row : best,
)
: null;
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
const displayTopProbability =
topContractRow?.probability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const displayTopProbabilityText = toPercent(displayTopProbability);
const displayUsesMarketBuckets = marketContractRows.length > 0;
const linkedMarketBucket = useMemo(() => {
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
if (topProbabilityTemp == null) return null;
return findMarketBucketForDisplayTemp(
marketAllBuckets,
topProbabilityTemp,
detail,
);
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no;
const linkedMarketAsk =
linkedMarketBucket?.yes_buy ??
linkedMarketBucket?.market_price ??
yesPriceView?.ask ??
null;
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel =
topContractRow?.label ||
(linkedMarketBucket
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
: null) ||
topProbabilityLabel ||
null;
const aggregatedMarketProbability =
getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
linkedMarketBucket,
detail,
);
const linkedMarketProbability =
topContractRow?.probability ??
aggregatedMarketProbability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
? linkedMarketProbability - Number(linkedMarketAsk)
: null;
const linkedNoEdge =
linkedMarketProbability != null && linkedNoAsk != null
? 1 - linkedMarketProbability - Number(linkedNoAsk)
: null;
const linkedBestSide =
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
? linkedNoEdge > linkedMarketEdge
? "no"
: "yes"
: null;
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
const preferredPriceView = linkedMarketBucket
? {
ask: linkedBestAsk,
edge: linkedBestEdge,
}
: priceAnalysis?.best_side === "no"
? noPriceView
: yesPriceView;
const preferredSideLabel = linkedMarketBucket
? linkedBestSide === "no"
? "NO"
: "YES"
: priceAnalysis?.best_side === "no"
? locale === "en-US"
? "NO"
: "NO"
: locale === "en-US"
? "YES"
: "YES";
const yesDisplayPrice = linkedMarketBucket
? linkedMarketAsk
: yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
: yesPriceView?.edge;
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
const hasPriceAnalysis =
isToday &&
(Boolean(priceAnalysis?.available) ||
Boolean(marketScan) ||
Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(
priceAnalysis?.lock?.available && lockEdge != null,
);
const quoteSource =
linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source ||
marketScan?.no_token?.quote_source ||
null;
const quoteAgeMs =
linkedMarketBucket?.quote_age_ms ??
marketScan?.yes_token?.quote_age_ms ??
marketScan?.no_token?.quote_age_ms;
const quoteSourceLabel =
quoteSource === "polymarket_ws"
? locale === "en-US"
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}` : ""}`
: locale === "en-US"
? "CLOB fallback"
: "CLOB 兜底";
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
const linkedContractOverpriced =
Boolean(linkedMarketBucket) &&
linkedBestSide === "no" &&
linkedMarketProbability != null &&
linkedMarketAsk != null &&
linkedMarketEdge != null &&
linkedMarketEdge < 0 &&
linkedNoEdge != null &&
linkedNoEdge > 0;
const linkedContractOverpay =
linkedContractOverpriced &&
linkedMarketProbability != null &&
linkedMarketAsk != null
? Number(linkedMarketAsk) - linkedMarketProbability
: null;
const actionText = !marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? linkedContractOverpriced
? locale === "en-US"
? "Overpriced"
: "市场偏贵"
: locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? linkedContractOverpriced
? locale === "en-US"
? "Slightly overpriced"
: "略偏贵"
: locale === "en-US"
? `Small ${preferredSideLabel}`
: `${preferredSideLabel} 优势较小`
: locale === "en-US"
? "No clear edge"
: "暂无优势";
const actionNote =
linkedContractOverpriced && linkedContractOverpay != null
? locale === "en-US"
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
: actionableEdge != null && actionableEdge >= 0.02
? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return (
<section className="prob-section">
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
<div className="prob-calibration-head">
<div>
<span className="prob-source-chip">{probabilityEngineLabel}</span>
<strong>
{displayTopLabel && displayTopProbabilityText
? locale === "en-US"
? displayUsesMarketBuckets
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
: displayUsesMarketBuckets
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
: locale === "en-US"
? "Awaiting calibrated buckets"
: "等待校准概率桶"}
</strong>
</div>
<p>
{hasLgbmProbability
? locale === "en-US"
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
: locale === "en-US"
? "Using the calibrated probability distribution; raw model points below are not probabilities."
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
</p>
</div>
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market reference only: top traded bucket ${topMarketBucketText}`
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US"
? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`}
</div>
)}
<div className="prob-distribution-panel">
<div className="prob-distribution-head">
<span>
{locale === "en-US"
? "Probability distribution"
: "概率分布"}
</span>
<em>
{marketContractRows.length > 0
? locale === "en-US"
? "market buckets are aggregated from single-degree probability buckets"
: "市场合约桶由单点概率分布聚合"
: locale === "en-US"
? "temperature probability buckets"
: "温度概率桶"}
</em>
</div>
{probabilityRows.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
probabilityRows.map((row, index) => {
const probability = Math.round(
Number(row.probability || 0) * 100,
);
return (
<div key={`${row.key || index}`} className="prob-row">
<div className="prob-label">{row.label}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
</div>
);
})
)}
</div>
{hasPriceAnalysis && (
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market context"
: "等待市场参照"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: locale === "en-US"
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>
{locale === "en-US" ? "Bucket" : "温度桶"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
</strong>
<em>
{linkedMarketProbabilityText || topProbabilityText || "--"}
</em>
</div>
<div>
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
<strong>
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
<strong>{modelVoteHint || "--"}</strong>
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
<em>{quoteSourceLabel}</em>
</div>
</div>
<p>
{locale === "en-US"
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
</p>
</div>
)}
{modelVoteHint && (
<div className="prob-model-hint">
<span>
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
</span>
<strong>{modelVoteHint}</strong>
<em>
{locale === "en-US"
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
: "仅作诊断;EMOS 与合约行使用校准概率"}
</em>
</div>
)}
</div>
</section>
);
}
@@ -1,274 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
export function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
const raw = String(rawMetar || "").toUpperCase();
const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
if (!matches.length) return "";
const coverText: Record<string, { zh: string; en: string }> = {
FEW: { zh: "少云", en: "few" },
SCT: { zh: "散云", en: "scattered" },
BKN: { zh: "多云", en: "broken" },
OVC: { zh: "阴天", en: "overcast" },
};
return matches
.slice(0, 3)
.map((match) => {
const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
return locale === "en-US"
? [cover.en, base].filter(Boolean).join(" ")
: [cover.zh, base].filter(Boolean).join(" ");
})
.join(locale === "en-US" ? ", " : "、");
}
export function decodeRawMetarVisibility(rawMetar?: string | null) {
const raw = String(rawMetar || "").toUpperCase();
if (/\b9999\b/.test(raw)) return "10km+";
const meterMatch = raw.match(/\b(\d{4})\b/);
if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
return "";
}
export function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") {
const raw = String(token || "").trim().toUpperCase();
if (!raw) return "";
const isEn = locale === "en-US";
const intensity = raw.startsWith("-")
? isEn
? "light "
: "轻"
: raw.startsWith("+")
? isEn
? "heavy "
: "强"
: "";
const cleaned = raw.replace(/^[+-]/, "");
const descriptors: Record<string, { zh: string; en: string }> = {
VC: { zh: "附近", en: "nearby " },
SH: { zh: "阵性", en: "showery " },
TS: { zh: "雷暴性", en: "thunderstorm " },
FZ: { zh: "冻", en: "freezing " },
BL: { zh: "吹扬", en: "blowing " },
DR: { zh: "低吹", en: "drifting " },
MI: { zh: "浅层", en: "shallow " },
BC: { zh: "碎片状", en: "patches of " },
PR: { zh: "部分", en: "partial " },
};
const phenomena: Record<string, { zh: string; en: string }> = {
DZ: { zh: "毛毛雨", en: "drizzle" },
RA: { zh: "雨", en: "rain" },
SN: { zh: "雪", en: "snow" },
SG: { zh: "米雪", en: "snow grains" },
IC: { zh: "冰晶", en: "ice crystals" },
PL: { zh: "冰粒", en: "ice pellets" },
GR: { zh: "冰雹", en: "hail" },
GS: { zh: "小冰雹", en: "small hail" },
UP: { zh: "未知降水", en: "unknown precipitation" },
BR: { zh: "薄雾", en: "mist" },
FG: { zh: "雾", en: "fog" },
FU: { zh: "烟", en: "smoke" },
VA: { zh: "火山灰", en: "volcanic ash" },
DU: { zh: "浮尘", en: "dust" },
SA: { zh: "沙", en: "sand" },
HZ: { zh: "霾", en: "haze" },
PY: { zh: "喷雾", en: "spray" },
PO: { zh: "尘卷风", en: "dust whirls" },
SQ: { zh: "飑", en: "squall" },
FC: { zh: "漏斗云", en: "funnel cloud" },
SS: { zh: "沙暴", en: "sandstorm" },
DS: { zh: "尘暴", en: "duststorm" },
};
const descriptorText = Object.entries(descriptors)
.filter(([code]) => cleaned.includes(code))
.map(([, text]) => (isEn ? text.en : text.zh))
.join("");
const phenomenonText = Object.entries(phenomena)
.filter(([code]) => cleaned.includes(code))
.map(([, text]) => (isEn ? text.en : text.zh))
.join(isEn ? " / " : "、");
if (!phenomenonText) return "";
return `${intensity}${descriptorText}${phenomenonText}`;
}
export function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") {
const raw = String(rawMetar || "").toUpperCase();
const matches = Array.from(
raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g),
);
return Array.from(
new Set(
matches
.map((match) => decodeMetarWeatherToken(match[1], locale))
.filter(Boolean),
),
).join(locale === "en-US" ? ", " : "、");
}
export function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) {
const context = row.metar_context || {};
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
detail?.airport_current || {};
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
return {
cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(),
rawMetar,
visibility:
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
? Number(context.airport_visibility_mi)
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
? Number(airport.visibility_mi)
: null,
weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(),
windSpeed:
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
? Number(context.airport_wind_speed_kt)
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
? Number(airport.wind_speed_kt)
: null,
};
}
export function formatAirportWeatherRead(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
) {
const isEn = locale === "en-US";
const inputs = getAirportWeatherInputs(row, detail);
const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale);
const decodedWeather =
decodeMetarWeatherToken(inputs.weather, locale) ||
inputs.weather ||
decodeRawMetarWeather(inputs.rawMetar, locale);
const visibilityText =
inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar);
const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase();
const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase();
const suppressors: string[] = [];
const supporters: string[] = [];
if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) {
suppressors.push(
isEn
? "cloud, precipitation or restricted visibility can suppress solar heating"
: "云雨、薄雾或低能见度会压制太阳辐射升温",
);
}
if (inputs.visibility != null && inputs.visibility < 6) {
suppressors.push(
isEn
? `visibility is only ${visibilityText}, so the airport path may warm more slowly`
: `能见度仅 ${visibilityText},机场路径可能升温偏慢`,
);
}
if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) {
supporters.push(
isEn
? "few or scattered clouds do not block the heating path materially"
: "少云或散云对日间升温压制不明显",
);
}
if (inputs.windSpeed != null && inputs.windSpeed >= 15) {
suppressors.push(
isEn
? "stronger wind mixing can change the airport temperature path"
: "风速偏大,边界层混合可能改写机场温度路径",
);
} else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) {
supporters.push(
isEn
? "light wind leaves the temperature path mainly driven by local sunshine"
: "风速较弱,温度路径更取决于本地日照",
);
}
const descriptors = [
decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null,
decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null,
visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null,
].filter(Boolean);
const read = suppressors[0] || supporters[0];
if (!descriptors.length && !read) return null;
const prefix = isEn ? "Airport weather read" : "机场气象解读";
const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : "")}` : "";
return `${prefix}${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}`;
}
export function formatAirportReportRead(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const context = row.metar_context || {};
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
detail?.airport_current || {};
const station =
context.station ||
detail?.risk?.icao ||
airport.station_code ||
null;
const obsTime =
context.airport_obs_time ||
context.last_time ||
airport.obs_time ||
row.metar_status?.last_observation_time ||
null;
const temp =
context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
? Number(context.airport_current_temp)
: airport.temp != null && Number.isFinite(Number(airport.temp))
? Number(airport.temp)
: null;
const windSpeed =
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
? Number(context.airport_wind_speed_kt)
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
? Number(airport.wind_speed_kt)
: null;
const windDir =
context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
? Number(context.airport_wind_dir)
: airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
? Number(airport.wind_dir)
: null;
const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim();
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale);
const decodedWeather =
decodeMetarWeatherToken(weather, locale) ||
weather ||
decodeRawMetarWeather(rawMetar, locale);
const visibility =
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
? Number(context.airport_visibility_mi)
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
? Number(airport.visibility_mi)
: null;
const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
const parts: string[] = [];
if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
if (windSpeed != null) {
parts.push(
windDir != null
? isEn
? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
: `${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
: isEn
? `wind ${Math.round(windSpeed)}kt`
: `${Math.round(windSpeed)}kt`,
);
}
if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`);
if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`);
if (!parts.length) return null;
const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读";
const head = [station, obsTime].filter(Boolean).join(" ");
return `${prefix}${head ? ` ${head}` : ""}${parts.join("")}`;
}
@@ -1,10 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
export function getLocalizedRowText(
row: ScanOpportunityRow,
locale: string,
zh?: string | null,
en?: string | null,
) {
return locale === "en-US" ? en || zh || null : zh || en || null;
}
@@ -1,37 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
export function normalizeLookupKey(value?: string | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
export function getDetailForRow(
row: Pick<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
cityDetailsByName?: Record<string, CityDetail>,
) {
if (!cityDetailsByName) return null;
const rowKeys = [row.city, row.city_display_name, row.display_name]
.map(normalizeLookupKey)
.filter(Boolean);
return (
Object.entries(cityDetailsByName).find(([name, detail]) => {
const detailKeys = [name, detail.name, detail.display_name]
.map(normalizeLookupKey)
.filter(Boolean);
return rowKeys.some((key) => detailKeys.includes(key));
})?.[1] || null
);
}
export function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
if (!row) return detail.local_date;
const rawDate = row.selected_date || row.local_date || "";
const phase = String(row.window_phase || "").toLowerCase();
if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
return detail.local_date;
}
return detail.local_date || rawDate;
}
@@ -1,124 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureLabel,
} from "@/lib/temperature-utils";
import { getTargetRange } from "./opportunity-target";
export function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
export function normalizeProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
}
export function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m left`;
return `${hours}h ${remains}m left`;
}
if (hours <= 0) return `剩余 ${remains} 分钟`;
return `剩余 ${hours}h ${remains}m`;
}
export function formatMinuteSpan(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m`;
return `${hours}h ${remains}m`;
}
if (hours <= 0) return `${remains} 分钟`;
return `${hours}h ${remains}m`;
}
export function formatAction(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
return formatTradeSide(row, locale, tempSymbol);
}
export function formatQuoteCents(value?: number | null) {
if (value == null || Number.isNaN(Number(value))) return "--";
const cents = Number(value) * 100;
const text =
cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
? cents.toFixed(1)
: Math.round(cents).toFixed(0);
return `${text.replace(/\.0$/, "")}¢`;
}
export function formatTradeSide(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const side = String(row.side || "").toLowerCase();
const isEn = locale === "en-US";
const { lower, upper } = getTargetRange(row);
const threshold =
lower != null && upper == null
? formatTemperatureValue(lower, tempSymbol)
: upper != null && lower == null
? formatTemperatureValue(upper, tempSymbol)
: null;
if (threshold && lower != null && upper == null) {
if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`;
if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`;
}
if (threshold && upper != null && lower == null) {
if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`;
if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`;
}
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`;
if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`;
}
const bucket = formatThreshold(row, tempSymbol);
if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket}`;
if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket}`;
if (row.action) {
return normalizeTemperatureLabel(
String(row.action).replace(String(row.target_label || ""), ""),
tempSymbol,
)
.replace(/\s+/g, " ")
.trim()
.toUpperCase();
}
return locale === "en-US" ? "WATCH" : "观察";
}
export function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
if (targetLabel) return targetLabel;
if (row.target_lower != null && row.target_upper != null) {
return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
}
if (row.target_threshold != null) {
return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
}
if (row.target_value != null) {
return formatTemperatureValue(Number(row.target_value), tempSymbol);
}
return "--";
}
export function formatTemperatureDelta(value: number, tempSymbol?: string | null) {
return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 });
}
@@ -1,185 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
import {
getDetailForRow,
getDetailViewDate,
} from "./opportunity-detail";
import {
formatThreshold,
normalizeProbability,
} from "./opportunity-format";
import { formatModelClusterRange } from "./opportunity-model-summary";
import {
extractNumbers,
getTargetRange,
normalizeBucketLabel,
} from "./opportunity-target";
export function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
return [bucket.label, bucket.bucket, bucket.range]
.map((value) => String(value || "").trim())
.filter(Boolean);
}
export function bucketMatchesRow(
bucket: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | string | null;
},
row: ScanOpportunityRow,
tempSymbol?: string | null,
) {
const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
const bucketLabels = getBucketText(bucket).map((label) =>
normalizeBucketLabel(label, tempSymbol),
);
if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
return true;
}
const rawTargetLabel = String(row.target_label || "");
const targetNumbers = extractNumbers(rawTargetLabel);
const targetValue =
row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
const bucketNumbers = [
...(bucket.value != null && Number.isFinite(Number(bucket.value))
? [Number(bucket.value)]
: []),
...getBucketText(bucket).flatMap(extractNumbers),
];
const matchesNumber = bucketNumbers.some(
(value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
);
if (!matchesNumber) return false;
const targetIsUpper =
/(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
(row.target_lower != null && row.target_upper == null);
const targetIsLower =
/(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
(row.target_upper != null && row.target_lower == null);
const bucketRaw = getBucketText(bucket).join(" ");
const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
return true;
}
export function getDetailBucketEventProbability(
detail: CityDetail | null,
row: ScanOpportunityRow,
tempSymbol?: string | null,
) {
if (!detail) return null;
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
const buckets = Array.isArray(view.probabilitiesAll)
? view.probabilitiesAll
: [];
if (!buckets.length) return null;
const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
return normalizeProbability(matched?.probability);
}
export type OpportunityGroup = {
key: string;
cityName: string;
date?: string | null;
tempSymbol?: string | null;
debLabel: string;
peakLabel: string;
peakProbability?: number | null;
phaseMeta: PhaseMeta;
localTime?: string | null;
remainingMinutes?: number | null;
rows: ScanOpportunityRow[];
};
export function buildOpportunityGroups(
rows: ScanOpportunityRow[],
locale: string,
cityDetailsByName?: Record<string, CityDetail>,
): OpportunityGroup[] {
const groups = new Map<string, OpportunityGroup>();
for (const row of rows) {
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
const detail = getDetailForRow(row, cityDetailsByName);
const cityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
);
const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
const key = `${row.city || cityName}|${date}`;
const modelView = detail ? getModelView(detail, date) : null;
const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
const modelClusterLabel = formatModelClusterRange(
modelView?.models || row.model_cluster_sources,
tempSymbol,
);
const existing = groups.get(key);
if (!existing) {
groups.set(key, {
key,
cityName,
date,
tempSymbol,
debLabel:
debPrediction != null
? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
: "--",
peakLabel: modelClusterLabel,
peakProbability: null,
phaseMeta: getWindowPhaseMeta(row, locale),
localTime: row.local_time,
remainingMinutes: row.remaining_window_minutes,
rows: [row],
});
continue;
}
existing.rows.push(row);
if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
existing.peakLabel = modelClusterLabel;
}
}
return Array.from(groups.values()).map((group) => ({
...group,
rows: [...group.rows].sort(
(a, b) =>
Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
),
}));
}
export function getBucketDisplayLabel(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const { lower, upper } = getTargetRange(row);
if (lower != null && upper == null) {
const value = formatTemperatureValue(lower, tempSymbol);
return isEn ? `${value} or higher` : `${value} 以上`;
}
if (upper != null && lower == null) {
const value = formatTemperatureValue(upper, tempSymbol);
return isEn ? `${value} or lower` : `${value} 以下`;
}
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
}
return formatThreshold(row, tempSymbol);
}
@@ -1,46 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
export function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
const sources = row.model_cluster_sources || {};
return Object.entries(sources)
.filter(([, value]) => value != null && Number.isFinite(Number(value)))
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => ({
name,
value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
}));
}
export function formatModelClusterRange(
sources?: Record<string, number | null> | null,
tempSymbol?: string | null,
) {
const values = Object.values(sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (!values.length) return "--";
const low = Math.min(...values);
const high = Math.max(...values);
if (Math.abs(low - high) < 0.05) {
return formatTemperatureValue(low, tempSymbol, { digits: 1 });
}
return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`;
}
export function getModelSourceSummary(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const sources = formatModelSources(row, tempSymbol);
if (!sources.length) {
return locale === "en-US"
? "model cluster pending"
: "模型集群暂未回传";
}
const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
return locale === "en-US"
? `all models: ${shown}`
: `全部模型:${shown}`;
}
@@ -1,328 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import {
formatAirportReportRead,
formatAirportWeatherRead,
} from "./opportunity-airport-read";
import { formatMinuteSpan } from "./opportunity-format";
import { getTargetRange } from "./opportunity-target";
export type ObservationPoint = { time?: string; temp?: number | null };
export function normalizeObservationPoints(points?: ObservationPoint[] | null) {
if (!Array.isArray(points)) return [];
return points
.map((point) => ({
time: String(point?.time || "").trim(),
temp:
point?.temp != null && Number.isFinite(Number(point.temp))
? Number(point.temp)
: null,
}))
.filter((point): point is { time: string; temp: number } =>
Boolean(point.time && point.temp != null),
)
.sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
}
export function getObservationSortMinutes(time: string) {
const parsed = Date.parse(time);
if (Number.isFinite(parsed)) {
const date = new Date(parsed);
return date.getUTCHours() * 60 + date.getUTCMinutes();
}
const match = time.match(/(\d{1,2}):(\d{2})/);
if (!match) return Number.MAX_SAFE_INTEGER;
return Number(match[1]) * 60 + Number(match[2]);
}
export function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
const isEn = locale === "en-US";
const phase = String(row.window_phase || "").toLowerCase();
const label = String(row.peak_window_label || "").trim();
const untilStart =
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
? Number(row.minutes_until_peak_start)
: null;
const untilEnd =
row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
? Number(row.minutes_until_peak_end)
: null;
const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}`;
}
if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
}
if (untilStart != null && untilStart > 0) {
return isEn
? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
: `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
}
if (phase === "early_today" || phase === "setup_today") {
return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
}
return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}`) : null;
}
export function firstNonEmptyPoints(...groups: Array<ReturnType<typeof normalizeObservationPoints>>) {
return groups.find((group) => group.length > 0) || [];
}
export function getMetarObservationContext(
row: ScanOpportunityRow,
detail: CityDetail | null,
) {
const context = row.metar_context || {};
const metarToday = firstNonEmptyPoints(
normalizeObservationPoints(context.today_obs),
normalizeObservationPoints(row.metar_today_obs),
);
const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
const metarRecent = firstNonEmptyPoints(
normalizeObservationPoints(context.recent_obs),
normalizeObservationPoints(row.metar_recent_obs),
);
const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
const settlementToday = firstNonEmptyPoints(
normalizeObservationPoints(context.settlement_today_obs),
normalizeObservationPoints(row.settlement_today_obs),
);
const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
const primaryPoints =
metarToday.length
? metarToday
: detailMetarToday.length
? detailMetarToday
: metarRecent.length
? metarRecent
: detailMetarRecent.length
? detailMetarRecent
: settlementToday.length
? settlementToday
: detailSettlementToday;
const trendPoints =
(metarRecent.length
? metarRecent
: detailMetarRecent.length
? detailMetarRecent
: primaryPoints.slice(-4));
const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
(best, point) => (!best || point.temp >= best.temp ? point : best),
null,
);
const trendFirst = trendPoints[0] || null;
const trendLast = trendPoints[trendPoints.length - 1] || null;
const trendDelta =
context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
? Number(context.trend_delta)
: trendFirst && trendLast && trendPoints.length >= 2
? trendLast.temp - trendFirst.temp
: null;
const lastTemp =
explicitLast != null && Number.isFinite(Number(explicitLast))
? Number(explicitLast)
: lastPoint?.temp ?? null;
const maxTemp =
context.max_temp != null && Number.isFinite(Number(context.max_temp))
? Number(context.max_temp)
: maxPoint?.temp ?? null;
const stale =
context.stale_for_today === true ||
row.metar_status?.stale_for_today === true ||
detail?.metar_status?.stale_for_today === true;
return {
points: primaryPoints,
lastTime: String(context.last_time || lastPoint?.time || ""),
lastTemp,
maxTime: String(context.max_time || maxPoint?.time || ""),
maxTemp,
trendDelta,
stale,
station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
};
}
export function getMetarGate(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const side = String(row.side || "").toLowerCase();
const selectedDate = String(row.selected_date || "");
const localDate = String(row.local_date || detail?.local_date || "");
const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
if (futureContract) return null;
const obs = getMetarObservationContext(row, detail);
const evidence: string[] = [];
const unit = normalizeTemperatureSymbol(tempSymbol);
const peakTiming = formatPeakWindowTiming(row, locale);
const airportReport = formatAirportReportRead(row, detail, locale, unit);
const airportWeatherRead = formatAirportWeatherRead(row, detail, locale);
if (peakTiming) evidence.push(peakTiming);
if (airportReport) evidence.push(airportReport);
if (airportWeatherRead) evidence.push(airportWeatherRead);
if (obs.lastTemp != null) {
evidence.push(
`${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`,
);
}
if (obs.maxTemp != null) {
evidence.push(
`${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
);
}
if (obs.trendDelta != null) {
evidence.push(
`${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
);
}
if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
if (obs.stale || !obs.points.length || obs.maxTemp == null) {
return {
decision: "downgrade" as const,
reason: isEn
? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
: "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
evidence,
};
}
const { lower, upper } = getTargetRange(row);
if (lower == null && upper == null) {
return {
decision: "watchlist" as const,
reason: isEn
? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
: "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
evidence,
};
}
const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
const trendDelta = obs.trendDelta;
const isNotRising = trendDelta != null && trendDelta <= epsilon;
const isFalling = trendDelta != null && trendDelta <= -epsilon;
const phase = String(row.window_phase || "").toLowerCase();
const remaining =
row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
? Number(row.remaining_window_minutes)
: null;
const minutesUntilPeakStart =
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
? Number(row.minutes_until_peak_start)
: null;
const lateWindow =
phase === "active_peak" ||
phase === "post_peak" ||
(remaining != null && remaining <= 180);
const beforePeak =
phase === "early_today" ||
phase === "setup_today" ||
phase === "tomorrow" ||
phase === "week_ahead" ||
(minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
const belowLower = lower != null && obs.maxTemp < lower - epsilon;
const insideBucket =
(lower == null || obs.maxTemp >= lower - epsilon) &&
(upper == null || obs.maxTemp <= upper + epsilon);
if (side === "no") {
if (aboveUpper) {
return {
decision: "approve" as const,
reason: isEn
? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
: "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
evidence,
};
}
if (belowLower && (lateWindow || isFalling || isNotRising)) {
if (beforePeak && !lateWindow) {
return {
decision: "watchlist" as const,
reason: isEn
? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
: "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
evidence,
};
}
return {
decision: "approve" as const,
reason: isEn
? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
: "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
evidence,
};
}
if (insideBucket && lateWindow && isNotRising) {
return {
decision: "downgrade" as const,
reason: isEn
? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
: "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
evidence,
};
}
}
if (side === "yes") {
if (aboveUpper) {
return {
decision: "veto" as const,
reason: isEn
? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
: "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
evidence,
};
}
if (belowLower && (lateWindow || isFalling || isNotRising)) {
if (beforePeak && !lateWindow) {
return {
decision: "watchlist" as const,
reason: isEn
? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
: "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
evidence,
};
}
return {
decision: "downgrade" as const,
reason: isEn
? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
: "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
evidence,
};
}
if (insideBucket) {
return {
decision: "approve" as const,
reason: isEn
? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
: "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
evidence,
};
}
}
return {
decision: "watchlist" as const,
reason: isEn
? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
: "METAR 还没有给出干净的最终确认,AI 暂列观察。",
evidence,
};
}
@@ -1,48 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { normalizeTemperatureLabel } from "@/lib/temperature-utils";
export function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
return normalizeTemperatureLabel(value, tempSymbol)
.toLowerCase()
.replace(/\s+/g, "")
.replace(/℃/g, "°c");
}
export function extractNumbers(value?: string | null) {
return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
Number(match[0]),
);
}
export function getTargetRange(row: ScanOpportunityRow) {
const lower =
row.target_lower != null && Number.isFinite(Number(row.target_lower))
? Number(row.target_lower)
: null;
const upper =
row.target_upper != null && Number.isFinite(Number(row.target_upper))
? Number(row.target_upper)
: null;
if (lower != null || upper != null) return { lower, upper };
const rawLabel = String(row.target_label || row.action || "");
const numbers = extractNumbers(rawLabel);
if (numbers.length >= 2) {
return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) };
}
const value =
row.target_threshold ??
row.target_value ??
(numbers.length ? numbers[0] : null);
if (value == null || !Number.isFinite(Number(value))) {
return { lower: null, upper: null };
}
const numeric = Number(value);
if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) {
return { lower: numeric, upper: null };
}
if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) {
return { lower: null, upper: numeric };
}
return { lower: numeric, upper: numeric };
}
@@ -1,250 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getTodayPaceView } from "@/lib/pace-utils";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import {
formatAirportReportRead,
formatAirportWeatherRead,
} from "./opportunity-airport-read";
import { getLocalizedRowText } from "./opportunity-copy";
import { formatPeakWindowTiming } from "./opportunity-observation";
import { getModelSourceSummary } from "./opportunity-model-summary";
import { getTargetRange } from "./opportunity-target";
import type { OpportunityGroup } from "./opportunity-groups";
import type { V4CityForecast } from "./opportunity-v4-types";
export function getPaceDeviationRead(
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
if (!detail) return null;
const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN");
if (!paceView) return null;
const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol);
const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 });
const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 });
const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`;
const isEn = locale === "en-US";
const toneText =
paceView.biasTone === "warm"
? isEn
? "running hotter"
: "偏热"
: paceView.biasTone === "cold"
? isEn
? "running cooler"
: "偏冷"
: isEn
? "tracking"
: "基本跟踪";
return {
adjustedHigh: paceView.paceAdjustedHigh,
delta: paceView.delta,
label: paceView.badge,
read: isEn
? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).`
: `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}${toneText})。`,
tone: paceView.biasTone,
};
}
export function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
const isEn = locale === "en-US";
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) {
return isEn ? "Path pending" : "路径待确认";
}
const unit = normalizeTemperatureSymbol(tempSymbol);
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`;
if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`;
return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`;
}
export function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return "";
const isEn = locale === "en-US";
const unit = normalizeTemperatureSymbol(tempSymbol);
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
if (forecast.paceTone === "warm") {
return isEn
? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.`
: ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`;
}
if (forecast.paceTone === "cold") {
return isEn
? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.`
: ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`;
}
return isEn
? " Observations are still tracking the DEB path."
: " 实测仍基本跟踪 DEB 路径。";
}
export 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 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function getV4CityForecast(
row: ScanOpportunityRow,
group: OpportunityGroup,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
): V4CityForecast {
const isEn = locale === "en-US";
const aiPredicted =
row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
? Number(row.ai_predicted_max)
: null;
const aiLow =
row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
? Number(row.ai_predicted_low)
: null;
const aiHigh =
row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
? Number(row.ai_predicted_high)
: null;
const modelValues = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
const fallbackPredicted =
aiPredicted ??
(row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: median(modelValues));
const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
const peakWindow =
getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
formatPeakWindowTiming(row, locale);
const airportRead =
getLocalizedRowText(
row,
locale,
row.ai_airport_metar_read_zh,
row.ai_airport_metar_read_en,
) || formatAirportReportRead(row, detail, locale, tempSymbol);
const weatherRead = formatAirportWeatherRead(row, detail, locale);
const paceRead = getPaceDeviationRead(detail, locale, tempSymbol);
const modelNote =
row.ai_city_model_cluster_note ||
row.ai_model_cluster_note ||
getModelSourceSummary(row, locale, tempSymbol);
const reason =
getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) ||
(fallbackPredicted != null
? isEn
? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.`
: `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
: null);
return {
predicted: fallbackPredicted,
low: fallbackLow,
high: fallbackHigh,
confidence: row.ai_forecast_confidence || row.ai_city_confidence,
peakWindow,
airportRead,
weatherRead,
paceRead: paceRead?.read || null,
paceTone: paceRead?.tone || null,
paceDelta: paceRead?.delta ?? null,
paceAdjustedHigh: paceRead?.adjustedHigh ?? null,
reason,
modelNote,
source: aiPredicted != null ? "ai" : "fallback",
};
}
export function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
if (forecast.low == null && forecast.high == null) return "--";
if (forecast.low != null && forecast.high != null) {
if (Math.abs(forecast.low - forecast.high) < 0.05) {
return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
}
return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
}
if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
}
export function getForecastContractFit(
row: ScanOpportunityRow,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
const explicitReason = getLocalizedRowText(
row,
locale,
row.ai_forecast_match_reason_zh,
row.ai_forecast_match_reason_en,
);
if (explicitMatch && explicitReason) {
return {
label:
explicitMatch === "core"
? isEn ? "Core bucket" : "核心桶"
: explicitMatch === "outside"
? isEn ? "Outside forecast" : "预测区间外"
: explicitMatch === "edge"
? isEn ? "Boundary bucket" : "边界桶"
: isEn ? "Watch" : "观察",
tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
reason: explicitReason,
};
}
const { lower, upper } = getTargetRange(row);
const predicted = forecast.predicted;
if (predicted == null || (lower == null && upper == null)) {
return {
label: isEn ? "Await forecast" : "等待预测",
tone: "watchlist",
reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
};
}
const unit = normalizeTemperatureSymbol(tempSymbol);
const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
const inside =
(lower == null || predicted >= lower - tolerance) &&
(upper == null || predicted <= upper + tolerance);
const rangeOverlaps =
forecast.low != null &&
forecast.high != null &&
(lower == null || forecast.high >= lower - tolerance) &&
(upper == null || forecast.low <= upper + tolerance);
if (inside) {
return {
label: isEn ? "Core bucket" : "核心桶",
tone: "approve",
reason: isEn
? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
: `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
};
}
if (rangeOverlaps) {
return {
label: isEn ? "Boundary bucket" : "边界桶",
tone: "watchlist",
reason: isEn
? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
: `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
};
}
return {
label: isEn ? "Outside forecast" : "预测区间外",
tone: "veto",
reason: isEn
? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
: `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
};
}
@@ -1,26 +0,0 @@
export type V4TradeDecision = {
decision: "approve" | "downgrade" | "veto" | "watchlist";
label: string;
tone: "approve" | "downgrade" | "veto" | "watchlist";
reason: string;
metarSummary?: string | null;
airportReport?: string | null;
metarEvidence: string[];
};
export type V4CityForecast = {
predicted: number | null;
low: number | null;
high: number | null;
confidence?: string | null;
peakWindow?: string | null;
airportRead?: string | null;
weatherRead?: string | null;
paceRead?: string | null;
paceTone?: "warm" | "cold" | "neutral" | string | null;
paceDelta?: number | null;
paceAdjustedHigh?: number | null;
reason?: string | null;
modelNote?: string | null;
source: "ai" | "fallback";
};
@@ -1,59 +0,0 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
export type PhaseMeta = {
label: string;
tone: "green" | "amber" | "blue" | "red";
};
export function getWindowPhaseMeta(
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
locale: string,
): PhaseMeta {
const mode = String(row.window_phase || "").toLowerCase();
if (mode === "city_snapshot") {
return {
label: locale === "en-US" ? "City Snapshot" : "城市概况",
tone: "blue",
};
}
if (mode === "active_peak") {
return {
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
tone: "red",
};
}
if (mode === "setup_today") {
return {
label: locale === "en-US" ? "Touch Play" : "触达博弈",
tone: "red",
};
}
if (mode === "early_today") {
return {
label: locale === "en-US" ? "Early Today" : "日内早段",
tone: "blue",
};
}
if (mode === "tomorrow" || mode === "week_ahead") {
return {
label: locale === "en-US" ? "Early" : "早期机会",
tone: "blue",
};
}
if (mode === "post_peak") {
return {
label: locale === "en-US" ? "Post Peak" : "峰后确认",
tone: "amber",
};
}
if (row.trend_alignment) {
return {
label: locale === "en-US" ? "Trend" : "趋势确认",
tone: "amber",
};
}
return {
label: locale === "en-US" ? "Tradable" : "可交易",
tone: "green",
};
}
@@ -2,14 +2,9 @@
Consolidates 20 CSS Modules that are always co-imported into
a single className, keeping ScanTerminalDashboard.tsx lean. */
/* Barrel: pre-combined scan-terminal root class name. */
import clsx from "clsx";
import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
import dashboardShellStyles from "./DashboardShell.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import detailContentStyles from "./DetailPanelContent.module.css";
import detailSectionsStyles from "./DetailPanelSections.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
@@ -23,8 +18,6 @@ import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
export const scanRootClass = clsx(
dashboardShellStyles.root,
dashboardModalGuideStyles.root,
scanTerminalStyles.root,
scanTerminalShellStyles.root,
scanTerminalFiltersStyles.root,
@@ -36,8 +29,4 @@ export const scanRootClass = clsx(
scanTerminalCardStyles.root,
scanTerminalContinentStyles.root,
scanTerminalMobileStyles.root,
detailChromeStyles.root,
detailContentStyles.root,
detailSectionsStyles.root,
modalChromeStyles.root,
);
@@ -1,567 +0,0 @@
"use client";
import clsx from "clsx";
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 { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
import {
buildWeatherDecisionView,
resolveExpectedHighCandidate,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
import {
getAiReadCopy,
getCityLoadingCopy,
} from "@/components/dashboard/scan-terminal/decision-copy";
import { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
import {
useAiCityForecast,
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getModelView } from "@/lib/model-utils";
import { getTodayPaceView } from "@/lib/pace-utils";
import { formatTemperatureValue } from "@/lib/temperature-utils";
function toFiniteDecisionNumber(value: unknown) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function getRowModelEntries(row: ScanOpportunityRow | null) {
const sources = row?.model_cluster_sources;
if (!sources || typeof sources !== "object") return [];
return Object.entries(sources)
.map(([name, value]) => [name, Number(value)] as const)
.filter(([, value]) => Number.isFinite(value));
}
function parseEpochMs(value: unknown) {
if (value == null || value === "") return null;
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000;
const parsed = new Date(String(value));
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
}
function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) {
const offsetSeconds = Number(detail?.utc_offset_seconds);
const epochMs =
parseEpochMs(detail?.airport_current?.report_time) ??
parseEpochMs(detail?.airport_current?.obs_time_epoch) ??
parseEpochMs(detail?.airport_current?.obs_time) ??
parseEpochMs(detail?.current?.report_time) ??
parseEpochMs(detail?.current?.obs_time_epoch) ??
parseEpochMs(detail?.current?.obs_time);
if (epochMs != null) {
const utc = new Date(epochMs);
const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String(
utc.getUTCMinutes(),
).padStart(2, "0")}Z`;
if (Number.isFinite(offsetSeconds)) {
const local = new Date(epochMs + offsetSeconds * 1000);
const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String(
local.getUTCMinutes(),
).padStart(2, "0")}`;
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
}
return zText;
}
const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i);
if (!rawToken) return "";
const zText = `${rawToken[2]}:${rawToken[3]}Z`;
if (!Number.isFinite(offsetSeconds)) return zText;
const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]);
if (!Number.isFinite(utcMinutes)) return zText;
const localMinutes = Math.round(
((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440,
);
const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String(
localMinutes % 60,
).padStart(2, "0")}`;
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
}
function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) {
if (!text || !displayTime) return text;
const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`;
return text
.replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
.replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
.replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`);
}
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
function formatFreshnessAge(value: unknown, isEn: boolean) {
const minutes = Number(value);
if (!Number.isFinite(minutes) || minutes < 0) return "";
if (minutes < 1) return isEn ? "just now" : "刚刚";
if (minutes < 60) {
const rounded = Math.max(1, Math.round(minutes));
return isEn ? `${rounded}m ago` : `${rounded} 分钟前`;
}
const hours = Math.floor(minutes / 60);
const remaining = Math.round(minutes % 60);
if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`;
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
}
function formatUpdateTime(value: unknown, locale: string) {
const epochMs = parseEpochMs(value);
if (epochMs == null) return "";
const date = new Date(epochMs);
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
if (locale === "en-US") {
const day = sameDay
? "today"
: date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
return `${day} ${time} updated`;
}
const day = sameDay
? "今日"
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
return `${day} ${time} 更新`;
}
function buildObservationFreshnessValue({
detail,
displayTime,
isEn,
isHkoObservation,
}: {
detail: CityDetail | null;
displayTime: string;
isEn: boolean;
isHkoObservation: boolean;
}) {
const stale = Boolean(
detail?.metar_status?.stale_for_today ||
detail?.airport_current?.stale_for_today ||
detail?.current?.observation_status === "stale",
);
if (stale) {
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
}
const ageLabel = formatFreshnessAge(
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
isEn,
);
if (ageLabel) return ageLabel;
if (displayTime) return displayTime;
return isEn ? "time pending" : "时间待确认";
}
function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) {
return (
formatUpdateTime(detail?.updated_at, locale) ||
(isEn ? "latest run loaded" : "已加载最新模型")
);
}
export function AiPinnedCityCard({
item,
detail,
row,
locale,
collapsed,
removing,
onRefreshCityDetail,
onRemove,
onToggleCollapsed,
}: {
item: AiPinnedCity;
detail: CityDetail | null;
row: ScanOpportunityRow | null;
locale: string;
collapsed: boolean;
removing?: boolean;
onRefreshCityDetail: (cityName: string) => Promise<void>;
onRemove: () => void;
onToggleCollapsed: () => void;
}) {
const isEn = locale === "en-US";
const displayName =
detail?.display_name ||
row?.city_display_name ||
row?.display_name ||
item.displayName ||
item.cityName;
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
const modelView = detail ? getModelView(detail, detail.local_date) : null;
const detailModelEntries = modelView
? Object.entries(modelView.models || {})
.map(([name, value]) => [name, Number(value)] as const)
.filter(([, value]) => Number.isFinite(value))
: [];
const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row);
const modelValues = modelEntries.map(([, value]) => value);
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
const peakWindow =
paceView?.peakWindowText ||
(row ? getPeakWindowLabel(row) : null) ||
"--";
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
const isHkoObservation = isHkoObservationCity(detail);
const displayAirportPrimary = getDisplayAirportPrimary(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp ?? row?.current_temp
: displayAirportPrimary?.temp ??
detail?.airport_current?.temp ??
detail?.current?.temp ??
row?.current_temp) ?? null;
const debNumber = toFiniteDecisionNumber(deb);
const currentTempNumber = toFiniteDecisionNumber(currentTemp);
const modelRange =
modelMin != null && modelMax != null
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
: "--";
const paceTone = paceView?.biasTone || "neutral";
const paceText =
paceView?.summary ||
(isEn
? "Waiting for intraday observations to compare against the DEB path."
: "等待更多日内实测,用来对照 DEB 预测路径。");
const report = isHkoObservation
? ""
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
const detailCityName = detail?.name || item.cityName;
const [refreshingDetail, setRefreshingDetail] = useState(false);
const { aiForecast, refreshAiForecast } = useAiCityForecast({
detail,
detailCityName,
enabled: Boolean(detail),
isEn,
locale,
report,
});
const isRefreshing = refreshingDetail || aiForecast.status === "loading";
const [isCompactCard, setIsCompactCard] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia("(max-width: 820px)");
const syncCompactMode = () => setIsCompactCard(media.matches);
syncCompactMode();
media.addEventListener("change", syncCompactMode);
return () => media.removeEventListener("change", syncCompactMode);
}, []);
const aiCityForecast = aiForecast.payload?.city_forecast || null;
const localizedFinalJudgment =
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
const localizedMetarRead =
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
"";
const localizedReasoning =
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
"";
const localizedModelNote =
(isEn
? aiCityForecast?.model_cluster_note_en
: aiCityForecast?.model_cluster_note_zh) || "";
const modelPreview = modelEntries
.slice(0, 4)
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
.join(isEn ? " / " : " / ");
const localModelSupportNote = modelEntries.length
? isEn
? modelEntries.length <= 2
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.`
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
: modelEntries.length <= 2
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `${modelPreview}` : ""},需要更重视 DEB 路径和${observationSourceZh}`
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}`
: isEn
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}`;
const aiPredictedMax =
aiForecast.status === "ready"
? toFiniteDecisionNumber(aiCityForecast?.predicted_max)
: null;
const aiRangeLow =
toFiniteDecisionNumber(aiCityForecast?.range_low) ??
toFiniteDecisionNumber(row?.ai_predicted_low) ??
modelMin;
const aiRangeHigh =
toFiniteDecisionNumber(aiCityForecast?.range_high) ??
toFiniteDecisionNumber(row?.ai_predicted_high) ??
modelMax;
const aiConfidence =
String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() || null;
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
aiPredictedMax,
currentTemp: currentTempNumber,
deb: debNumber,
modelMax,
modelMin,
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
});
const decisionView = buildWeatherDecisionView({
aiCityForecast,
currentTemp: currentTempNumber,
deb: debNumber,
isEn,
localModelSupportNote,
modelEntries,
modelMax,
modelMin,
paceTone,
paceView,
peakWindow,
tempSymbol,
});
const expectedHighText =
decisionExpectedHighNumber != null
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
: "--";
const debText =
debNumber != null
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
: "--";
const aiMeta = aiCityForecast?._polyweather_meta || null;
const guardReason = aiMeta?.deterministic_guard_reason || {};
const observationStale = Boolean(
detail?.metar_status?.stale_for_today ||
detail?.airport_current?.stale_for_today ||
detail?.current?.observation_status === "stale" ||
guardReason.observation_stale,
);
const observedHighBreak = Boolean(
guardReason.observed_high_break ||
(currentTempNumber != null &&
modelMax != null &&
currentTempNumber > modelMax + 0.2),
);
const observedLowBreak = Boolean(guardReason.observed_low_break);
const observedLowLag = Boolean(guardReason.observed_low_lag);
const peakHasPassed = Boolean(
guardReason.peak_has_passed ||
["past", "post_peak", "after_peak"].includes(
String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(),
),
);
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
const modelHighlyConsistent =
modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2;
const needsNextBulletin =
!observationStale &&
!observedHighBreak &&
!observedLowBreak &&
!peakHasPassed &&
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
const aiRuleEvidenceMode = Boolean(
aiForecast.status === "failed" ||
(aiForecast.status === "ready" && !aiCityForecast) ||
aiForecast.payload?.degraded ||
aiMeta?.fallback,
);
const aiReadCopy = getAiReadCopy({ isEn, isHkoObservation });
const aiReadInProgressText = aiReadCopy.inProgress;
const aiReadCompleteText = aiReadCopy.complete;
const aiRuleEvidenceText = aiReadCopy.ruleEvidence;
const decisionState = buildCityDecisionState({
aiCityForecast,
aiForecast,
aiRuleEvidenceMode,
isEn,
isHkoObservation,
modelHighlyConsistent,
needsNextBulletin,
observationStale,
observedHighBreak,
observedLowBreak,
observedLowLag,
peakHasPassed,
});
const dataFreshnessRows = [
{
label: isEn ? "Models" : "模型",
value: buildModelFreshnessValue(detail, locale, isEn),
tone: "fresh" as const,
},
];
const freshnessSeparator = isEn ? ": " : "";
const localizedRisksRaw =
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
const localizedRisks = Array.isArray(localizedRisksRaw)
? localizedRisksRaw
: localizedRisksRaw
? [String(localizedRisksRaw)]
: [];
const aiBullets = [
localizedMetarRead,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
localizedModelNote || localModelSupportNote,
...localizedRisks,
].filter((line) => String(line || "").trim());
const fallbackAiReason =
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
aiForecast.payload?.reason ||
"";
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
const handleRefresh = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (refreshingDetail) return;
setRefreshingDetail(true);
void onRefreshCityDetail(detailCityName)
.catch(() => {})
.finally(() => {
refreshAiForecast();
setRefreshingDetail(false);
});
};
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRemove();
};
return (
<article
className={clsx(
"scan-ai-city-card",
isCompactCard && "scan-mobile-decision-card",
collapsed && !isCompactCard && "collapsed",
removing && "removing",
)}
tabIndex={-1}
data-ai-status={decisionState.aiStatus}
data-evidence-quality={decisionState.evidenceQuality}
data-recommendation={decisionState.recommendation}
data-urgency={decisionState.urgency}
>
{isCompactCard ? (
<MobileDecisionCard
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiConfidence={aiConfidence}
aiForecast={aiForecast}
aiPredictedMax={aiPredictedMax}
aiRangeHigh={aiRangeHigh}
aiRangeLow={aiRangeLow}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
debPrediction={debNumber}
decisionState={decisionState}
detail={detail}
displayName={displayName}
expectedHighText={expectedHighText}
fallbackAiReason={fallbackAiReason}
isEn={isEn}
isHkoObservation={isHkoObservation}
isRefreshing={isRefreshing}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
onRefresh={handleRefresh}
onRemove={handleRemove}
peakWindow={peakWindow}
removing={removing}
tempSymbol={tempSymbol}
/>
) : (
<>
<CityCardHeader
aiStatusLabel={decisionState.aiStatusLabel}
aiStatusTone={decisionState.aiStatusTone}
collapseId={collapseId}
collapsed={collapsed}
debText={debText}
detailLocalTime={detail?.local_time}
displayName={displayName}
expectedHighText={expectedHighText}
isEn={isEn}
isRefreshing={isRefreshing}
modelRange={modelRange}
onRefresh={handleRefresh}
onRemove={handleRemove}
onToggleCollapsed={onToggleCollapsed}
peakWindow={peakWindow}
removing={removing}
rowLocalTime={row?.local_time}
statusTags={decisionState.badges}
/>
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<WeatherDecisionBand
decisionView={decisionView}
decisionWhyText={decisionState.primaryReason}
isEn={isEn}
paceDeltaText={paceView?.deltaText || "--"}
/>
<div className="scan-ai-city-analysis-grid">
<AiCityTemperatureChart detail={detail} />
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiConfidence={aiConfidence}
aiForecast={aiForecast}
aiPredictedMax={aiPredictedMax}
aiRangeHigh={aiRangeHigh}
aiRangeLow={aiRangeLow}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
debPrediction={debNumber}
fallbackAiReason={fallbackAiReason}
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
tempSymbol={tempSymbol}
/>
</div>
<ModelEvidencePanel detail={detail} isEn={isEn} />
</div>
) : !detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={loadingCopy.title}
description={loadingCopy.description}
compact
/>
</div>
) : null}
</>
)}
</article>
);
}
@@ -1,183 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AiPinnedCityCard } from "@/components/dashboard/scan-terminal/AiPinnedCityCard";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import { findRowForCity, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
export function AiPinnedForecastView({
items,
rows,
detailsByName,
locale,
onRefreshCityDetail,
onRemoveCity,
}: {
items: AiPinnedCity[];
rows: ScanOpportunityRow[];
detailsByName: Record<string, CityDetail>;
locale: string;
onRefreshCityDetail: (cityName: string) => Promise<void>;
onRemoveCity: (cityName: string) => void;
}) {
const isEn = locale === "en-US";
const [expandedCityKey, setExpandedCityKey] = useState<string | null>(null);
const [removingCities, setRemovingCities] = useState<Set<string>>(
() => new Set(),
);
const cityCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const knownCityKeysRef = useRef<Set<string>>(new Set());
const previousFirstCityKeyRef = useRef<string | null>(null);
const removeTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
const activeKeyList = items.map(
(item) => normalizeCityKey(item.cityName) || item.cityName,
);
const activeKeys = new Set(activeKeyList);
const firstCityKey = activeKeyList[0] ?? null;
const hasNewCity = activeKeyList.some((key) => !knownCityKeysRef.current.has(key));
const firstCityChanged = firstCityKey !== previousFirstCityKeyRef.current;
setExpandedCityKey((current) => {
if (!firstCityKey) return null;
if (hasNewCity || firstCityChanged) return firstCityKey;
if (current && activeKeys.has(current)) return current;
return firstCityKey;
});
Object.keys(cityCardRefs.current).forEach((key) => {
if (!activeKeys.has(key)) {
delete cityCardRefs.current[key];
}
});
knownCityKeysRef.current = activeKeys;
previousFirstCityKeyRef.current = firstCityKey;
}, [items]);
useEffect(() => {
return () => {
removeTimersRef.current.forEach((timer) => clearTimeout(timer));
removeTimersRef.current.clear();
};
}, []);
const removeCityWithMotion = useCallback(
(item: AiPinnedCity, stableKey: string) => {
if (removeTimersRef.current.has(stableKey)) return;
setRemovingCities((current) => {
const next = new Set(current);
next.add(stableKey);
return next;
});
const timer = setTimeout(() => {
onRemoveCity(item.cityName);
setRemovingCities((current) => {
const next = new Set(current);
next.delete(stableKey);
return next;
});
removeTimersRef.current.delete(stableKey);
}, 260);
removeTimersRef.current.set(stableKey, timer);
},
[onRemoveCity],
);
const scrollToCity = useCallback((stableKey: string) => {
cityCardRefs.current[stableKey]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, []);
const focusCity = useCallback((stableKey: string) => {
setExpandedCityKey(stableKey);
window.requestAnimationFrame(() => scrollToCity(stableKey));
}, [scrollToCity]);
if (!items.length) {
return (
<div className="scan-ai-workspace empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Select a city from the market list" : "从市场列表选择城市"}
</div>
<div className="scan-empty-copy">
{isEn
? "Selected cities will appear here as deep analysis blocks."
: "选中的城市会加入深度分析页,并保留为城市分析区块。"}
</div>
</div>
</div>
);
}
return (
<div className="scan-ai-workspace">
<div className="scan-ai-workspace-head">
<div>
<span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
<strong>
{isEn
? `${items.length} cities under deep analysis`
: `${items.length} 个城市正在深度分析`}
</strong>
</div>
<p>
{isEn
? "Market-list selections add cities here. City analysis stays here until you remove it."
: "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"}
</p>
</div>
<div className="scan-ai-city-jumpbar" aria-label={isEn ? "Selected city shortcuts" : "已选城市快捷跳转"}>
{items.map((item) => {
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
return (
<button
key={stableKey}
type="button"
onClick={() => focusCity(stableKey)}
>
{item.cityName}
</button>
);
})}
</div>
<div className="scan-ai-city-stack">
{items.map((item) => {
const detail = findDetailForCity(detailsByName, item.cityName);
const row = findRowForCity(rows, item.cityName);
const key = normalizeCityKey(item.cityName);
const stableKey = key || item.cityName;
return (
<div
key={stableKey}
ref={(node) => {
cityCardRefs.current[stableKey] = node;
}}
className="scan-ai-city-anchor"
>
<AiPinnedCityCard
item={item}
detail={detail}
row={row}
locale={locale}
collapsed={expandedCityKey !== stableKey}
removing={removingCities.has(stableKey)}
onRefreshCityDetail={onRefreshCityDetail}
onRemove={() => removeCityWithMotion(item, stableKey)}
onToggleCollapsed={() => {
setExpandedCityKey((current) =>
current === stableKey ? null : stableKey,
);
}}
/>
</div>
);
})}
</div>
</div>
);
}
@@ -1,190 +0,0 @@
"use client";
import { RefreshCw, X } from "lucide-react";
import type { MouseEvent } from "react";
import { useState } from "react";
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
import {
CityStatusTags,
type CityStatusTag,
type StatusTone,
} from "@/components/dashboard/scan-terminal/CityStatusTags";
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
import type { CityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
import {
getCityLoadingCopy,
getMobileDecisionCopy,
} from "@/components/dashboard/scan-terminal/decision-copy";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
export function MobileDecisionCard({
aiBullets,
aiCityForecast,
aiConfidence,
aiForecast,
aiPredictedMax,
aiRangeHigh,
aiRangeLow,
aiReadCompleteText,
aiReadInProgressText,
aiRuleEvidenceMode,
aiRuleEvidenceText,
debPrediction,
decisionState,
detail,
displayName,
expectedHighText,
fallbackAiReason,
isEn,
isHkoObservation,
isRefreshing,
localModelSupportNote,
localizedFinalJudgment,
onRefresh,
onRemove,
peakWindow,
removing,
tempSymbol,
}: {
aiBullets: string[];
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
aiConfidence: string | null;
aiForecast: AiCityForecastState;
aiPredictedMax: number | null;
aiRangeHigh: number | null;
aiRangeLow: number | null;
aiReadCompleteText: string;
aiReadInProgressText: string;
aiRuleEvidenceMode: boolean;
aiRuleEvidenceText: string;
debPrediction: number | null;
decisionState: CityDecisionState;
detail: CityDetail | null;
displayName: string;
expectedHighText: string;
fallbackAiReason: string;
isEn: boolean;
isHkoObservation: boolean;
isRefreshing: boolean;
localModelSupportNote: string;
localizedFinalJudgment: string;
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
peakWindow: string;
removing?: boolean;
tempSymbol: string;
}) {
const copy = getMobileDecisionCopy(isEn);
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
const [modelOpen, setModelOpen] = useState(false);
const [chartOpen, setChartOpen] = useState(false);
const statusTags: CityStatusTag[] = decisionState.badges.length
? decisionState.badges
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
return (
<>
<header className="scan-mobile-decision-head">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Mobile action card" : "移动端行动卡"}
</span>
<h3>{displayName}</h3>
</div>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={onRefresh}
aria-label={`${copy.refresh} ${displayName}`}
title={copy.refresh}
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={onRemove}
aria-label={`${copy.remove} ${displayName}`}
title={copy.remove}
disabled={removing}
>
<X size={15} />
</button>
</div>
</header>
<div className="scan-mobile-decision-metrics">
<span className="primary">
<small>{copy.expectedHigh}</small>
<b>{expectedHighText}</b>
</span>
<span>
<small>{copy.peakWindow}</small>
<b>{peakWindow}</b>
</span>
</div>
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
<CityStatusTags tags={statusTags} />
{!detail ? (
<div className="scan-ai-city-loading">
<LoadingSignal
title={loadingCopy.title}
description={loadingCopy.description}
compact
/>
</div>
) : (
<div className="scan-mobile-decision-folds">
<AiEvidencePanel
aiBullets={aiBullets}
aiCityForecast={aiCityForecast}
aiConfidence={aiConfidence}
aiForecast={aiForecast}
aiPredictedMax={aiPredictedMax}
aiRangeHigh={aiRangeHigh}
aiRangeLow={aiRangeLow}
aiReadCompleteText={aiReadCompleteText}
aiReadInProgressText={aiReadInProgressText}
aiRuleEvidenceMode={aiRuleEvidenceMode}
aiRuleEvidenceText={aiRuleEvidenceText}
debPrediction={debPrediction}
fallbackAiReason={fallbackAiReason}
isEn={isEn}
isHkoObservation={isHkoObservation}
localModelSupportNote={localModelSupportNote}
localizedFinalJudgment={localizedFinalJudgment}
tempSymbol={tempSymbol}
/>
<details
className="scan-ai-city-section scan-mobile-fold"
open={modelOpen}
onToggle={(event) => setModelOpen(event.currentTarget.open)}
>
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
</details>
<details
className="scan-ai-city-section scan-mobile-fold"
open={chartOpen}
onToggle={(event) => setChartOpen(event.currentTarget.open)}
>
<summary className="scan-ai-city-section-title">{copy.chart}</summary>
{chartOpen ? <AiCityTemperatureChart detail={detail} /> : null}
</details>
</div>
)}
</>
);
}
@@ -1,21 +0,0 @@
"use client";
import { ModelForecast } from "@/components/dashboard/PanelSections";
import type { CityDetail } from "@/lib/dashboard-types";
export function ModelEvidencePanel({
detail,
isEn,
}: {
detail: CityDetail;
isEn: boolean;
}) {
return (
<section className="scan-ai-city-section models">
<div className="scan-ai-city-section-title">
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
</div>
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
</section>
);
}
@@ -1,166 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import type { useDashboardStore } from "@/hooks/useDashboardStore";
import {
findDetailForCity,
isFullEnoughForDeepAnalysis,
} from "@/components/dashboard/scan-terminal/city-detail-utils";
import {
findRowForCity,
normalizeCityKey,
prettifyCityName,
} from "@/components/dashboard/scan-terminal/decision-utils";
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
type DashboardStore = ReturnType<typeof useDashboardStore>;
export function useAiPinnedCityWorkspace({
locale,
store,
timeSortedRows,
}: {
locale: string;
store: DashboardStore;
timeSortedRows: ScanOpportunityRow[];
}) {
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
const aiFullHydrationRef = useRef<Set<string>>(new Set());
const aiHydrationQueueRef = useRef<string[]>([]);
const aiHydrationRunningRef = useRef(false);
const aiHydrationRetriesRef = useRef<Map<string, number>>(new Map());
const runAiHydrationQueue = useCallback(async () => {
if (aiHydrationRunningRef.current) return;
aiHydrationRunningRef.current = true;
try {
while (aiHydrationQueueRef.current.length > 0) {
const nextCity = aiHydrationQueueRef.current.shift();
const key = normalizeCityKey(nextCity || "");
if (!nextCity || !key) continue;
try {
const detail = await store.ensureCityDetail(
nextCity,
false,
"full",
);
if (!isFullEnoughForDeepAnalysis(detail)) {
const retries = aiHydrationRetriesRef.current.get(key) || 0;
if (retries >= 3) {
aiFullHydrationRef.current.delete(key);
aiHydrationRetriesRef.current.delete(key);
} else {
aiHydrationRetriesRef.current.set(key, retries + 1);
}
}
} catch {
const retries = aiHydrationRetriesRef.current.get(key) || 0;
if (retries >= 3) {
aiFullHydrationRef.current.delete(key);
aiHydrationRetriesRef.current.delete(key);
} else {
aiHydrationRetriesRef.current.set(key, retries + 1);
}
}
}
} finally {
aiHydrationRunningRef.current = false;
if (aiHydrationQueueRef.current.length > 0) {
void runAiHydrationQueue();
}
}
}, [store.ensureCityDetail]);
const queueAiFullHydration = useCallback(
(cityName: string) => {
const key = normalizeCityKey(cityName);
if (!key || aiFullHydrationRef.current.has(key)) return;
aiFullHydrationRef.current.add(key);
aiHydrationQueueRef.current.push(cityName);
void runAiHydrationQueue();
},
[runAiHydrationQueue],
);
const addAiPinnedCity = useCallback((cityName: string) => {
const cleanName = String(cityName || "").trim();
const key = normalizeCityKey(cleanName);
if (!key) return;
const matchedRow = findRowForCity(timeSortedRows, cleanName);
const prettyName = prettifyCityName(cleanName);
const displayName =
matchedRow?.city_display_name ||
matchedRow?.display_name ||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
prettyName ||
cleanName;
// Clear the hydration guard so that re-selecting this city always
// triggers a fresh hydration attempt (fixes second-city loading failure).
aiFullHydrationRef.current.delete(key);
aiHydrationRetriesRef.current.delete(key);
setAiPinnedCities((current) => {
const existing = current.findIndex(
(item) => normalizeCityKey(item.cityName) === key,
);
const nextItem = {
cityName: matchedRow?.city || cleanName,
displayName,
addedAt: Date.now(),
};
if (existing >= 0) {
const next = [...current];
next[existing] = { ...next[existing], ...nextItem };
return [
next[existing],
...next.filter((_, index) => index !== existing),
];
}
return [nextItem, ...current].slice(0, 8);
});
queueAiFullHydration(matchedRow?.city || cleanName);
}, [locale, queueAiFullHydration, timeSortedRows]);
const removeAiPinnedCity = useCallback((cityName: string) => {
const key = normalizeCityKey(cityName);
aiFullHydrationRef.current.delete(key);
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
(queuedCity) => normalizeCityKey(queuedCity) !== key,
);
setAiPinnedCities((current) =>
current.filter((item) => normalizeCityKey(item.cityName) !== key),
);
}, []);
const refreshAiPinnedCityDetail = useCallback(
async (cityName: string) => {
const key = normalizeCityKey(cityName);
if (key) {
aiFullHydrationRef.current.delete(key);
}
await store.ensureCityDetail(cityName, true, "full");
},
[store.ensureCityDetail],
);
useEffect(() => {
aiPinnedCities.forEach((item) => {
const key = normalizeCityKey(item.cityName);
if (!key || aiFullHydrationRef.current.has(key)) return;
const retries = aiHydrationRetriesRef.current.get(key) || 0;
if (retries >= 3) return;
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
if (!needsFullHydration) return;
queueAiFullHydration(item.cityName);
});
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
return {
addAiPinnedCity,
aiPinnedCities,
refreshAiPinnedCityDetail,
removeAiPinnedCity,
};
}
File diff suppressed because it is too large Load Diff
-575
View File
@@ -1,575 +0,0 @@
"use client";
import {
CityDetail,
CityListItem,
CitySummary,
MarketScan,
ScanTerminalFilters,
ScanTerminalResponse,
} from "@/lib/dashboard-types";
import {
buildBrowserBackendHeaders,
fetchBackendApi,
} from "@/lib/backend-api";
import { formatHttpErrorMessage } from "@/lib/http-error";
const CACHE_KEY = "polyWeather_v2_chart_full_day";
const CACHE_TTL_MS = 30 * 60 * 1000;
const SCAN_TERMINAL_CLIENT_TIMEOUT_MS = 35_000;
const CITY_DETAIL_CLIENT_TIMEOUT_MS = 35_000;
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
const pendingCityMarketScanRequests = new Map<
string,
Promise<{
fetched_at?: string | null;
market_scan?: MarketScan | null;
selected_date?: string | null;
}>
>();
const pendingScanTerminalRequests = new Map<string, Promise<ScanTerminalResponse>>();
const pendingScanTerminalAiRequests = new Map<string, Promise<ScanTerminalResponse>>();
const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1";
type CityCacheMeta = {
cachedAt: number;
revision: string;
};
type CityCacheBundle = {
details: Record<string, CityDetail>;
meta: Record<string, CityCacheMeta>;
};
function normalizeCityName(cityName: string) {
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
}
function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") {
if (depth === "full") return "full";
if (depth === "nearby") return "nearby";
if (depth === "market") return "market";
return "panel";
}
async function fetchJson<T>(
url: string,
options?: { cache?: RequestCache; timeoutMs?: number },
): Promise<T> {
const timeoutMs = options?.timeoutMs;
const controller =
timeoutMs && typeof AbortController !== "undefined"
? new AbortController()
: null;
const timeoutId =
controller &&
typeof window !== "undefined" &&
typeof window.setTimeout === "function"
? window.setTimeout(() => controller.abort(), timeoutMs)
: null;
const headers = await buildBrowserBackendHeaders({
Accept: "application/json",
});
let response: Response;
try {
response = await fetchBackendApi(url, {
headers,
cache: options?.cache ?? "default",
signal: controller?.signal,
});
} catch (error) {
if (controller?.signal.aborted) {
throw new Error("Request timed out");
}
throw error;
} finally {
if (timeoutId != null) {
if (typeof window.clearTimeout === "function") {
window.clearTimeout(timeoutId);
}
}
}
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
formatHttpErrorMessage(response.status, response.statusText, body),
);
}
return response.json() as Promise<T>;
}
function isStaleMarketSlugResponse(payload: {
market_scan?: MarketScan | null;
} | null | undefined) {
const scan = payload?.market_scan;
const reason = String(scan?.reason || "").toLowerCase();
return (
scan?.available === false &&
(reason.includes("market_slug not found") ||
reason.includes("specified market_slug not found") ||
reason.includes("slug not found"))
);
}
function isClient() {
return typeof window !== "undefined";
}
function normalizeRevisionPart(value: unknown) {
return value == null ? "" : String(value);
}
export function getCityRevision(source?: CityDetail | CitySummary | null) {
if (!source) return "";
const modelDaily =
"multi_model_daily" in source && source.multi_model_daily
? source.multi_model_daily?.[source.local_date || ""]
: null;
const modelFootprint = modelDaily?.models || ("multi_model" in source ? source.multi_model : null);
const forecastFootprint =
"forecast" in source && Array.isArray(source.forecast?.daily)
? source.forecast.daily
.map((item) => `${normalizeRevisionPart(item?.date)}:${normalizeRevisionPart(item?.max_temp)}`)
.join("|")
: "";
const marketScan = "market_scan" in source ? source.market_scan : null;
const marketFootprint = marketScan
? [
normalizeRevisionPart(marketScan.selected_slug),
normalizeRevisionPart(marketScan.market_price),
normalizeRevisionPart(marketScan.yes_buy),
normalizeRevisionPart(marketScan.yes_sell),
normalizeRevisionPart(marketScan.no_buy),
normalizeRevisionPart(marketScan.no_sell),
normalizeRevisionPart(marketScan.price_analysis?.best_side),
normalizeRevisionPart(
Array.isArray(marketScan.all_buckets)
? marketScan.all_buckets
.slice(0, 6)
.map(
(bucket) =>
`${normalizeRevisionPart(bucket?.temp ?? bucket?.value)}:${normalizeRevisionPart(
bucket?.market_price ?? bucket?.yes_buy,
)}`,
)
.join(",")
: "",
),
].join("|")
: "";
return [
normalizeRevisionPart(source.updated_at),
normalizeRevisionPart(source.current?.obs_time),
normalizeRevisionPart(source.current?.temp),
normalizeRevisionPart(source.deb?.prediction),
normalizeRevisionPart(
modelFootprint && typeof modelFootprint === "object"
? Object.keys(modelFootprint)
.sort()
.map((key) => `${key}:${normalizeRevisionPart(modelFootprint[key])}`)
.join("|")
: "",
),
normalizeRevisionPart(forecastFootprint),
normalizeRevisionPart(marketFootprint),
].join("|");
}
export function toCitySummary(detail: CityDetail): CitySummary {
return {
name: detail.name,
display_name: detail.display_name,
icao: detail.risk?.icao,
local_time: detail.local_time,
temp_symbol: detail.temp_symbol,
current: {
obs_time: detail.current?.obs_time,
temp: detail.current?.temp,
},
deb: {
prediction: detail.deb?.prediction,
},
deviation_monitor: detail.deviation_monitor,
risk: {
level: detail.risk?.level,
warning: detail.risk?.warning,
},
updated_at: detail.updated_at,
};
}
function isFresh(meta?: CityCacheMeta | null) {
return Boolean(meta && Date.now() - meta.cachedAt < CACHE_TTL_MS);
}
function readLegacyCache(raw: string): CityCacheBundle {
const parsed = JSON.parse(raw) as {
timestamp?: number;
data?: Record<string, CityDetail>;
};
const details = parsed.data || {};
const cachedAt = parsed.timestamp || 0;
const freshDetails: Record<string, CityDetail> = {};
const meta: Record<string, CityCacheMeta> = {};
Object.entries(details).forEach(([cityName, detail]) => {
const nextMeta = {
cachedAt,
revision: getCityRevision(detail),
};
if (!isFresh(nextMeta)) return;
freshDetails[cityName] = detail;
meta[cityName] = nextMeta;
});
return { details: freshDetails, meta };
}
export const dashboardClient = {
clearCityDetailCache() {
if (!isClient()) return;
try {
window.sessionStorage?.removeItem(CACHE_KEY);
} catch {
// Storage can be unavailable in embedded/private browser contexts.
}
},
async getCities() {
const data = await fetchJson<{ cities?: CityListItem[] }>("/api/cities");
return data.cities || [];
},
sendPriorityWarmHint(timezone?: string | null) {
if (!isClient()) return;
if (
typeof fetch !== "function" ||
typeof Intl === "undefined" ||
!window.sessionStorage
) {
return;
}
const tz = String(
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
).trim();
if (!tz) return;
const cacheKey = `${PRIORITY_WARM_SESSION_KEY}:${tz}`;
try {
if (window.sessionStorage.getItem(cacheKey)) return;
window.sessionStorage.setItem(cacheKey, "1");
} catch {
return;
}
const params = new URLSearchParams({ timezone: tz });
void fetch(`/api/system/priority-warm?${params.toString()}`, {
method: "POST",
headers: { Accept: "application/json" },
cache: "default",
keepalive: true,
}).catch(() => {});
},
async getCitySummary(cityName: string, options?: { force?: boolean }) {
const force = options?.force ?? false;
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
const existing = pendingCitySummaryRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetchJson<CitySummary>(
`/api/city/${normalizeCityName(cityName)}/summary?force_refresh=${force}`,
force ? { cache: "no-store" } : undefined,
).finally(() => {
pendingCitySummaryRequests.delete(requestKey);
});
pendingCitySummaryRequests.set(requestKey, request);
return request;
},
async getCityDetail(
cityName: string,
options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" },
) {
const force = options?.force ?? false;
const depth = normalizeDetailDepth(options?.depth);
if (!force) {
const requestKey = `${cityName}::${depth}::cached`;
const existing = pendingCityDetailRequests.get(requestKey);
if (existing) {
return existing;
}
const request = fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?force_refresh=false&depth=${depth}`,
{ timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
).finally(() => {
pendingCityDetailRequests.delete(requestKey);
});
pendingCityDetailRequests.set(requestKey, request);
return request;
}
const params = new URLSearchParams({
force_refresh: "true",
depth,
_ts: String(Date.now()),
});
return fetchJson<CityDetail>(
`/api/city/${normalizeCityName(cityName)}?${params.toString()}`,
{ cache: "no-store", timeoutMs: CITY_DETAIL_CLIENT_TIMEOUT_MS },
);
},
async getCityMarketScan(
cityName: string,
options?: {
force?: boolean;
lite?: boolean;
marketSlug?: string | null;
targetDate?: string | null;
},
) {
const force = options?.force ?? false;
const params = new URLSearchParams({
force_refresh: String(force),
_ts: String(Date.now()),
});
if (options?.targetDate) {
params.set("target_date", options.targetDate);
}
if (options?.marketSlug) {
params.set("market_slug", options.marketSlug);
}
if (options?.lite) {
params.set("lite", "true");
}
const requestKey = [
cityName,
force ? "force" : "cached",
options?.lite ? "lite" : "full",
options?.targetDate || "",
options?.marketSlug || "",
].join("::");
if (!force) {
const existing = pendingCityMarketScanRequests.get(requestKey);
if (existing) {
return existing;
}
}
type MarketScanPayload = {
fetched_at?: string | null;
market_scan?: MarketScan | null;
selected_date?: string | null;
};
const request = (async () => {
const payload = await fetchJson<MarketScanPayload>(
`/api/city/${normalizeCityName(cityName)}/market-scan?${params.toString()}`,
force ? { cache: "no-store" } : undefined,
);
if (!force && options?.marketSlug && isStaleMarketSlugResponse(payload)) {
const fallbackParams = new URLSearchParams({
force_refresh: "false",
_ts: String(Date.now()),
});
if (options?.lite) {
fallbackParams.set("lite", "true");
}
return fetchJson<MarketScanPayload>(
`/api/city/${normalizeCityName(cityName)}/market-scan?${fallbackParams.toString()}`,
);
}
return payload;
})().finally(() => {
pendingCityMarketScanRequests.delete(requestKey);
});
if (!force) {
pendingCityMarketScanRequests.set(requestKey, request);
}
return request;
},
async getScanTerminal(
filters: ScanTerminalFilters,
options?: { force?: boolean },
) {
const force = options?.force ?? false;
const params = new URLSearchParams({
scan_mode: String(filters.scan_mode),
min_price: String(filters.min_price),
max_price: String(filters.max_price),
min_edge_pct: String(filters.min_edge_pct),
min_liquidity: String(filters.min_liquidity),
high_liquidity_only: String(filters.high_liquidity_only),
market_type: String(filters.market_type),
time_range: String(filters.time_range),
limit: String(filters.limit),
force_refresh: String(force),
});
const requestKey = `${params.toString()}::${force ? "force" : "cached"}`;
if (!force) {
const existing = pendingScanTerminalRequests.get(requestKey);
if (existing) {
return existing;
}
}
const request = fetchJson<ScanTerminalResponse>(
`/api/scan/terminal?${params.toString()}`,
{
cache: force ? "no-store" : "default",
timeoutMs: SCAN_TERMINAL_CLIENT_TIMEOUT_MS,
},
).finally(() => {
pendingScanTerminalRequests.delete(requestKey);
});
if (!force) {
pendingScanTerminalRequests.set(requestKey, request);
}
return request;
},
async reviewScanTerminalWithAi(payload: {
filters: ScanTerminalFilters;
snapshotId?: string | null;
}) {
const snapshotId = String(payload.snapshotId || "").trim();
const requestKey = [
snapshotId || "latest",
JSON.stringify(payload.filters || {}),
].join("::");
const existing = pendingScanTerminalAiRequests.get(requestKey);
if (existing) {
return existing;
}
const request = buildBrowserBackendHeaders({
Accept: "application/json",
"Content-Type": "application/json",
}).then((headers) => fetchBackendApi("/api/scan/terminal/ai", {
method: "POST",
headers,
cache: "default",
body: JSON.stringify({
filters: payload.filters,
snapshot_id: snapshotId || null,
}),
}))
.then(async (response) => {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
formatHttpErrorMessage(response.status, response.statusText, body),
);
}
return response.json() as Promise<ScanTerminalResponse>;
})
.finally(() => {
pendingScanTerminalAiRequests.delete(requestKey);
});
pendingScanTerminalAiRequests.set(requestKey, request);
return request;
},
isCityDetailFresh(meta?: CityCacheMeta | null) {
return isFresh(meta);
},
readCityDetailCacheBundle() {
if (!isClient()) {
return {
details: {},
meta: {},
} satisfies CityCacheBundle;
}
try {
const cached = window.sessionStorage.getItem(CACHE_KEY);
if (!cached) {
return {
details: {},
meta: {},
} satisfies CityCacheBundle;
}
const parsed = JSON.parse(cached) as
| {
entries?: Record<
string,
{ cachedAt?: number; detail?: CityDetail; revision?: string }
>;
}
| {
timestamp?: number;
data?: Record<string, CityDetail>;
};
if ("entries" in parsed && parsed.entries) {
const details: Record<string, CityDetail> = {};
const meta: Record<string, CityCacheMeta> = {};
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
if (!entry?.detail) return;
const nextMeta = {
cachedAt: entry.cachedAt || 0,
revision: entry.revision || getCityRevision(entry.detail),
};
if (!isFresh(nextMeta)) return;
details[cityName] = entry.detail;
meta[cityName] = nextMeta;
});
return { details, meta };
}
return readLegacyCache(cached);
} catch {
return {
details: {},
meta: {},
} satisfies CityCacheBundle;
}
},
readCityDetailCache() {
return this.readCityDetailCacheBundle().details;
},
writeCityDetailCacheBundle(
details: Record<string, CityDetail>,
meta: Record<string, CityCacheMeta>,
) {
if (!isClient()) return;
// Keep only the 12 most-recently-accessed cities to prevent sessionStorage bloat
const MAX_CACHED_CITIES = 12;
const allEntries = Object.entries(details).map(([cityName, detail]) => ({
cityName,
cachedAt: meta[cityName]?.cachedAt || 0,
detail,
revision: meta[cityName]?.revision || getCityRevision(detail),
}));
const topEntries = allEntries
.sort((a, b) => b.cachedAt - a.cachedAt)
.slice(0, MAX_CACHED_CITIES);
const entries = Object.fromEntries(
topEntries.map((e) => [e.cityName, { cachedAt: e.cachedAt, detail: e.detail, revision: e.revision }]),
);
try {
window.sessionStorage?.setItem(CACHE_KEY, JSON.stringify({ entries }));
} catch {
// Storage can be unavailable in embedded/private browser contexts.
}
},
writeCityDetailCache(data: Record<string, CityDetail>) {
const now = Date.now();
const meta = Object.fromEntries(
Object.entries(data).map(([cityName, detail]) => [
cityName,
{ cachedAt: now, revision: getCityRevision(detail) },
]),
);
this.writeCityDetailCacheBundle(data, meta);
},
};