diff --git a/frontend/components/dashboard/CitySidebar.tsx b/frontend/components/dashboard/CitySidebar.tsx deleted file mode 100644 index d108994e..00000000 --- a/frontend/components/dashboard/CitySidebar.tsx +++ /dev/null @@ -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 = { - 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 { - if (!value || typeof value !== "object") { - return DEFAULT_EXPANDED_GROUPS; - } - const candidate = value as Partial>; - 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 - >(DEFAULT_EXPANDED_GROUPS); - const [searchQuery, setSearchQuery] = useState(""); - const cityItemRefs = useRef>({}); - 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 = { - 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 ( - - ); -} diff --git a/frontend/components/dashboard/DashboardModalGuide.module.css b/frontend/components/dashboard/DashboardModalGuide.module.css deleted file mode 100644 index cf255197..00000000 --- a/frontend/components/dashboard/DashboardModalGuide.module.css +++ /dev/null @@ -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); } } diff --git a/frontend/components/dashboard/DashboardShell.module.css b/frontend/components/dashboard/DashboardShell.module.css deleted file mode 100644 index 2b6f3aa0..00000000 --- a/frontend/components/dashboard/DashboardShell.module.css +++ /dev/null @@ -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); } } diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx deleted file mode 100644 index 00ccb003..00000000 --- a/frontend/components/dashboard/DetailPanel.tsx +++ /dev/null @@ -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: () =>
, - 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(null); - const lastDetailFetchedAtRef = useRef(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 ( - - ); -} diff --git a/frontend/components/dashboard/DetailPanelChrome.module.css b/frontend/components/dashboard/DetailPanelChrome.module.css deleted file mode 100644 index 4b5ec5f5..00000000 --- a/frontend/components/dashboard/DetailPanelChrome.module.css +++ /dev/null @@ -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; -} diff --git a/frontend/components/dashboard/DetailPanelContent.module.css b/frontend/components/dashboard/DetailPanelContent.module.css deleted file mode 100644 index b038cc2d..00000000 --- a/frontend/components/dashboard/DetailPanelContent.module.css +++ /dev/null @@ -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; - } -} diff --git a/frontend/components/dashboard/DetailPanelSections.module.css b/frontend/components/dashboard/DetailPanelSections.module.css deleted file mode 100644 index d37b7487..00000000 --- a/frontend/components/dashboard/DetailPanelSections.module.css +++ /dev/null @@ -1,1094 +0,0 @@ -/* ── Detail Panel ── */ -.root :global(.detail-panel) { - position: fixed; - top: 0; - right: 0; - width: var(--panel-width); - height: 100vh; - height: 100dvh; - z-index: 950; - background: linear-gradient( - 180deg, - rgba(10, 14, 26, 0.92) 0%, - rgba(15, 23, 42, 0.95) 100% - ); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border-left: 1px solid var(--border-glass); - box-shadow: -10px 0 60px rgba(0, 0, 0, 0.5); - transform: translateX(100%); - transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); - display: flex; - flex-direction: column; - overflow: hidden; -} -.root :global(.detail-panel.visible) { - transform: translateX(0); -} - -.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) { - position: sticky; - top: 16px; - width: auto; - min-width: 380px; - height: calc(100vh - 32px); - min-height: 0; - max-height: calc(100vh - 32px); - z-index: auto; - transform: none; - transition: none; - border: 1px solid rgba(82, 114, 161, 0.18); - border-left: 1px solid rgba(82, 114, 161, 0.18); - border-radius: 22px; - background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94)); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); - backdrop-filter: blur(14px); - align-self: start; - overflow: hidden; -} - -.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail.visible) { - transform: none; -} - -.root :global(.scan-city-detail-rail .panel-header) { - padding: 20px 16px 16px; -} - -.root :global(.panel-header) { - padding: 20px 20px 16px; - border-bottom: 1px solid var(--border-subtle); - position: relative; - flex-shrink: 0; -} - -.root :global(.panel-close) { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: 8px; - border: 1px solid var(--border-glass); - background: transparent; - color: var(--text-muted); - font-size: 14px; - cursor: pointer; - transition: var(--transition); - display: flex; - align-items: center; - justify-content: center; -} -.root :global(.panel-close:hover) { - background: rgba(248, 113, 113, 0.15); - border-color: var(--accent-red); - color: var(--accent-red); -} - -.root :global(.panel-title-area h2) { - font-size: 22px; - font-weight: 700; - letter-spacing: -0.02em; - margin-bottom: 6px; -} - -.root :global(.panel-loading-hint) { - display: inline-flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid rgba(34, 211, 238, 0.18); - background: rgba(12, 24, 42, 0.78); - color: var(--accent-cyan); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.2px; -} - -.root :global(.panel-loading-spinner) { - width: 10px; - height: 10px; - border-radius: 999px; - border: 2px solid rgba(34, 211, 238, 0.22); - border-top-color: rgba(34, 211, 238, 0.92); - animation: spin 0.8s linear infinite; -} - -.root :global(.panel-meta) { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.root :global(.city-loading-toast) { - position: fixed; - top: 78px; - left: 50%; - transform: translateX(-50%); - z-index: 1200; - display: inline-flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - border-radius: 999px; - border: 1px solid rgba(34, 211, 238, 0.18); - background: linear-gradient( - 180deg, - rgba(10, 18, 34, 0.94), - rgba(10, 18, 34, 0.82) - ); - box-shadow: - 0 18px 40px rgba(2, 6, 23, 0.38), - 0 0 0 1px rgba(34, 211, 238, 0.04) inset; - backdrop-filter: blur(18px); - pointer-events: none; -} - -.root :global(.city-loading-dot) { - width: 10px; - height: 10px; - border-radius: 999px; - background: var(--accent-cyan); - box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.5); - animation: city-loading-pulse 1.35s ease-out infinite; -} - -.root :global(.city-loading-copy) { - color: var(--text-primary); - font-size: 12px; - font-weight: 600; - letter-spacing: 0.25px; -} - -.root :global(.pro-locked) { - filter: grayscale(0.8) opacity(0.7); - position: relative; -} - -.root :global(.pro-locked::after) { - content: "PRO"; - position: absolute; - top: -4px; - right: -4px; - background: var(--accent-blue); - color: white; - font-size: 7px; - padding: 1px 3px; - border-radius: 3px; - font-weight: 900; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.root :global(.risk-badge) { - font-size: 11px; - font-weight: 600; - padding: 3px 10px; - border-radius: 6px; - letter-spacing: 0.5px; -} -.root :global(.risk-badge.high) { - background: rgba(239, 68, 68, 0.15); - color: var(--risk-high); - border: 1px solid rgba(239, 68, 68, 0.3); -} -.root :global(.risk-badge.medium) { - background: rgba(245, 158, 11, 0.15); - color: var(--risk-medium); - border: 1px solid rgba(245, 158, 11, 0.3); -} -.root :global(.risk-badge.low) { - background: rgba(34, 197, 94, 0.15); - color: var(--risk-low); - border: 1px solid rgba(34, 197, 94, 0.3); -} - -.root :global(.local-time) { - font-size: 12px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} - -.root :global(.panel-body) { - overflow-y: auto; - flex: 1; - position: relative; - padding: 0 20px 24px; -} - -.root :global(.panel-sync-blocker) { - position: sticky; - top: 0; - z-index: 6; - display: flex; - align-items: center; - gap: 10px; - margin: 0 0 12px; - padding: 12px 14px; - border: 1px solid rgba(34, 211, 238, 0.22); - border-radius: 8px; - background: rgba(8, 16, 30, 0.96); - color: var(--text-primary); - box-shadow: 0 14px 34px rgba(2, 6, 23, 0.38); - backdrop-filter: blur(18px); - font-size: 12px; - font-weight: 600; - line-height: 1.45; -} - -.root :global(.panel-content-stale) { - opacity: 0.28; - pointer-events: none; - user-select: none; -} - -.root :global(.panel-content-stale canvas) { - visibility: hidden; -} -.root :global(.panel-body::-webkit-scrollbar) { - width: 4px; -} -.root :global(.panel-body::-webkit-scrollbar-thumb) { - background: var(--border-glass); - border-radius: 2px; -} - -.root :global(.panel-body section) { - padding: 18px 0; - border-bottom: 1px solid var(--border-subtle); -} -.root :global(.panel-body section:last-child) { - border-bottom: none; -} - -.root :global(.panel-body section.detail-scenery-card) { - padding: 0; - margin: 18px 0; -} - -.root :global(.panel-body h3) { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - margin-bottom: 12px; - letter-spacing: 0.3px; -} - -/* ── Hero Section ── */ -.root :global(.hero-section) { - text-align: center; - padding-top: 12px; -} - -.root :global(.hero-weather) { - font-size: 13px; - font-weight: 600; - color: var(--accent-cyan); - margin-bottom: -4px; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.root :global(.hero-temp) { - display: flex; - align-items: flex-start; - justify-content: center; - gap: 2px; - margin-bottom: 2px; -} - -.root :global(.hero-max-time) { - font-size: 10px; - font-weight: 500; - color: var(--text-muted); - margin-bottom: 16px; - min-height: 12px; -} -.root :global(.hero-value) { - font-size: 56px; - font-weight: 800; - letter-spacing: -0.04em; - line-height: 1; - background: linear-gradient(135deg, #fff 30%, var(--accent-cyan)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} -.root :global(.hero-unit) { - font-size: 20px; - font-weight: 400; - color: var(--text-muted); - margin-top: 8px; -} - -.root :global(.hero-details) { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-bottom: 12px; -} -.root :global(.hero-item) { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border-subtle); - border-radius: 10px; - padding: 10px 8px; - text-align: center; -} -.root :global(.hero-item .label) { - display: block; - font-size: 10px; - color: var(--text-muted); - margin-bottom: 4px; -} -.root :global(.hero-item .value) { - display: block; - font-size: 16px; - font-weight: 700; - color: var(--text-primary); - font-variant-numeric: tabular-nums; -} -.root :global(.hero-item .value.highlight) { - color: var(--accent-cyan); - text-shadow: 0 0 12px rgba(34, 211, 238, 0.3); -} - -.root :global(.hero-sub) { - font-size: 12px; - color: var(--text-muted); - display: flex; - justify-content: center; - gap: 16px; - flex-wrap: wrap; -} -.root :global(.hero-sub span) { - white-space: nowrap; -} - -/* ── Chart Section ── */ -.root :global(.chart-wrapper) { - height: 180px; - position: relative; - background: rgba(255, 255, 255, 0.02); - border-radius: 12px; - border: 1px solid var(--border-subtle); - padding: 12px; -} - -.root :global(.chart-legend) { - display: flex; - justify-content: center; - gap: 16px; - margin-top: 8px; - font-size: 11px; - color: var(--text-muted); -} - -/* ── Probability Bars ── */ -.root :global(.prob-bars) { - display: flex; - flex-direction: column; - gap: 8px; -} - -.root :global(.prob-calibration-head) { - display: grid; - gap: 6px; - margin-bottom: 4px; - padding: 10px; - border: 1px solid rgba(34, 211, 238, 0.16); - border-radius: 8px; - background: rgba(15, 23, 42, 0.26); -} - -.root :global(.prob-calibration-head > div) { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.root :global(.prob-calibration-head strong) { - color: var(--text-primary); - font-size: 13px; - font-weight: 800; -} - -.root :global(.prob-calibration-head p) { - margin: 0; - color: var(--text-muted); - font-size: 11px; - line-height: 1.45; -} - -.root :global(.prob-source-chip) { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 3px 8px; - border: 1px solid rgba(34, 211, 238, 0.28); - border-radius: 8px; - color: var(--color-accent-secondary); - background: rgba(34, 211, 238, 0.08); - font-size: 11px; - font-weight: 900; -} - -.root :global(.prob-row) { - display: flex; - align-items: center; - gap: 10px; -} - -.root :global(.prob-label) { - width: 80px; - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; - text-align: right; - flex-shrink: 0; -} - -.root :global(.prob-bar-track) { - flex: 1; - height: 28px; - background: rgba(255, 255, 255, 0.04); - border-radius: 8px; - overflow: hidden; - position: relative; -} - -.root :global(.prob-bar-fill) { - height: 100%; - border-radius: 8px; - transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); - display: flex; - align-items: center; - padding-left: 10px; - font-size: 12px; - font-weight: 600; - color: white; - min-width: 40px; -} - -.root :global(.prob-bar-fill.rank-0) { - background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan)); - box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); -} -.root :global(.prob-bar-fill.rank-1) { - background: linear-gradient( - 90deg, - rgba(99, 102, 241, 0.6), - rgba(34, 211, 238, 0.5) - ); -} -.root :global(.prob-bar-fill.rank-2) { - background: rgba(99, 102, 241, 0.3); -} -.root :global(.prob-bar-fill.rank-3) { - background: rgba(99, 102, 241, 0.15); -} - -.root :global(.prob-market-inline) { - min-width: 120px; - text-align: right; - font-size: 12px; - font-weight: 700; - border-radius: 999px; - padding: 4px 10px; - letter-spacing: 0.02em; - font-variant-numeric: tabular-nums; -} - -.root :global(.prob-market-inline.yes) { - color: #4ade80; - background: rgba(74, 222, 128, 0.12); - border: 1px solid rgba(74, 222, 128, 0.3); -} - -.root :global(.prob-market-inline.no) { - color: #fb7185; - background: rgba(251, 113, 133, 0.12); - border: 1px solid rgba(251, 113, 133, 0.26); -} - -.root :global(.prob-distribution-panel) { - display: grid; - gap: 8px; - padding: 10px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 8px; - background: rgba(15, 23, 42, 0.24); -} - -.root :global(.prob-distribution-head) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 2px; -} - -.root :global(.prob-distribution-head span) { - color: var(--cyan); - font-size: 11px; - font-weight: 900; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.root :global(.prob-distribution-head em) { - color: var(--text-muted); - font-size: 11px; - font-style: normal; -} - -.root :global(.prob-price-card) { - display: grid; - gap: 8px; - padding: 10px; - border: 1px solid rgba(34, 211, 238, 0.16); - border-radius: 8px; - background: rgba(8, 20, 32, 0.52); -} - -.root :global(.prob-price-head) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.root :global(.prob-price-head span) { - color: var(--cyan); - font-size: 11px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.root :global(.prob-price-head strong) { - color: var(--text-primary); - font-size: 13px; - font-weight: 800; -} - -.root :global(.prob-price-grid) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - -.root :global(.prob-price-grid > div) { - display: grid; - gap: 3px; - min-width: 0; - padding: 8px; - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: 8px; - background: rgba(15, 23, 42, 0.45); -} - -.root :global(.prob-price-grid span), -.root :global(.prob-price-grid em), -.root :global(.prob-price-card p) { - color: var(--text-muted); - font-size: 11px; - font-style: normal; - line-height: 1.35; -} - -.root :global(.prob-price-grid strong) { - color: var(--text-primary); - font-size: 13px; - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.root :global(.prob-price-card p) { - margin: 0; -} - -.root :global(.prob-model-hint) { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 10px; - padding: 7px 9px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 8px; - background: rgba(15, 23, 42, 0.22); -} - -.root :global(.prob-model-hint span) { - color: var(--cyan); - font-size: 11px; - font-weight: 800; -} - -.root :global(.prob-model-hint strong) { - color: var(--text-secondary); - font-size: 11px; - font-weight: 800; -} - -.root :global(.prob-model-hint em) { - color: var(--text-muted); - font-size: 11px; - font-style: normal; -} - -/* ── Model Bars ── */ -.root :global(.model-bars) { - display: flex; - flex-direction: column; - gap: 8px; -} - -.root :global(.model-row) { - display: flex; - align-items: center; - gap: 10px; - font-size: 12px; -} - -.root :global(.model-name) { - width: 80px; - text-align: right; - color: var(--text-muted); - font-weight: 500; - flex-shrink: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.root :global(.model-row-rich) { - align-items: flex-start; -} - -.root :global(.model-row-rich .model-name) { - width: 118px; - white-space: normal; - line-height: 1.25; -} - -.root :global(.model-row-rich .model-name strong) { - display: block; - color: var(--text-primary); - font-size: 12px; -} - -.root :global(.model-row-rich .model-name span) { - display: block; - margin-top: 2px; - color: var(--text-muted); - font-size: 10px; - font-weight: 500; -} - -.root :global(.model-stack-summary) { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-bottom: 2px; -} - -.root :global(.model-stack-summary span) { - border: 1px solid rgba(148, 163, 184, 0.16); - border-radius: 6px; - background: rgba(15, 23, 42, 0.5); - color: var(--text-secondary); - font-size: 11px; - padding: 5px 8px; -} - -.root :global(.model-stack-summary strong) { - color: var(--text-primary); - font-weight: 800; -} - -.root :global(.model-group) { - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px; - border-left: 2px solid rgba(148, 163, 184, 0.26); - background: rgba(15, 23, 42, 0.28); - border-radius: 6px; -} - -.root :global(.model-group-cyan) { - border-left-color: rgba(34, 211, 238, 0.75); -} - -.root :global(.model-group-blue) { - border-left-color: rgba(96, 165, 250, 0.75); -} - -.root :global(.model-group-amber) { - border-left-color: rgba(245, 158, 11, 0.8); -} - -.root :global(.model-group-heading) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: var(--accent-cyan); - font-size: 10px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.root :global(.model-group-heading em) { - color: var(--text-muted); - font-style: normal; - letter-spacing: 0; -} - -.root :global(.model-bar-track) { - flex: 1; - height: 20px; - background: rgba(255, 255, 255, 0.03); - border-radius: 6px; - position: relative; - overflow: hidden; -} - -.root :global(.model-bar-fill) { - height: 100%; - border-radius: 6px; - background: linear-gradient( - 90deg, - rgba(14, 165, 233, 0.72), - rgba(34, 211, 238, 0.92) - ); - transition: width 0.6s ease-out; -} - -.root :global(.model-bar-fill.deb) { - background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); - box-shadow: 0 0 8px rgba(34, 211, 238, 0.3); -} - -.root :global(.model-bar-value) { - position: absolute; - top: 50%; - right: 8px; - z-index: 3; - transform: translateY(-50%); - color: var(--text-primary); - font-size: 11px; - font-weight: 800; - line-height: 1; - max-width: calc(100% - 12px); - overflow: visible; - pointer-events: none; - text-align: right; - text-shadow: 0 1px 2px rgba(2, 6, 23, 0.9); - white-space: nowrap; -} - -.root :global(.model-bar-value.deb) { - color: #ecfeff; -} - -.root :global(.model-deb-line) { - position: absolute; - top: 0; - bottom: 0; - width: 2px; - background: var(--accent-cyan); - box-shadow: 0 0 6px var(--accent-cyan); - z-index: 2; -} - -/* ── Forecast Table ── */ -.root :global(.forecast-table) { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); - gap: 8px; -} - -.root :global(.forecast-inline-note) { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - padding: 8px 10px; - border: 1px solid rgba(34, 211, 238, 0.18); - border-radius: 8px; - color: var(--text-secondary); - background: rgba(34, 211, 238, 0.06); - font-size: 12px; - line-height: 1.45; -} - -.root :global(.forecast-inline-note::before) { - content: ""; - width: 8px; - height: 8px; - flex: 0 0 auto; - border-radius: 999px; - background: #22d3ee; - box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12); - animation: pulseGlow 1.25s ease-in-out infinite; -} - -.root :global(.forecast-day) { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border-subtle); - border-radius: 10px; - padding: 10px; - text-align: center; - transition: var(--transition); -} -.root :global(.forecast-day:hover) { - border-color: var(--border-glass); - background: rgba(255, 255, 255, 0.05); -} -.root :global(.forecast-day .f-date) { - font-size: 11px; - color: var(--text-muted); - margin-bottom: 4px; -} -.root :global(.forecast-day .f-temp) { - font-size: 18px; - font-weight: 700; - color: var(--text-primary); -} -.root :global(.forecast-day.today) { - border-color: var(--accent-blue); - background: rgba(99, 102, 241, 0.08); -} -.root :global(.forecast-day.today .f-date) { - color: var(--accent-cyan); -} - -.root :global(.forecast-day.selected) { - border-color: var(--accent-cyan); - background: rgba(34, 211, 238, 0.1); - box-shadow: 0 0 12px rgba(34, 211, 238, 0.2); -} -.root :global(.forecast-day.selected .f-date) { - color: var(--accent-cyan); -} - -.root :global(.forecast-day-sync) { - cursor: wait; - opacity: 0.76; - background: linear-gradient( - 90deg, - rgba(15, 23, 42, 0.86), - rgba(30, 41, 59, 0.72), - rgba(15, 23, 42, 0.86) - ); - background-size: 220% 100%; - animation: panelSkeletonSweep 1.35s ease-in-out infinite; -} - -.root :global(.forecast-day-sync .f-temp) { - color: var(--text-muted); -} - -@keyframes panelSkeletonSweep { - 0% { - background-position: 120% 0; - } - 100% { - background-position: -120% 0; - } -} - -@media (max-width: 768px) { - .root :global(.forecast-table) { - display: flex; - gap: 10px; - overflow-x: auto; - padding-bottom: 4px; - scroll-snap-type: x proximity; - } - - .root :global(.forecast-table::-webkit-scrollbar) { - height: 6px; - } - - .root :global(.forecast-table::-webkit-scrollbar-thumb) { - background: rgba(148, 163, 184, 0.32); - border-radius: 999px; - } - - .root :global(.forecast-day) { - flex: 0 0 112px; - min-width: 112px; - scroll-snap-align: start; - } -} - -.root :global(.sun-info) { - margin-top: 10px; - font-size: 12px; - color: var(--text-muted); - display: flex; - gap: 16px; - justify-content: center; -} - -/* ── AI Section ── */ -.root :global(.ai-box) { - background: rgba(99, 102, 241, 0.06); - border: 1px solid rgba(99, 102, 241, 0.15); - border-radius: 12px; - padding: 16px; - font-size: 13px; - line-height: 1.7; - color: var(--text-secondary); - white-space: pre-wrap; - word-break: break-word; -} - -.root :global(.ai-placeholder) { - color: var(--text-muted); - font-style: italic; -} - -/* ── Risk Section ── */ -.root :global(.risk-info) { - font-size: 12px; - color: var(--text-secondary); - line-height: 1.8; - word-break: break-word; /* 确保数字和英文长句也能折行 */ - white-space: normal; -} -.root :global(.risk-info .risk-row) { - display: flex; - gap: 8px; - align-items: flex-start; -} -.root :global(.risk-info .risk-label) { - color: var(--text-muted); - min-width: 60px; - flex-shrink: 0; -} - -/* ── Nearby Markers ── */ -.root :global(.nearby-marker) { - display: flex; - align-items: center; - gap: 8px; - background: rgba(13, 17, 28, 0.85); - color: var(--text-primary); - border: 1px solid rgba(34, 211, 238, 0.25); - border-radius: 12px; - padding: 5px 12px; - font-size: 11px; - font-weight: 500; - box-shadow: - 0 4px 16px rgba(0, 0, 0, 0.6), - inset 0 0 10px rgba(34, 211, 238, 0.05); - white-space: nowrap; - backdrop-filter: blur(12px); - pointer-events: none; - animation: markerFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); - transition: border-color 0.3s ease; -} - -.root :global(.nearby-marker:hover) { - border-color: var(--accent-cyan); -} - -.root :global(.nearby-name) { - color: rgba(255, 255, 255, 0.6); - font-weight: 400; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; -} - -.root :global(.nearby-temp) { - font-weight: 800; - color: var(--accent-cyan); - font-size: 13px; - text-shadow: 0 0 10px rgba(34, 211, 238, 0.3); -} - -.root :global(.nearby-unit) { - font-weight: 600; - font-size: 10px; - color: var(--accent-cyan); - opacity: 0.8; - margin-left: 1px; -} - -.root :global(.wind-info) { - display: flex; - align-items: center; - gap: 5px; - margin-left: 4px; - padding-left: 8px; - border-left: 1.5px solid rgba(255, 255, 255, 0.15); -} - -.root :global(.wind-arrow) { - display: inline-block; - font-size: 14px; - color: var(--accent-green); - text-shadow: 0 0 8px rgba(52, 211, 153, 0.4); -} - -.root :global(.wind-speed) { - font-size: 10px; - font-weight: 700; - color: var(--text-primary); - font-variant-numeric: tabular-nums; -} - -@keyframes markerFadeIn { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - - -@keyframes spin { to { transform: rotate(360deg); } } - -/* ── Model Forecast Light Mode Overrides ── */ -:global(html.light) .root :global(.model-group) { - background: #f8fafc; - border-left-color: var(--color-border-default); -} - -:global(html.light) .root :global(.model-bar-track) { - background: var(--color-bg-input); -} - -:global(html.light) .root :global(.model-bar-value) { - color: #ffffff; - text-shadow: 0 1px 2px rgba(15, 23, 42, 0.4); -} - -:global(html.light) .root :global(.model-name strong) { - color: var(--color-text-primary); -} - -:global(html.light) .root :global(.model-name span) { - color: var(--color-text-muted); -} - -:global(html.light) .root :global(.model-stack-summary span) { - background: #ffffff; - border-color: var(--color-border-default); - color: var(--color-text-secondary); -} - diff --git a/frontend/components/dashboard/ModalChrome.module.css b/frontend/components/dashboard/ModalChrome.module.css deleted file mode 100644 index 380e7033..00000000 --- a/frontend/components/dashboard/ModalChrome.module.css +++ /dev/null @@ -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; -} - diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx deleted file mode 100644 index 32c65b1e..00000000 --- a/frontend/components/dashboard/PanelSections.tsx +++ /dev/null @@ -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 ( -
{text}
- ); -} - -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 ( -
-
- - {weatherIcon} {weatherText} - -
-
- - {current.temp != null ? current.temp.toFixed(1) : "--"} - - {data.temp_symbol || "°C"} -
-
- {isMax && current.max_temp_time - ? locale === "en-US" - ? `Today's peak temperature appeared at local time ${current.max_temp_time}` - : `该城市今日最高温出现在当地时间 ${current.max_temp_time}` - : ""} -
-
-
- - {locale === "en-US" ? "Current Obs" : "当前实测"} - - {currentObsText} -
-
- - {locale === "en-US" - ? `${settlementSource} Anchor` - : `${settlementSource} 锚点`} - - - {current.wu_settlement != null - ? `${current.wu_settlement}${data.temp_symbol}` - : "--"} - -
-
- - {locale === "en-US" ? "DEB Forecast" : "DEB 预测"} - - - {data.deb?.prediction != null - ? `${data.deb.prediction}${data.temp_symbol}` - : "--"} - -
-
-
- {metaItems.map((item) => ( - {item} - ))} -
-
- ); -} - -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 ( -
-

{t("section.todayTempTrend")}

-
- -
-
- {chartData?.legendText || t("section.chartEmpty")} -
-
- ); -} - -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(); - 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 ( -
-

{t("forecast.title")}

- {isSparseDaily && ( -
- {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." - : "当前只收到当日预报,其他日期结果暂未回传。"} -
- )} -
- {daily.length === 0 ? ( - - ) : ( - 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 ( - - ); - }) - .concat( - isForecastCompleting - ? Array.from({ length: Math.max(0, 5 - daily.length) }).map( - (_, index) => ( - - ), - ) - : [], - ) - )} -
-
- ); -} - -export function RiskInfo() { - const { data } = useCityData(); - const { t } = useI18n(); - if (!data) return null; - const risk = data.risk || {}; - - return ( -
-

{t("section.risk")}

-
- {!risk.airport ? ( - - {t("section.noRiskProfile")} - - ) : ( - <> -
- {t("section.airport")} - - {risk.airport} ({risk.icao}) - -
-
- {t("section.distance")} - {risk.distance_km}km -
- {risk.warning && ( -
- {t("section.note")} - {risk.warning} -
- )} - - )} -
-
- ); -} diff --git a/frontend/components/dashboard/ProbabilityDistribution.tsx b/frontend/components/dashboard/ProbabilityDistribution.tsx deleted file mode 100644 index df964cea..00000000 --- a/frontend/components/dashboard/ProbabilityDistribution.tsx +++ /dev/null @@ -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 ( -
{text}
- ); -} - -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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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["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(); - 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 = []; - const seenKeys = new Set(); - 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(() => { - if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) { - return []; - } - - const rows: ProbabilityDisplayRow[] = []; - const seenKeys = new Set(); - 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( - () => - (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 ( -
- {!hideTitle &&

{t("section.probability")}

} -
-
-
- {probabilityEngineLabel} - - {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" - : "等待校准概率桶"} - -
-

- {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." - : "使用校准后的概率分布;下方原始模型落点不是概率。"} -

-
- {marketScan?.available && (topMarketBucketText || marketYesText) && ( -
- {useMarketTopBuckets - ? locale === "en-US" - ? `Market reference only: top traded bucket ${topMarketBucketText}` - : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}` - : locale === "en-US" - ? `Market reference only: this bucket ${marketYesText}` - : `市场仅作参考:该温度桶 ${marketYesText}`} -
- )} -
-
- - {locale === "en-US" - ? "Probability distribution" - : "概率分布"} - - - {marketContractRows.length > 0 - ? locale === "en-US" - ? "market buckets are aggregated from single-degree probability buckets" - : "市场合约桶由单点概率分布聚合" - : locale === "en-US" - ? "temperature probability buckets" - : "温度概率桶"} - -
- {probabilityRows.length === 0 ? ( - - ) : ( - probabilityRows.map((row, index) => { - const probability = Math.round( - Number(row.probability || 0) * 100, - ); - - return ( -
-
{row.label}
-
-
- {probability}% -
-
-
- ); - }) - )} -
- {hasPriceAnalysis && ( -
-
- - {locale === "en-US" ? "Win-rate reference" : "胜率参考"} - - - {!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 || "--"}`} - -
-
-
- - {locale === "en-US" ? "Bucket" : "温度桶"} - - - {linkedContractLabel || topProbabilityLabel || "--"} - - - {linkedMarketProbabilityText || topProbabilityText || "--"} - -
-
- {locale === "en-US" ? "DEB" : "DEB"} - - {modelView.deb != null && Number.isFinite(Number(modelView.deb)) - ? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}` - : "--"} - - {locale === "en-US" ? "final fused forecast" : "最终融合预测"} -
-
- {locale === "en-US" ? "Model support" : "模型支持"} - {modelVoteHint || "--"} - {locale === "en-US" ? "raw model agreement" : "原始模型一致性"} -
-
- {locale === "en-US" ? "Market role" : "盘口角色"} - {locale === "en-US" ? "Reference only" : "仅作参考"} - {quoteSourceLabel} -
-
-

- {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 是否冲突。"} -

-
- )} - {modelVoteHint && ( -
- - {locale === "en-US" ? "Raw model points" : "原始模型落点"} - - {modelVoteHint} - - {locale === "en-US" - ? "diagnostic only; EMOS and contract rows use calibrated probabilities" - : "仅作诊断;EMOS 与合约行使用校准概率"} - -
- )} -
-
- ); -} diff --git a/frontend/components/dashboard/opportunity-airport-read.ts b/frontend/components/dashboard/opportunity-airport-read.ts deleted file mode 100644 index d22d554d..00000000 --- a/frontend/components/dashboard/opportunity-airport-read.ts +++ /dev/null @@ -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 = { - 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 = { - 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 = { - 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> = - 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> = - 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(",")}。`; -} diff --git a/frontend/components/dashboard/opportunity-copy.ts b/frontend/components/dashboard/opportunity-copy.ts deleted file mode 100644 index 41a6c751..00000000 --- a/frontend/components/dashboard/opportunity-copy.ts +++ /dev/null @@ -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; -} diff --git a/frontend/components/dashboard/opportunity-detail.ts b/frontend/components/dashboard/opportunity-detail.ts deleted file mode 100644 index fb939ceb..00000000 --- a/frontend/components/dashboard/opportunity-detail.ts +++ /dev/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, - cityDetailsByName?: Record, -) { - 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; -} diff --git a/frontend/components/dashboard/opportunity-format.ts b/frontend/components/dashboard/opportunity-format.ts deleted file mode 100644 index c5916815..00000000 --- a/frontend/components/dashboard/opportunity-format.ts +++ /dev/null @@ -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 }); -} diff --git a/frontend/components/dashboard/opportunity-groups.ts b/frontend/components/dashboard/opportunity-groups.ts deleted file mode 100644 index ae867fcc..00000000 --- a/frontend/components/dashboard/opportunity-groups.ts +++ /dev/null @@ -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, -): OpportunityGroup[] { - const groups = new Map(); - 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); -} diff --git a/frontend/components/dashboard/opportunity-model-summary.ts b/frontend/components/dashboard/opportunity-model-summary.ts deleted file mode 100644 index cb31be99..00000000 --- a/frontend/components/dashboard/opportunity-model-summary.ts +++ /dev/null @@ -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 | 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}`; -} diff --git a/frontend/components/dashboard/opportunity-observation.ts b/frontend/components/dashboard/opportunity-observation.ts deleted file mode 100644 index 458d2796..00000000 --- a/frontend/components/dashboard/opportunity-observation.ts +++ /dev/null @@ -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>) { - 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, - }; -} diff --git a/frontend/components/dashboard/opportunity-target.ts b/frontend/components/dashboard/opportunity-target.ts deleted file mode 100644 index 04a0d5db..00000000 --- a/frontend/components/dashboard/opportunity-target.ts +++ /dev/null @@ -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 }; -} diff --git a/frontend/components/dashboard/opportunity-v4-forecast.ts b/frontend/components/dashboard/opportunity-v4-forecast.ts deleted file mode 100644 index 7c1088fe..00000000 --- a/frontend/components/dashboard/opportunity-v4-forecast.ts +++ /dev/null @@ -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)} 之外。`, - }; -} diff --git a/frontend/components/dashboard/opportunity-v4-types.ts b/frontend/components/dashboard/opportunity-v4-types.ts deleted file mode 100644 index 6c231453..00000000 --- a/frontend/components/dashboard/opportunity-v4-types.ts +++ /dev/null @@ -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"; -}; diff --git a/frontend/components/dashboard/opportunity-window-phase.ts b/frontend/components/dashboard/opportunity-window-phase.ts deleted file mode 100644 index 7fcf39e8..00000000 --- a/frontend/components/dashboard/opportunity-window-phase.ts +++ /dev/null @@ -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, - 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", - }; -} diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts index 39674891..20088531 100644 --- a/frontend/components/dashboard/scan-root-styles.ts +++ b/frontend/components/dashboard/scan-root-styles.ts @@ -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, ); diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx deleted file mode 100644 index 944e19b7..00000000 --- a/frontend/components/dashboard/scan-terminal/AiPinnedCityCard.tsx +++ /dev/null @@ -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; - 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) => { - event.preventDefault(); - event.stopPropagation(); - if (refreshingDetail) return; - setRefreshingDetail(true); - void onRefreshCityDetail(detailCityName) - .catch(() => {}) - .finally(() => { - refreshAiForecast(); - setRefreshingDetail(false); - }); - }; - const handleRemove = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onRemove(); - }; - - return ( -
- {isCompactCard ? ( - - ) : ( - <> - - - {detail && !collapsed ? ( -
- - -
- - -
- - -
- ) : !detail ? ( -
- -
- ) : null} - - )} -
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx b/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx deleted file mode 100644 index bcc84baf..00000000 --- a/frontend/components/dashboard/scan-terminal/AiPinnedForecastView.tsx +++ /dev/null @@ -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; - locale: string; - onRefreshCityDetail: (cityName: string) => Promise; - onRemoveCity: (cityName: string) => void; -}) { - const isEn = locale === "en-US"; - const [expandedCityKey, setExpandedCityKey] = useState(null); - const [removingCities, setRemovingCities] = useState>( - () => new Set(), - ); - const cityCardRefs = useRef>({}); - const knownCityKeysRef = useRef>(new Set()); - const previousFirstCityKeyRef = useRef(null); - const removeTimersRef = useRef>>(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 ( -
-
-
- {isEn ? "Select a city from the market list" : "从市场列表选择城市"} -
-
- {isEn - ? "Selected cities will appear here as deep analysis blocks." - : "选中的城市会加入深度分析页,并保留为城市分析区块。"} -
-
-
- ); - } - - return ( -
-
-
- {isEn ? "Selected city workspace" : "城市分析工作区"} - - {isEn - ? `${items.length} cities under deep analysis` - : `${items.length} 个城市正在深度分析`} - -
-

- {isEn - ? "Market-list selections add cities here. City analysis stays here until you remove it." - : "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"} -

-
-
- {items.map((item) => { - const stableKey = normalizeCityKey(item.cityName) || item.cityName; - return ( - - ); - })} -
-
- {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 ( -
{ - cityCardRefs.current[stableKey] = node; - }} - className="scan-ai-city-anchor" - > - removeCityWithMotion(item, stableKey)} - onToggleCollapsed={() => { - setExpandedCityKey((current) => - current === stableKey ? null : stableKey, - ); - }} - /> -
- ); - })} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx b/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx deleted file mode 100644 index 7b52a0ee..00000000 --- a/frontend/components/dashboard/scan-terminal/MobileDecisionCard.tsx +++ /dev/null @@ -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) => void; - onRemove: (event: MouseEvent) => 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 ( - <> -
-
- - {isEn ? "Mobile action card" : "移动端行动卡"} - -

{displayName}

-
-
- - -
-
- -
- - {copy.expectedHigh} - {expectedHighText} - - - {copy.peakWindow} - {peakWindow} - -
- -

{decisionState.primaryReason}

- - - {!detail ? ( -
- -
- ) : ( -
- - -
setModelOpen(event.currentTarget.open)} - > - {copy.modelEvidence} - {modelOpen ? : null} -
- -
setChartOpen(event.currentTarget.open)} - > - {copy.chart} - {chartOpen ? : null} -
-
- )} - - ); -} diff --git a/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx deleted file mode 100644 index 2327af0d..00000000 --- a/frontend/components/dashboard/scan-terminal/ModelEvidencePanel.tsx +++ /dev/null @@ -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 ( -
-
- {isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"} -
- -
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts b/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts deleted file mode 100644 index 742af19d..00000000 --- a/frontend/components/dashboard/scan-terminal/use-ai-pinned-city-workspace.ts +++ /dev/null @@ -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; - -export function useAiPinnedCityWorkspace({ - locale, - store, - timeSortedRows, -}: { - locale: string; - store: DashboardStore; - timeSortedRows: ScanOpportunityRow[]; -}) { - const [aiPinnedCities, setAiPinnedCities] = useState([]); - const aiFullHydrationRef = useRef>(new Set()); - const aiHydrationQueueRef = useRef([]); - const aiHydrationRunningRef = useRef(false); - const aiHydrationRetriesRef = useRef>(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, - }; -} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx deleted file mode 100644 index e2e79ffb..00000000 --- a/frontend/hooks/useDashboardStore.tsx +++ /dev/null @@ -1,1773 +0,0 @@ -"use client"; - -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - dashboardClient, - getCityRevision, - toCitySummary, -} from "@/lib/dashboard-client"; -import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; -import { - getSupabaseBrowserClient, - hasSupabasePublicEnv, -} from "@/lib/supabase/client"; -import { - getLocalDevProAccessState, - isBrowserLocalFullAccess, -} from "@/lib/local-dev-access"; -import { - CityDetail, - CityListItem, - CitySummary, - DashboardState, - ForecastModalMode, - LoadingState, - ProAccessState, -} from "@/lib/dashboard-types"; - -interface DashboardStoreValue extends DashboardState { - clearCityFocus: () => void; - closeFutureModal: () => void; - closePanel: () => void; - ensureCityDetail: ( - cityName: string, - force?: boolean, - depth?: "panel" | "market" | "nearby" | "full", - ) => Promise; - ensureCityMarketScan: ( - cityName: string, - force?: boolean, - options?: { - lite?: boolean; - marketSlug?: string | null; - targetDate?: string | null; - }, - ) => Promise; - focusCity: (cityName: string) => Promise; - forecastModalMode: ForecastModalMode | null; - futureModalDate: string | null; - loadCities: () => Promise; - preloadCityFromRow: (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null }) => void; - openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; - openTodayModal: (forceRefresh?: boolean) => Promise; - registerMapStopMotion: (stopMotion: () => void) => void; - refreshAll: () => Promise; - refreshProAccess: () => Promise; - refreshSelectedCity: () => Promise; - selectedDetail: CityDetail | null; - selectCity: (cityName: string) => Promise; - setMapInteractionActive: (active: boolean) => void; - setForecastDate: (dateStr: string | null) => void; -} - -type DashboardModalContextValue = Pick< - DashboardStoreValue, - | "closeFutureModal" - | "forecastModalMode" - | "futureModalDate" - | "loadingState" - | "openFutureModal" - | "openTodayModal" - | "selectedForecastDate" - | "setForecastDate" ->; -type DashboardProAccessContextValue = Pick< - DashboardStoreValue, - "proAccess" | "refreshProAccess" ->; - -const DashboardStoreContext = createContext(null); -const DashboardActionsContext = createContext | null>(null); -const DashboardModalContext = - createContext(null); -const DashboardProAccessContext = - createContext(null); -const DashboardSelectionContext = createContext | null>(null); -const CityDetailsContext = createContext<{ - cityDetailsByName: Record; - cityDetailMetaByName: Record; - citySummariesByName: Record; - loadingState: LoadingState; -} | null>(null); - -function getInitialLoadingState(): LoadingState { - return { - cities: false, - cityDetail: false, - futureDeep: false, - refresh: false, - marketScan: false, - }; -} - -function getInitialProAccessState(): ProAccessState { - if (isBrowserLocalFullAccess()) { - return getLocalDevProAccessState(); - } - const cached = readStoredProAccess(); - if (cached) { - return cached; - } - return { - loading: true, - authenticated: false, - userId: null, - subscriptionActive: false, - subscriptionPlanCode: null, - subscriptionExpiresAt: null, - subscriptionTotalExpiresAt: null, - subscriptionQueuedDays: 0, - points: 0, - error: null, - }; -} - -const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1"; -const PRO_ACCESS_STORAGE_KEY = "polyWeather_pro_access_v1"; -const CITY_LOAD_RETRY_DELAYS_MS = [700, 1600]; -const PRO_ACCESS_FALLBACK_TTL_MS = 24 * 60 * 60 * 1000; -type CityDetailDepth = "panel" | "market" | "nearby" | "full"; - -type StoredProAccessState = ProAccessState & { - cachedAt: number; - expiresAtMs: number; - version: 1; -}; - -function wait(ms: number) { - return new Promise((resolve) => { - if ( - typeof window !== "undefined" && - typeof window.setTimeout === "function" - ) { - window.setTimeout(resolve, ms); - return; - } - resolve(undefined); - }); -} - -function getSubscriptionExpiryMs(access: Pick< - ProAccessState, - "subscriptionExpiresAt" | "subscriptionTotalExpiresAt" ->) { - const raw = - access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt || ""; - const parsed = Date.parse(raw); - return Number.isFinite(parsed) ? parsed : 0; -} - -function clearStoredProAccess() { - if (typeof window === "undefined") return; - try { - window.localStorage.removeItem(PRO_ACCESS_STORAGE_KEY); - } catch { - // Ignore storage failures; backend auth remains the source of truth. - } -} - -function readStoredProAccess(): ProAccessState | null { - if (typeof window === "undefined") return null; - try { - const raw = window.localStorage.getItem(PRO_ACCESS_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - if (parsed.version !== 1) { - clearStoredProAccess(); - return null; - } - if (!parsed.authenticated || !parsed.subscriptionActive || !parsed.userId) { - clearStoredProAccess(); - return null; - } - const expiresAtMs = Number(parsed.expiresAtMs || 0); - if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) { - clearStoredProAccess(); - return null; - } - return { - loading: false, - authenticated: true, - userId: String(parsed.userId), - subscriptionActive: true, - subscriptionPlanCode: parsed.subscriptionPlanCode ?? null, - subscriptionExpiresAt: parsed.subscriptionExpiresAt ?? null, - subscriptionTotalExpiresAt: parsed.subscriptionTotalExpiresAt ?? null, - subscriptionQueuedDays: Math.max( - 0, - Number(parsed.subscriptionQueuedDays ?? 0), - ), - points: Number(parsed.points ?? 0), - error: null, - }; - } catch { - clearStoredProAccess(); - return null; - } -} - -function writeStoredProAccess(access: ProAccessState) { - if (typeof window === "undefined") return; - if (!access.authenticated || !access.subscriptionActive || !access.userId) { - clearStoredProAccess(); - return; - } - const explicitExpiryMs = getSubscriptionExpiryMs(access); - const expiresAtMs = - explicitExpiryMs > Date.now() - ? explicitExpiryMs - : Date.now() + PRO_ACCESS_FALLBACK_TTL_MS; - const payload: StoredProAccessState = { - ...access, - loading: false, - error: null, - cachedAt: Date.now(), - expiresAtMs, - version: 1, - }; - try { - window.localStorage.setItem(PRO_ACCESS_STORAGE_KEY, JSON.stringify(payload)); - } catch { - // Storage can be unavailable in private mode; keep the in-memory state. - } -} - -function mergeWithStoredProAccess( - next: ProAccessState, - reason: string, -): ProAccessState { - if (next.subscriptionActive || !next.authenticated) return next; - const cached = readStoredProAccess(); - if (!cached) return next; - if (next.userId && cached.userId !== next.userId) return next; - const payloadExpiryMs = getSubscriptionExpiryMs(next); - if (payloadExpiryMs > 0 && payloadExpiryMs <= Date.now()) return next; - return { - ...cached, - loading: false, - points: Math.max(cached.points, next.points), - error: reason, - }; -} - -async function buildAuthMeHeaders(): Promise { - const headers: Record = { - Accept: "application/json", - }; - if (!hasSupabasePublicEnv()) { - return headers; - } - - try { - const { - data: { session: cachedSession }, - } = await getSupabaseBrowserClient().auth.getSession(); - let accessToken = String(cachedSession?.access_token || "").trim(); - const expiresAtSec = Number(cachedSession?.expires_at || 0); - const nowSec = Math.floor(Date.now() / 1000); - if (!accessToken || (Number.isFinite(expiresAtSec) && expiresAtSec <= nowSec + 60)) { - const { - data: { session: refreshedSession }, - } = await getSupabaseBrowserClient().auth.refreshSession(); - accessToken = String(refreshedSession?.access_token || accessToken || "").trim(); - } - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}`; - } - } catch { - // The same-origin route can still fall back to cookie-backed auth. - } - return headers; -} - -function countAvailableModels( - detail?: CityDetail | null, - targetDate?: string | null, -): number { - if (!detail) return 0; - const date = String(targetDate || detail.local_date || "").trim(); - const dailyModels = detail.multi_model_daily?.[date]?.models; - const models = dailyModels && typeof dailyModels === "object" - ? dailyModels - : detail.multi_model || {}; - return Object.values(models).filter((value) => - Number.isFinite(Number(value)), - ).length; -} - -function countForecastDays(detail?: CityDetail | null): number { - const daily = detail?.forecast?.daily; - if (!Array.isArray(daily)) return 0; - return new Set( - daily - .map((day) => String(day?.date || "").trim()) - .filter(Boolean), - ).size; -} - -function normalizeCityLookupKey(value?: string | null): string { - return String(value || "").trim().toLowerCase(); -} - -function findCachedCityDetail( - detailsByName: Record, - cityName?: string | null, -) { - const key = normalizeCityLookupKey(cityName); - if (!key) return null; - return ( - detailsByName[cityName || ""] || - Object.entries(detailsByName).find(([storedName, detail]) => - [storedName, detail?.name, detail?.display_name].some( - (value) => normalizeCityLookupKey(value) === key, - ), - )?.[1] || - null - ); -} - -function hasSparseModelCoverage( - detail?: CityDetail | null, - targetDate?: string | null, -): boolean { - return countAvailableModels(detail, targetDate) <= 1; -} - -function hasSparseDetailCoverage( - detail?: CityDetail | null, - targetDate?: string | null, -): boolean { - if (!detail) return true; - return ( - hasSparseModelCoverage(detail, targetDate) || countForecastDays(detail) <= 1 - ); -} - -function hasMarketDetailCoverage( - detail?: CityDetail | null, - targetDate?: string | null, -): boolean { - if (!detail) return false; - return countAvailableModels(detail, targetDate) > 1; -} - -function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth { - if (detail?.detail_depth === "market") return "market"; - if (detail?.detail_depth === "nearby") return "nearby"; - if (detail?.detail_depth === "panel") return "panel"; - return "full"; -} - -function detailSatisfiesDepth( - detail: CityDetail | null | undefined, - depth: CityDetailDepth, - targetDate?: string | null, -) { - if (!detail) return false; - if (depth === "panel") return true; - if (depth === "market") { - const normalized = normalizeDetailDepth(detail); - return ( - normalized === "market" || - normalized === "full" || - hasMarketDetailCoverage(detail, targetDate) - ); - } - if (depth === "nearby") { - const normalized = normalizeDetailDepth(detail); - return normalized === "nearby" || normalized === "full"; - } - return normalizeDetailDepth(detail) === "full"; -} - -function hasMeaningfulModelMap( - value: Record | undefined, -): value is Record { - return Boolean( - value && - Object.values(value).some((entry) => Number.isFinite(Number(entry))), - ); -} - -function hasMeaningfulDailyModelMap( - value: CityDetail["multi_model_daily"] | undefined, -) { - return Boolean( - value && - Object.values(value).some((day) => - hasMeaningfulModelMap(day?.models || undefined), - ), - ); -} - -function countModelMapEntries(value: Record | undefined) { - if (!value || typeof value !== "object") return 0; - return Object.values(value).filter((entry) => Number.isFinite(Number(entry))).length; -} - -function pickRicherModelMap( - currentValue: CityDetail["multi_model"] | undefined, - incomingValue: CityDetail["multi_model"] | undefined, -) { - return countModelMapEntries(incomingValue) >= countModelMapEntries(currentValue) - ? incomingValue || currentValue - : currentValue; -} - -function mergeDailyModelMap( - currentValue: CityDetail["multi_model_daily"] | undefined, - incomingValue: CityDetail["multi_model_daily"] | undefined, -) { - if (!hasMeaningfulDailyModelMap(incomingValue)) return currentValue; - if (!hasMeaningfulDailyModelMap(currentValue)) return incomingValue; - const merged = { ...(currentValue || {}) }; - Object.entries(incomingValue || {}).forEach(([date, incomingDay]) => { - const currentDay = merged[date]; - const incomingCount = countModelMapEntries(incomingDay?.models || undefined); - const currentCount = countModelMapEntries(currentDay?.models || undefined); - if (incomingCount >= currentCount) { - merged[date] = { - ...(currentDay || {}), - ...(incomingDay || {}), - models: incomingDay?.models || currentDay?.models, - }; - } - }); - return merged; -} - -function pickRicherForecast( - currentValue: CityDetail["forecast"] | undefined, - incomingValue: CityDetail["forecast"] | undefined, -) { - const picked = countForecastDays({ forecast: incomingValue } as CityDetail) >= - countForecastDays({ forecast: currentValue } as CityDetail) - ? incomingValue || currentValue - : currentValue; - if (!picked?.daily || !Array.isArray(picked.daily)) return picked; - const seen = new Set(); - return { - ...picked, - daily: picked.daily.filter((day) => { - const date = String(day?.date || "").trim(); - if (!date || seen.has(date)) return false; - seen.add(date); - return true; - }), - }; -} - -function countHourlyPoints(value: CityDetail["hourly"] | undefined) { - const times = Array.isArray(value?.times) ? value?.times || [] : []; - const temps = Array.isArray(value?.temps) ? value?.temps || [] : []; - return Math.min(times.length, temps.length); -} - -function pickRicherHourly( - currentValue: CityDetail["hourly"] | undefined, - incomingValue: CityDetail["hourly"] | undefined, -) { - const incomingCount = countHourlyPoints(incomingValue); - const currentCount = countHourlyPoints(currentValue); - if (incomingCount <= 0) return currentValue; - if (currentCount <= 0) return incomingValue; - return incomingCount >= currentCount ? incomingValue : currentValue; -} - -function countObservationSeriesPoints( - value: T[] | null | undefined, -) { - return (Array.isArray(value) ? value : []).filter((row) => { - const time = String(row?.time || "").trim(); - const temp = Number(row?.temp); - return Boolean(time) && Number.isFinite(temp); - }).length; -} - -function pickRicherObservationSeries< - T extends { time?: string | null; temp?: number | null }, ->( - currentValue: T[] | null | undefined, - incomingValue: T[] | null | undefined, -): T[] | undefined { - const incomingCount = countObservationSeriesPoints(incomingValue); - const currentCount = countObservationSeriesPoints(currentValue); - if (incomingCount <= 0) return currentValue || undefined; - if (currentCount <= 0) return incomingValue || undefined; - return (incomingCount >= currentCount ? incomingValue : currentValue) || undefined; -} - -function mergeTrendInfo( - currentValue: CityDetail["trend"] | undefined, - incomingValue: CityDetail["trend"] | undefined, -) { - if (!incomingValue) return currentValue; - if (!currentValue) return incomingValue; - return { - ...currentValue, - ...incomingValue, - recent: pickRicherObservationSeries( - currentValue.recent, - incomingValue.recent, - ), - }; -} - -function mergeMgmData( - currentValue: CityDetail["mgm"] | undefined, - incomingValue: CityDetail["mgm"] | undefined, -) { - if (!incomingValue) return currentValue; - if (!currentValue) return incomingValue; - return { - ...currentValue, - ...incomingValue, - hourly: pickRicherObservationSeries( - currentValue.hourly, - incomingValue.hourly, - ), - }; -} - -function pickPreferredNearbyStations( - currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"], - incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"], -) { - const currentList = Array.isArray(currentValue) ? currentValue : []; - const incomingList = Array.isArray(incomingValue) ? incomingValue : []; - if (incomingList.length > 0) { - return incomingList; - } - return currentList; -} - -function mergeCityDetail( - current: CityDetail | undefined, - incoming: CityDetail, -): CityDetail { - if (!current) return incoming; - - const currentDepth = normalizeDetailDepth(current); - const incomingDepth = normalizeDetailDepth(incoming); - const mergedDepth = - currentDepth === "full" || incomingDepth === "full" - ? "full" - : currentDepth === "nearby" || incomingDepth === "nearby" - ? "nearby" - : currentDepth === "market" || incomingDepth === "market" - ? "market" - : "panel"; - - return { - ...current, - ...incoming, - detail_depth: mergedDepth, - current: incoming.current || current.current, - airport_current: incoming.airport_current || current.airport_current, - deb: incoming.deb || current.deb, - probabilities: incoming.probabilities || current.probabilities, - trend: mergeTrendInfo(current.trend, incoming.trend), - metar_today_obs: pickRicherObservationSeries( - current.metar_today_obs, - incoming.metar_today_obs, - ), - settlement_today_obs: pickRicherObservationSeries( - current.settlement_today_obs, - incoming.settlement_today_obs, - ), - mgm: mergeMgmData(current.mgm, incoming.mgm), - multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model), - multi_model_daily: mergeDailyModelMap( - current.multi_model_daily, - incoming.multi_model_daily, - ), - forecast: pickRicherForecast(current.forecast, incoming.forecast), - hourly: pickRicherHourly(current.hourly, incoming.hourly), - official_nearby: pickPreferredNearbyStations( - current.official_nearby, - incoming.official_nearby, - ), - mgm_nearby: pickPreferredNearbyStations( - current.mgm_nearby, - incoming.mgm_nearby, - ), - network_lead_signal: - current.network_lead_signal || incoming.network_lead_signal, - airport_vs_network_delta: - current.airport_vs_network_delta ?? incoming.airport_vs_network_delta, - }; -} - -function mergeMarketScan( - current: CityDetail["market_scan"] | undefined, - incoming: CityDetail["market_scan"] | null | undefined, -): CityDetail["market_scan"] | undefined { - if (!incoming) return current; - if (!current) return incoming || undefined; - - const preserveHeavySlices = incoming.scan_scope === "lite"; - const nextTopBuckets = - preserveHeavySlices && - (!Array.isArray(incoming.top_buckets) || incoming.top_buckets.length === 0) - ? current.top_buckets - : incoming.top_buckets; - const nextAllBuckets = - preserveHeavySlices && - (!Array.isArray(incoming.all_buckets) || incoming.all_buckets.length === 0) - ? current.all_buckets - : incoming.all_buckets; - const nextRecentTrades = - preserveHeavySlices && - (!Array.isArray(incoming.recent_trades) || incoming.recent_trades.length === 0) - ? current.recent_trades - : incoming.recent_trades; - - return { - ...current, - ...incoming, - top_buckets: nextTopBuckets, - all_buckets: nextAllBuckets, - recent_trades: nextRecentTrades, - }; -} - -export function DashboardStoreProvider({ - children, -}: { - children: React.ReactNode; -}) { - const initialCacheRef = useRef | null>(null); - const [cities, setCities] = useState([]); - const [cityDetailsByName, setCityDetailsByName] = useState< - Record - >({}); - const [citySummariesByName, setCitySummariesByName] = useState< - Record - >({}); - const [cityDetailMetaByName, setCityDetailMetaByName] = useState< - Record - >({}); - const MAX_CACHED_CITIES = 20; - const cityLruRef = useRef([]); - - const touchCityLru = (cityName: string) => { - cityLruRef.current = [ - ...cityLruRef.current.filter((c) => c !== cityName), - cityName, - ]; - }; - - // Evict LRU cities when cache exceeds limit - useEffect(() => { - const detailsCount = Object.keys(cityDetailsByName).length; - if (detailsCount <= MAX_CACHED_CITIES) return; - const lru = cityLruRef.current; - const excess = detailsCount - MAX_CACHED_CITIES; - const toEvict = lru.slice(0, excess); - if (!toEvict.length) return; - cityLruRef.current = lru.slice(excess); - setCityDetailsByName((current) => { - const next = { ...current }; - toEvict.forEach((c) => delete next[c]); - return next; - }); - setCitySummariesByName((current) => { - const next = { ...current }; - toEvict.forEach((c) => delete next[c]); - return next; - }); - setCityDetailMetaByName((current) => { - const next = { ...current }; - toEvict.forEach((c) => delete next[c]); - return next; - }); - }, [cityDetailsByName]); - - const [selectedCity, setSelectedCity] = useState(null); - const [isPanelOpen, setIsPanelOpen] = useState(false); - const [selectedForecastDate, setSelectedForecastDate] = useState< - string | null - >(null); - const [futureModalDate, setFutureModalDate] = useState(null); - const [forecastModalMode, setForecastModalMode] = - useState(null); - const [isMapInteracting, setIsMapInteracting] = useState(false); - const [loadingState, setLoadingState] = useState( - getInitialLoadingState, - ); - const [proAccess, setProAccess] = useState( - getInitialProAccessState, - ); - const proAccessRef = useRef(getInitialProAccessState()); - - const mapStopMotionRef = useRef<() => void>(() => {}); - const modalOpenSeqRef = useRef(0); - const hydratedSelectionRef = useRef(false); - const hydratedProCacheRef = useRef(false); - const summaryInflightByCityRef = useRef>>( - {}, - ); - const citiesRef = useRef([]); - const citySummariesRef = useRef>({}); - const selectedCityRef = useRef(null); - const setCityDetailLoading = (isLoading: boolean) => { - setLoadingState((current) => - current.cityDetail === isLoading - ? current - : { ...current, cityDetail: isLoading }, - ); - }; - const selectedDetail = selectedCity - ? findCachedCityDetail(cityDetailsByName, selectedCity) - : null; - useEffect(() => { - if (proAccess.loading) return; - if (!proAccess.authenticated || !proAccess.subscriptionActive) { - return; - } - dashboardClient.writeCityDetailCacheBundle( - cityDetailsByName, - cityDetailMetaByName, - ); - }, [ - cityDetailMetaByName, - cityDetailsByName, - proAccess.authenticated, - proAccess.loading, - proAccess.subscriptionActive, - ]); - - useEffect(() => { - citiesRef.current = cities; - }, [cities]); - - useEffect(() => { - citySummariesRef.current = citySummariesByName; - }, [citySummariesByName]); - - useEffect(() => { - selectedCityRef.current = selectedCity; - }, [selectedCity]); - - useEffect(() => { - proAccessRef.current = proAccess; - }, [proAccess]); - - useEffect(() => { - if (proAccess.loading) return; - if (!proAccess.authenticated || !proAccess.subscriptionActive) { - hydratedProCacheRef.current = false; - initialCacheRef.current = null; - return; - } - if (hydratedProCacheRef.current) return; - - hydratedProCacheRef.current = true; - const cached = - initialCacheRef.current || dashboardClient.readCityDetailCacheBundle(); - initialCacheRef.current = cached; - if (!Object.keys(cached.details).length) return; - - setCityDetailsByName(cached.details); - setCityDetailMetaByName(cached.meta); - setCitySummariesByName((current) => ({ - ...Object.fromEntries( - Object.entries(cached.details).map(([cityName, detail]) => [ - cityName, - toCitySummary(detail), - ]), - ), - ...current, - })); - }, [proAccess.authenticated, proAccess.loading, proAccess.subscriptionActive]); - - useEffect(() => { - if (proAccess.loading) return; - if (proAccess.authenticated && proAccess.subscriptionActive) return; - dashboardClient.clearCityDetailCache(); - }, [proAccess]); - - const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => { - const cityName = (row.city || row.city_display_name || row.display_name || "").trim(); - if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return; - touchCityLru(cityName); - // Pre-populate cache from scan terminal row so detail panel shows data immediately - const now = Date.now(); - const currentTemp = Number(row.current_temp ?? row.current_max_so_far); - const debPrediction = Number(row.deb_prediction); - const rawModelSources = - row.model_cluster_sources && typeof row.model_cluster_sources === "object" - ? (row.model_cluster_sources as Record) - : {}; - const multiModel = Object.fromEntries( - Object.entries(rawModelSources) - .map(([name, value]) => [name, Number(value)] as const) - .filter(([, value]) => Number.isFinite(value)), - ); - setCityDetailsByName((current) => ({ - ...current, - [cityName]: { - name: String(row.city || cityName), - display_name: String(row.city_display_name || row.display_name || cityName), - detail_depth: "panel", - lat: 0, - lon: 0, - local_date: String(row.local_date || row.selected_date || ""), - local_time: String(row.local_time || ""), - temp_symbol: String(row.temp_symbol || "°C"), - current: { - temp: Number.isFinite(currentTemp) ? currentTemp : null, - max_so_far: Number.isFinite(Number(row.current_max_so_far)) - ? Number(row.current_max_so_far) - : Number.isFinite(currentTemp) - ? currentTemp - : null, - max_temp_time: null, - wu_settlement: null, - station_code: null, - station_name: String(row.airport || ""), - obs_time: String((row.metar_context as { last_time?: string } | null)?.last_time || ""), - obs_age_min: null, - wind_speed_kt: null, - wind_dir: null, - humidity: null, - cloud_desc: null, - clouds_raw: [], - visibility_mi: null, - wx_desc: null, - }, - deb: { - prediction: Number.isFinite(debPrediction) ? debPrediction : null, - }, - forecast: { today_high: null, daily: [] }, - hourly: { times: [], temps: [] }, - multi_model: multiModel, - probabilities: {}, - risk: { - level: String(row.risk_level || "medium"), - airport: String(row.airport || ""), - }, - } as CityDetail, - })); - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { cachedAt: now - CACHE_TTL_MS, revision: `scan-${now}` }, - })); - }; - - const CACHE_TTL_MS = 30 * 60 * 1000; // reuse same TTL as dashboard-client - - const ensureCityDetail = async ( - cityName: string, - force = false, - depth: CityDetailDepth = "panel", - ) => { - touchCityLru(cityName); - const cached = findCachedCityDetail(cityDetailsByName, cityName); - const cachedMeta = cityDetailMetaByName[cityName]; - const marketTargetDate = - depth === "market" ? selectedForecastDate || cached?.local_date : null; - const hasRequestedDepth = detailSatisfiesDepth( - cached, - depth, - marketTargetDate, - ); - if ( - !force && - cached && - hasRequestedDepth && - dashboardClient.isCityDetailFresh(cachedMeta) - ) { - return cached; - } - - if (!force && cached && hasRequestedDepth) { - // stale-while-revalidate: return cached immediately, refresh in background - void (async () => { - try { - const latestDetail = await dashboardClient.getCityDetail(cityName, { - force: false, - depth, - }); - const detail = latestDetail; - setCityDetailsByName((current) => ({ - ...current, - [cityName]: mergeCityDetail(current[cityName], detail), - })); - setCitySummariesByName((current) => ({ - ...current, - [cityName]: toCitySummary(detail), - })); - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { - cachedAt: Date.now(), - revision: getCityRevision(detail), - }, - })); - } catch { /* keep cached data on failure */ } - })(); - return cached; - } - - const latestDetail = await dashboardClient.getCityDetail(cityName, { - force, - depth, - }); - const detail = latestDetail; - setCityDetailsByName((current) => ({ - ...current, - [cityName]: mergeCityDetail(current[cityName], detail), - })); - setCitySummariesByName((current) => ({ - ...current, - [cityName]: toCitySummary(detail), - })); - setCityDetailMetaByName((current) => ({ - ...current, - [cityName]: { - cachedAt: Date.now(), - revision: getCityRevision(detail), - }, - })); - return detail; - }; - - const ensureCityMarketScan = async ( - cityName: string, - force = false, - options?: { - lite?: boolean; - marketSlug?: string | null; - targetDate?: string | null; - }, - ) => { - let cached = findCachedCityDetail(cityDetailsByName, cityName); - try { - if (!cached) { - cached = await ensureCityDetail(cityName, false, "panel"); - } - const payload = await dashboardClient.getCityMarketScan(cityName, { - force, - lite: options?.lite === true, - marketSlug: options?.marketSlug || null, - targetDate: - options?.targetDate || - (options?.marketSlug ? cached?.local_date || selectedForecastDate || null : null), - }); - if (!payload.market_scan) return null; - setCityDetailsByName((current) => { - const detail = current[cityName] || cached; - if (!detail) return current; - return { - ...current, - [cityName]: { - ...detail, - market_scan: mergeMarketScan(detail.market_scan, payload.market_scan), - }, - }; - }); - return payload.market_scan; - } catch { - return null; - } - }; - - useEffect(() => { - if (proAccess.loading) return; - if (!selectedCity) return; - if (!isPanelOpen) return; - if (findCachedCityDetail(cityDetailsByName, selectedCity)) return; - - let cancelled = false; - setCityDetailLoading(true); - void ensureCityDetail(selectedCity, false, "panel") - .then((detail) => { - if (cancelled) return; - setSelectedForecastDate(detail.local_date); - }) - .catch(() => {}) - .finally(() => { - if (cancelled) return; - setCityDetailLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [ - cityDetailsByName, - isPanelOpen, - proAccess.authenticated, - proAccess.loading, - proAccess.subscriptionActive, - selectedCity, - ]); - - const loadCities = async () => { - setLoadingState((current) => ({ ...current, cities: true })); - let lastError: unknown = null; - try { - for (let attempt = 0; attempt <= CITY_LOAD_RETRY_DELAYS_MS.length; attempt += 1) { - try { - const nextCities = await dashboardClient.getCities(); - if (!nextCities.length) { - throw new Error("City list was empty"); - } - setCities(nextCities); - return; - } catch (error) { - lastError = error; - const delayMs = CITY_LOAD_RETRY_DELAYS_MS[attempt]; - if (delayMs == null) break; - await wait(delayMs); - } - } - console.error("Failed to load monitored cities", lastError); - } finally { - setLoadingState((current) => ({ ...current, cities: false })); - } - }; - - const refreshProAccess = async () => { - if (isBrowserLocalFullAccess()) { - const localAccess = getLocalDevProAccessState(); - writeStoredProAccess(localAccess); - setProAccess(localAccess); - return; - } - setProAccess((current) => ({ - ...current, - loading: current.subscriptionActive ? false : true, - error: null, - })); - try { - const headers = await buildAuthMeHeaders(); - const response = await fetch("/api/auth/me", { - cache: "no-store", - headers, - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const payload = (await response.json()) as { - authenticated?: boolean; - user_id?: string | null; - subscription_active?: boolean | null; - subscription_plan_code?: string | null; - subscription_expires_at?: string | null; - subscription_total_expires_at?: string | null; - subscription_queued_days?: number | null; - points?: number; - degraded_auth_profile?: boolean | null; - degraded_reason?: string | null; - }; - const nextAccess: ProAccessState = { - loading: false, - authenticated: Boolean(payload.authenticated), - userId: payload.user_id ?? null, - subscriptionActive: payload.subscription_active === true, - subscriptionPlanCode: payload.subscription_plan_code ?? null, - subscriptionExpiresAt: payload.subscription_expires_at ?? null, - subscriptionTotalExpiresAt: - payload.subscription_total_expires_at ?? payload.subscription_expires_at ?? null, - subscriptionQueuedDays: Math.max( - 0, - Number(payload.subscription_queued_days ?? 0), - ), - points: payload.points ?? 0, - error: null, - }; - const mergedAccess = mergeWithStoredProAccess( - nextAccess, - payload.degraded_auth_profile - ? String(payload.degraded_reason || "degraded_auth_profile") - : "using_cached_pro_access", - ); - if (mergedAccess.subscriptionActive) { - writeStoredProAccess(mergedAccess); - } else if (!mergedAccess.authenticated || payload.subscription_active === false) { - clearStoredProAccess(); - } - setProAccess(mergedAccess); - } catch (error) { - const cachedAccess = readStoredProAccess(); - if (cachedAccess) { - setProAccess({ - ...cachedAccess, - loading: false, - error: String(error), - }); - return; - } - clearStoredProAccess(); - setProAccess({ - loading: false, - authenticated: false, - userId: null, - subscriptionActive: false, - subscriptionPlanCode: null, - subscriptionExpiresAt: null, - subscriptionTotalExpiresAt: null, - subscriptionQueuedDays: 0, - points: 0, - error: String(error), - }); - } - }; - - useEffect(() => { - void loadCities(); - }, []); - - useEffect(() => { - if (!cities.length) return; - if (typeof window === "undefined") return; - const schedule = () => dashboardClient.sendPriorityWarmHint(); - const idleCallback = (window as Window & { - requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; - }).requestIdleCallback; - if (typeof idleCallback === "function") { - const id = idleCallback(schedule, { timeout: 3000 }); - return () => { - if (typeof window.cancelIdleCallback === "function") { - window.cancelIdleCallback(id); - } - }; - } - if ( - typeof window.setTimeout !== "function" || - typeof window.clearTimeout !== "function" - ) { - return; - } - const timer = window.setTimeout(schedule, 2000); - return () => window.clearTimeout(timer); - }, [cities.length]); - - useEffect(() => { - void refreshProAccess(); - }, []); - - useEffect(() => { - if (!hasSupabasePublicEnv()) return; - const { - data: { subscription }, - } = getSupabaseBrowserClient().auth.onAuthStateChange((event) => { - if (event === "SIGNED_OUT") { - clearStoredProAccess(); - setProAccess({ - loading: false, - authenticated: false, - userId: null, - subscriptionActive: false, - subscriptionPlanCode: null, - subscriptionExpiresAt: null, - subscriptionTotalExpiresAt: null, - subscriptionQueuedDays: 0, - points: 0, - error: null, - }); - return; - } - if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { - void refreshProAccess(); - } - }); - return () => subscription.unsubscribe(); - }, []); - - const ensureCitySummary = async (cityName: string, force = false) => { - touchCityLru(cityName); - const existing = citySummariesRef.current[cityName]; - if (!force && existing) { - return existing; - } - - const inflight = summaryInflightByCityRef.current[cityName]; - if (inflight) { - const settled = await inflight; - if (!force) { - return settled; - } - } - - const request = dashboardClient - .getCitySummary(cityName, { force }) - .then((summary) => { - setCitySummariesByName((current) => { - const currentSummary = current[cityName]; - const currentRevision = getCityRevision(currentSummary); - const nextRevision = getCityRevision(summary); - if ( - currentSummary && - currentRevision && - nextRevision && - currentRevision === nextRevision - ) { - return current; - } - const next = { - ...current, - [cityName]: summary, - }; - citySummariesRef.current = next; - return next; - }); - return summary; - }) - .finally(() => { - if (summaryInflightByCityRef.current[cityName] === request) { - delete summaryInflightByCityRef.current[cityName]; - } - }); - - summaryInflightByCityRef.current[cityName] = request; - return request; - }; - - useEffect(() => { - if (proAccess.loading || !proAccess.authenticated || !proAccess.userId) { - return; - } - if ( - markAnalyticsOnce(`dashboard-active:${proAccess.userId}`, "session") - ) { - trackAppEvent("dashboard_active", { - subscription_active: proAccess.subscriptionActive, - subscription_plan_code: proAccess.subscriptionPlanCode, - }); - } - - }, [ - proAccess.authenticated, - proAccess.loading, - proAccess.subscriptionActive, - proAccess.subscriptionPlanCode, - proAccess.userId, - ]); - - const selectCity = async (cityName: string) => { - const wasSelectedCity = selectedCityRef.current === cityName; - const cached = findCachedCityDetail(cityDetailsByName, cityName); - selectedCityRef.current = cityName; - setSelectedCity(cityName); - setIsPanelOpen(true); - setSelectedForecastDate( - cached?.local_date || (wasSelectedCity ? selectedForecastDate : null), - ); - setFutureModalDate(null); - setForecastModalMode(null); - - const summaryPromise = !citySummariesRef.current[cityName] - ? ensureCitySummary(cityName).catch(() => null) - : Promise.resolve(citySummariesRef.current[cityName]); - - if (proAccessRef.current.loading) { - setCityDetailLoading(true); - const detailPromise = ensureCityDetail(cityName, false, "panel"); - try { - const [, detail] = await Promise.allSettled([summaryPromise, detailPromise]); - if (selectedCityRef.current === cityName) { - if (detail.status === "fulfilled") { - setSelectedForecastDate(detail.value.local_date); - } - } - } catch { - } finally { - if (selectedCityRef.current === cityName) { - setCityDetailLoading(false); - } - } - return; - } - - setCityDetailLoading(!cached); - const detailPromise = ensureCityDetail(cityName, false, "panel"); - void Promise.allSettled([summaryPromise, detailPromise]) - .then(([, detail]) => { - if (selectedCityRef.current !== cityName) return; - if (detail.status === "fulfilled") { - setSelectedForecastDate(detail.value.local_date); - } - }) - .finally(() => { - if (selectedCityRef.current !== cityName) return; - setCityDetailLoading(false); - }); - }; - - const focusCity = async (cityName: string) => { - const cached = findCachedCityDetail(cityDetailsByName, cityName); - selectedCityRef.current = cityName; - setSelectedCity(cityName); - setIsPanelOpen(false); - setSelectedForecastDate(null); - setFutureModalDate(null); - setForecastModalMode(null); - setCityDetailLoading(!cached); - void Promise.allSettled([ - ensureCitySummary(cityName), - ensureCityDetail(cityName, false, "panel"), - ]) - .then(([, detail]) => { - if (selectedCityRef.current !== cityName) return; - if (detail.status === "fulfilled") { - setSelectedForecastDate(detail.value.local_date); - } - }) - .finally(() => { - if (selectedCityRef.current !== cityName) return; - setCityDetailLoading(false); - }); - }; - - const clearCityFocus = () => { - selectedCityRef.current = null; - setSelectedCity(null); - setIsPanelOpen(false); - setSelectedForecastDate(null); - setFutureModalDate(null); - setForecastModalMode(null); - }; - - useEffect(() => { - if (typeof window === "undefined") return; - try { - if (!window.localStorage) return; - if (selectedCity) { - window.localStorage.setItem(SELECTED_CITY_STORAGE_KEY, selectedCity); - } else { - window.localStorage.removeItem(SELECTED_CITY_STORAGE_KEY); - } - } catch { - // Storage can be unavailable in embedded/private browser contexts. - } - }, [selectedCity]); - - useEffect(() => { - if (hydratedSelectionRef.current) return; - if (!cities.length) return; - if (selectedCity) { - hydratedSelectionRef.current = true; - return; - } - if (typeof window === "undefined") return; - - hydratedSelectionRef.current = true; - try { - window.localStorage?.removeItem(SELECTED_CITY_STORAGE_KEY); - } catch { - // Storage can be unavailable in embedded/private browser contexts. - } - }, [cities, selectedCity]); - - const refreshSelectedCity = async () => { - if (!selectedCity) return; - setLoadingState((current) => ({ ...current, refresh: true })); - try { - const detail = await ensureCityDetail(selectedCity, true, "panel"); - setSelectedForecastDate(detail.local_date); - } finally { - setLoadingState((current) => ({ ...current, refresh: false })); - } - }; - - const refreshAll = async () => { - dashboardClient.clearCityDetailCache(); - setCityDetailsByName({}); - setCitySummariesByName({}); - setCityDetailMetaByName({}); - cityLruRef.current = []; - if (!citiesRef.current.length) { - await loadCities(); - } - if (selectedCity) { - const access = proAccessRef.current; - setLoadingState((current) => ({ ...current, refresh: true })); - try { - if (access.authenticated && access.subscriptionActive) { - const latestDetail = await dashboardClient.getCityDetail(selectedCity, { - force: true, - depth: "panel", - }); - const detail = latestDetail; - setCityDetailsByName({ [selectedCity]: detail }); - setCitySummariesByName((current) => ({ - ...current, - [selectedCity]: toCitySummary(detail), - })); - setCityDetailMetaByName({ - [selectedCity]: { - cachedAt: Date.now(), - revision: getCityRevision(detail), - }, - }); - setSelectedForecastDate(detail.local_date); - } else { - const summary = await ensureCitySummary(selectedCity, true); - setCitySummariesByName((current) => ({ - ...current, - [selectedCity]: summary, - })); - } - } finally { - setLoadingState((current) => ({ ...current, refresh: false })); - } - } - }; - - const closeFutureModal = () => { - modalOpenSeqRef.current += 1; - setFutureModalDate(null); - setForecastModalMode(null); - }; - - - const openFutureModal = async (dateStr: string, forceRefresh = false) => { - mapStopMotionRef.current(); - if (!selectedCity || !proAccess.subscriptionActive) return; - const cityName = selectedCity; - const modalSeq = (modalOpenSeqRef.current += 1); - const isLatestModalRequest = () => - modalOpenSeqRef.current === modalSeq && - selectedCityRef.current === cityName; - let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity); - if (!cachedDetail) { - setCityDetailLoading(true); - try { - cachedDetail = await ensureCityDetail(cityName, false, "panel"); - } finally { - if (isLatestModalRequest()) { - setCityDetailLoading(false); - } - } - } - if (!isLatestModalRequest()) return; - const hasFullCachedDetail = - detailSatisfiesDepth(cachedDetail, "full") && - !hasSparseDetailCoverage(cachedDetail, dateStr); - const todayDate = - cachedDetail?.local_date || - cachedDetail?.forecast?.daily?.[0]?.date || - null; - const modalMode: ForecastModalMode = - todayDate && dateStr === todayDate ? "today" : "future"; - - setSelectedForecastDate(dateStr); - setFutureModalDate(dateStr); - setForecastModalMode(modalMode); - if (!hasFullCachedDetail || forceRefresh) { - setLoadingState((current) => ({ - ...current, - futureDeep: true, - })); - void ensureCityDetail(cityName, true, "full") - .catch(() => {}) - .finally(() => { - if (!isLatestModalRequest()) return; - setLoadingState((current) => ({ - ...current, - futureDeep: false, - })); - }); - } - }; - - const openTodayModal = async (forceRefresh?: boolean) => { - const activeCity = selectedCityRef.current || selectedCity; - if (!activeCity) { - return; - } - - mapStopMotionRef.current(); - const cityName = activeCity; - const modalSeq = (modalOpenSeqRef.current += 1); - const isLatestModalRequest = () => - modalOpenSeqRef.current === modalSeq && - selectedCityRef.current === cityName; - let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName); - if (!cachedDetail) { - setCityDetailLoading(true); - try { - cachedDetail = await ensureCityDetail(cityName, false, "panel"); - } finally { - if (isLatestModalRequest()) { - setCityDetailLoading(false); - } - } - } - if (!isLatestModalRequest()) return; - const hasFullCachedDetail = - detailSatisfiesDepth(cachedDetail, "full") && - !hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); - const targetDate = - cachedDetail?.local_date || - cachedDetail?.forecast?.daily?.[0]?.date || - null; - if (targetDate) { - setSelectedForecastDate(targetDate); - setFutureModalDate(targetDate); - setForecastModalMode("today"); - } - if (!proAccess.subscriptionActive) return; - const needsDetailRefresh = - forceRefresh || - !detailSatisfiesDepth(cachedDetail, "full") || - hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); - - setLoadingState((current) => ({ - ...current, - futureDeep: needsDetailRefresh, - })); - void ensureCityDetail(cityName, needsDetailRefresh, "full") - .then((detail) => { - if (!isLatestModalRequest()) return; - setSelectedForecastDate(detail.local_date); - setFutureModalDate(detail.local_date); - setForecastModalMode("today"); - }) - .catch(() => { - if (!isLatestModalRequest()) return; - if (cachedDetail?.local_date) { - setSelectedForecastDate(cachedDetail.local_date); - setFutureModalDate(cachedDetail.local_date); - setForecastModalMode("today"); - } - }) - .finally(() => { - if (!isLatestModalRequest()) return; - setLoadingState((current) => ({ - ...current, - futureDeep: false, - })); - }); - }; - - const setForecastDate = (dateStr: string | null) => - setSelectedForecastDate(dateStr); - - const value = useMemo( - () => ({ - cities, - cityDetailsByName, - citySummariesByName, - clearCityFocus, - closeFutureModal, - closePanel: () => { - setIsPanelOpen(false); - }, - ensureCityDetail, - ensureCityMarketScan, - focusCity, - forecastModalMode, - futureModalDate, - isPanelOpen, - loadCities, - preloadCityFromRow, - loadingState, - proAccess, - openFutureModal, - openTodayModal, - registerMapStopMotion: (stopMotion: () => void) => { - mapStopMotionRef.current = stopMotion; - }, - refreshAll, - refreshProAccess, - refreshSelectedCity, - selectedCity, - selectedDetail, - selectedForecastDate, - selectCity, - setMapInteractionActive: setIsMapInteracting, - setForecastDate, - }), - [ - cities, - cityDetailsByName, - citySummariesByName, - forecastModalMode, - futureModalDate, - isPanelOpen, - loadingState, - proAccess, - selectedCity, - selectedDetail, - selectedForecastDate, - ], - ); - - const cityDetailsValue = useMemo( - () => ({ cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState }), - [cityDetailsByName, cityDetailMetaByName, citySummariesByName, loadingState], - ); - const latestEnsureCityDetailRef = useRef(ensureCityDetail); - useEffect(() => { - latestEnsureCityDetailRef.current = ensureCityDetail; - }, [ensureCityDetail]); - const dashboardActionsValue = useMemo>( - () => ({ - ensureCityDetail: (...args) => latestEnsureCityDetailRef.current(...args), - }), - [], - ); - const dashboardSelectionValue = useMemo< - NonNullable> - >( - () => ({ - cities, - forecastModalMode, - futureModalDate, - isPanelOpen, - selectedCity, - selectedDetail, - selectedForecastDate, - }), - [ - cities, - forecastModalMode, - futureModalDate, - isPanelOpen, - selectedCity, - selectedDetail, - selectedForecastDate, - ], - ); - const dashboardModalValue = useMemo( - () => ({ - closeFutureModal, - forecastModalMode, - futureModalDate, - loadingState, - openFutureModal, - openTodayModal, - selectedForecastDate, - setForecastDate, - }), - [ - closeFutureModal, - forecastModalMode, - futureModalDate, - loadingState, - openFutureModal, - openTodayModal, - selectedForecastDate, - setForecastDate, - ], - ); - const dashboardProAccessValue = useMemo( - () => ({ - proAccess, - refreshProAccess, - }), - [proAccess, refreshProAccess], - ); - - return ( - - - - - - - {children} - - - - - - - ); -} - -export function useDashboardStore() { - const context = useContext(DashboardStoreContext); - if (!context) { - throw new Error( - "useDashboardStore must be used within DashboardStoreProvider", - ); - } - return context; -} - -export function useCityDetails() { - const context = useContext(CityDetailsContext); - if (!context) { - throw new Error( - "useCityDetails must be used within DashboardStoreProvider", - ); - } - return context; -} - -export function useDashboardActions() { - const context = useContext(DashboardActionsContext); - if (!context) { - throw new Error( - "useDashboardActions must be used within DashboardStoreProvider", - ); - } - return context; -} - -export function useDashboardModal() { - const context = useContext(DashboardModalContext); - if (!context) { - throw new Error( - "useDashboardModal must be used within DashboardStoreProvider", - ); - } - return context; -} - -export function useProAccess() { - const context = useContext(DashboardProAccessContext); - if (!context) { - throw new Error("useProAccess must be used within DashboardStoreProvider"); - } - return context; -} - -export function useDashboardSelection() { - const context = useContext(DashboardSelectionContext); - if (!context) { - throw new Error( - "useDashboardSelection must be used within DashboardStoreProvider", - ); - } - return context; -} - -export function useCityData(name?: string | null) { - const selection = useDashboardSelection(); - const details = useCityDetails(); - const key = name || selection.selectedCity; - return { - data: key ? findCachedCityDetail(details.cityDetailsByName, key) : null, - isLoading: - details.loadingState.cityDetail && - Boolean(key) && - selection.selectedCity === key, - }; -} diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts deleted file mode 100644 index 2f839cd0..00000000 --- a/frontend/lib/dashboard-client.ts +++ /dev/null @@ -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>(); -const pendingCitySummaryRequests = new Map>(); -const pendingCityMarketScanRequests = new Map< - string, - Promise<{ - fetched_at?: string | null; - market_scan?: MarketScan | null; - selected_date?: string | null; - }> ->(); -const pendingScanTerminalRequests = new Map>(); -const pendingScanTerminalAiRequests = new Map>(); -const PRIORITY_WARM_SESSION_KEY = "polyWeather_priority_warm_v1"; - -type CityCacheMeta = { - cachedAt: number; - revision: string; -}; - -type CityCacheBundle = { - details: Record; - meta: Record; -}; - -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( - url: string, - options?: { cache?: RequestCache; timeoutMs?: number }, -): Promise { - 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; -} - -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; - }; - const details = parsed.data || {}; - const cachedAt = parsed.timestamp || 0; - const freshDetails: Record = {}; - const meta: Record = {}; - 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( - `/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( - `/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( - `/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( - `/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( - `/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( - `/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; - }) - .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; - }; - - if ("entries" in parsed && parsed.entries) { - const details: Record = {}; - const meta: Record = {}; - 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, - meta: Record, - ) { - 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) { - const now = Date.now(); - const meta = Object.fromEntries( - Object.entries(data).map(([cityName, detail]) => [ - cityName, - { cachedAt: now, revision: getCityRevision(detail) }, - ]), - ); - this.writeCityDetailCacheBundle(data, meta); - }, -};