From c9d03fd3e10559d0a13c00e9312eced3adea95f1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 14 May 2026 15:05:13 +0800 Subject: [PATCH] feat: implement dashboard state management, API proxy layer, and modular UI components for weather monitoring and payments --- frontend/app/api/cities/route.ts | 42 +- frontend/app/api/city/[name]/detail/route.ts | 46 +- .../app/api/city/[name]/market-scan/route.ts | 53 +- frontend/app/api/city/[name]/summary/route.ts | 64 +- frontend/app/api/history/[name]/route.ts | 45 +- frontend/app/api/payments/config/route.ts | 44 +- .../api/payments/intents/[intentId]/route.ts | 41 +- frontend/app/api/payments/runtime/route.ts | 42 +- frontend/app/api/payments/wallets/route.ts | 33 +- frontend/app/api/scan/terminal/route.ts | 48 +- frontend/app/api/system/status/route.ts | 45 +- frontend/components/dashboard/DetailPanel.tsx | 24 +- .../dashboard/FutureForecastModal.tsx | 1158 +---------------- .../dashboard/FutureForecastModalChart.tsx | 5 +- .../dashboard/FutureForecastModalContent.tsx | 1098 ++++++++++++++++ .../dashboard/FutureForecastModalHeader.tsx | 100 ++ frontend/components/dashboard/HeaderBar.tsx | 30 +- .../components/dashboard/HistoryModal.tsx | 66 +- .../components/dashboard/PanelSections.tsx | 25 +- .../dashboard/ProFeaturePaywall.tsx | 4 +- .../dashboard/ScanTerminalDashboard.tsx | 209 +-- .../components/dashboard/WeatherAuraLayer.tsx | 6 +- .../dashboard/monitoring/MonitorPanel.tsx | 22 +- .../monitoring/monitor-temperature.ts | 64 + .../scan-terminal/ScanTerminalShellParts.tsx | 229 ++++ .../__tests__/monitorTemperature.test.ts | 62 + .../__tests__/sourceFreshness.test.ts | 20 + frontend/hooks/useDashboardStore.tsx | 443 ++++--- frontend/lib/api-proxy.ts | 67 + frontend/lib/source-freshness.ts | 11 + 30 files changed, 2213 insertions(+), 1933 deletions(-) create mode 100644 frontend/components/dashboard/FutureForecastModalContent.tsx create mode 100644 frontend/components/dashboard/FutureForecastModalHeader.tsx create mode 100644 frontend/components/dashboard/monitoring/monitor-temperature.ts create mode 100644 frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts diff --git a/frontend/app/api/cities/route.ts b/frontend/app/api/cities/route.ts index 7f1f197b..db84acc6 100644 --- a/frontend/app/api/cities/route.ts +++ b/frontend/app/api/cities/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -20,30 +12,10 @@ export async function GET(req: NextRequest) { return response; } - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const res = await fetch(`${API_BASE}/api/cities`, { - headers: auth.headers, - next: { revalidate: 60 }, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=60, stale-while-revalidate=300", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch cities", - }); - return response; - } + return proxyBackendJsonGet(req, { + cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300", + publicMessage: "Failed to fetch cities", + revalidateSeconds: 60, + url: `${API_BASE}/api/cities`, + }); } diff --git a/frontend/app/api/city/[name]/detail/route.ts b/frontend/app/api/city/[name]/detail/route.ts index e60e47b4..bd7526c1 100644 --- a/frontend/app/api/city/[name]/detail/route.ts +++ b/frontend/app/api/city/[name]/detail/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { buildCityDetailProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -44,32 +36,12 @@ export async function GET( } const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/detail?${searchParams.toString()}`; - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const res = await fetch(url, { - headers: auth.headers, - ...(cachePolicy.fetchMode === "no-store" - ? { cache: "no-store" as const } - : { next: { revalidate: cachePolicy.revalidateSeconds ?? 15 } }), - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - cachePolicy.responseCacheControl, - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch city detail aggregate", - }); - return response; - } + return proxyBackendJsonGet(req, { + cacheControl: cachePolicy.responseCacheControl, + fetchCache: + cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, + publicMessage: "Failed to fetch city detail aggregate", + revalidateSeconds: cachePolicy.revalidateSeconds, + url, + }); } diff --git a/frontend/app/api/city/[name]/market-scan/route.ts b/frontend/app/api/city/[name]/market-scan/route.ts index 83a0c7f3..651c27b5 100644 --- a/frontend/app/api/city/[name]/market-scan/route.ts +++ b/frontend/app/api/city/[name]/market-scan/route.ts @@ -1,13 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; +import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -27,6 +20,7 @@ export async function GET( const params = new URLSearchParams(); const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; params.set("force_refresh", forceRefresh); + const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20); const targetDate = req.nextUrl.searchParams.get("target_date"); if (targetDate) { @@ -45,34 +39,15 @@ export async function GET( const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/market-scan?${params.toString()}`; - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const res = await fetch(url, { - headers: auth.headers, - next: { revalidate: 20 }, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 800, - error: "Backend city market scan failed", - }); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=20, stale-while-revalidate=60", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch city market scan", - status: 502, - }); - return response; - } + return proxyBackendJsonGet(req, { + cacheControl: cachePolicy.responseCacheControl, + detailLimit: 800, + error: "Backend city market scan failed", + fetchCache: + cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, + publicMessage: "Failed to fetch city market scan", + revalidateSeconds: cachePolicy.revalidateSeconds, + statusOnException: 502, + url, + }); } diff --git a/frontend/app/api/city/[name]/summary/route.ts b/frontend/app/api/city/[name]/summary/route.ts index 212a1efb..682b5379 100644 --- a/frontend/app/api/city/[name]/summary/route.ts +++ b/frontend/app/api/city/[name]/summary/route.ts @@ -1,13 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; +import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -25,50 +18,15 @@ export async function GET( const { name } = await context.params; const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false"; - const bypassCache = forceRefresh === "true"; + const cachePolicy = buildForceRefreshProxyCachePolicy(forceRefresh, 20); const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`; - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const fetchOptions = - bypassCache - ? { - headers: auth.headers, - cache: "no-store" as const, - } - : { - headers: auth.headers, - next: { revalidate: 20 }, - }; - const res = await fetch(url, { - ...fetchOptions, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - if (bypassCache) { - const response = NextResponse.json(data, { - headers: { - "Cache-Control": "no-store", - }, - }); - return applyAuthResponseCookies(response, auth.response); - } - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=20, stale-while-revalidate=60", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch city summary", - }); - return response; - } + return proxyBackendJsonGet(req, { + cacheControl: cachePolicy.responseCacheControl, + fetchCache: + cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, + publicMessage: "Failed to fetch city summary", + revalidateSeconds: cachePolicy.revalidateSeconds, + url, + }); } diff --git a/frontend/app/api/history/[name]/route.ts b/frontend/app/api/history/[name]/route.ts index e5d62ca5..9fd48611 100644 --- a/frontend/app/api/history/[name]/route.ts +++ b/frontend/app/api/history/[name]/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -26,33 +18,10 @@ export async function GET( const { name } = await context.params; const url = `${API_BASE}/api/history/${encodeURIComponent(name)}`; - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const fetchOptions = { - headers: auth.headers, - next: { revalidate: 60 }, - } as const; - const res = await fetch(url, { - ...fetchOptions, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=60, stale-while-revalidate=300", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const response = buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch history", - }); - return response; - } + return proxyBackendJsonGet(req, { + cacheControl: "public, max-age=0, s-maxage=60, stale-while-revalidate=300", + publicMessage: "Failed to fetch history", + revalidateSeconds: 60, + url, + }); } diff --git a/frontend/app/api/payments/config/route.ts b/frontend/app/api/payments/config/route.ts index d2c917be..68199eb4 100644 --- a/frontend/app/api/payments/config/route.ts +++ b/frontend/app/api/payments/config/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -18,30 +10,12 @@ export async function GET(req: NextRequest) { { status: 500 }, ); } - try { - const auth = await buildBackendRequestHeaders(req); - const res = await fetch(`${API_BASE}/api/payments/config`, { - headers: auth.headers, - next: { revalidate: 300 }, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 350, - }); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=300, stale-while-revalidate=900", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch payment config", - }); - } + return proxyBackendJsonGet(req, { + cacheControl: "public, max-age=0, s-maxage=300, stale-while-revalidate=900", + detailLimit: 350, + includeSupabaseIdentity: true, + publicMessage: "Failed to fetch payment config", + revalidateSeconds: 300, + url: `${API_BASE}/api/payments/config`, + }); } - diff --git a/frontend/app/api/payments/intents/[intentId]/route.ts b/frontend/app/api/payments/intents/[intentId]/route.ts index bf869afa..31a2cbc0 100644 --- a/frontend/app/api/payments/intents/[intentId]/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/route.ts @@ -1,12 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -21,29 +14,11 @@ export async function GET( ); } const { intentId } = await context.params; - try { - const auth = await buildBackendRequestHeaders(req); - const res = await fetch( - `${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`, - { - method: "GET", - headers: auth.headers, - cache: "no-store", - }, - ); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 350, - }); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = NextResponse.json(data); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch payment intent", - }); - } + return proxyBackendJsonGet(req, { + detailLimit: 350, + fetchCache: "no-store", + includeSupabaseIdentity: true, + publicMessage: "Failed to fetch payment intent", + url: `${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`, + }); } diff --git a/frontend/app/api/payments/runtime/route.ts b/frontend/app/api/payments/runtime/route.ts index 5c97ee05..1189b0bd 100644 --- a/frontend/app/api/payments/runtime/route.ts +++ b/frontend/app/api/payments/runtime/route.ts @@ -1,12 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -18,28 +11,13 @@ export async function GET(req: NextRequest) { ); } - try { - const auth = await buildBackendRequestHeaders(req); - const res = await fetch(`${API_BASE}/api/payments/runtime`, { - headers: auth.headers, - cache: "no-store", - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 500, - }); - return applyAuthResponseCookies(response, auth.response); - } - - const data = await res.json(); - const response = NextResponse.json(data, { - headers: { "Cache-Control": "no-store" }, - }); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch payment runtime", - }); - } + return proxyBackendJsonGet(req, { + cacheControl: "no-store", + conditionalResponse: false, + detailLimit: 500, + fetchCache: "no-store", + includeSupabaseIdentity: true, + publicMessage: "Failed to fetch payment runtime", + url: `${API_BASE}/api/payments/runtime`, + }); } diff --git a/frontend/app/api/payments/wallets/route.ts b/frontend/app/api/payments/wallets/route.ts index 2d0d398e..c3426123 100644 --- a/frontend/app/api/payments/wallets/route.ts +++ b/frontend/app/api/payments/wallets/route.ts @@ -5,6 +5,7 @@ import { } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, + proxyBackendJsonGet, buildUpstreamErrorResponse, } from "@/lib/api-proxy"; @@ -17,29 +18,15 @@ export async function GET(req: NextRequest) { { status: 500 }, ); } - try { - const auth = await buildBackendRequestHeaders(req); - const res = await fetch(`${API_BASE}/api/payments/wallets`, { - headers: auth.headers, - cache: "no-store", - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 350, - }); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = NextResponse.json(data, { - headers: { "Cache-Control": "no-store" }, - }); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch wallets", - }); - } + return proxyBackendJsonGet(req, { + cacheControl: "no-store", + conditionalResponse: false, + detailLimit: 350, + fetchCache: "no-store", + includeSupabaseIdentity: true, + publicMessage: "Failed to fetch wallets", + url: `${API_BASE}/api/payments/wallets`, + }); } export async function DELETE(req: NextRequest) { diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index 4af05ca7..539ca7d4 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; import { buildForceRefreshProxyCachePolicy } from "@/lib/proxy-cache-policy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -48,42 +40,20 @@ export async function GET(req: NextRequest) { const url = `${API_BASE}/api/scan/terminal?${params.toString()}`; - let auth: Awaited> | null = null; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), SCAN_TERMINAL_PROXY_TIMEOUT_MS); try { - auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const res = await fetch(url, { - headers: auth.headers, - ...(cachePolicy.fetchMode === "no-store" - ? { cache: "no-store" as const } - : { next: { revalidate: cachePolicy.revalidateSeconds ?? 10 } }), + return await proxyBackendJsonGet(req, { + cacheControl: cachePolicy.responseCacheControl, + fetchCache: + cachePolicy.fetchMode === "no-store" ? "no-store" : undefined, + publicMessage: "Failed to fetch scan terminal data", + revalidateSeconds: cachePolicy.revalidateSeconds, signal: controller.signal, + timeoutPublicMessage: "Scan terminal request timed out", + url, }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw); - return applyAuthResponseCookies(response, auth.response); - } - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - cachePolicy.responseCacheControl, - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - const timedOut = controller.signal.aborted; - const response = buildProxyExceptionResponse(error, { - publicMessage: timedOut - ? "Scan terminal request timed out" - : "Failed to fetch scan terminal data", - status: timedOut ? 504 : 500, - }); - return auth ? applyAuthResponseCookies(response, auth.response) : response; } finally { clearTimeout(timeoutId); } diff --git a/frontend/app/api/system/status/route.ts b/frontend/app/api/system/status/route.ts index c089d406..b0aa7f19 100644 --- a/frontend/app/api/system/status/route.ts +++ b/frontend/app/api/system/status/route.ts @@ -1,13 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { - applyAuthResponseCookies, - buildBackendRequestHeaders, -} from "@/lib/backend-auth"; -import { - buildProxyExceptionResponse, - buildUpstreamErrorResponse, -} from "@/lib/api-proxy"; -import { buildCachedJsonResponse } from "@/lib/http-cache"; +import { proxyBackendJsonGet } from "@/lib/api-proxy"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; @@ -19,32 +11,11 @@ export async function GET(req: NextRequest) { ); } - try { - const auth = await buildBackendRequestHeaders(req, { - includeSupabaseIdentity: false, - }); - const res = await fetch(`${API_BASE}/api/system/status`, { - headers: auth.headers, - next: { revalidate: 30 }, - }); - if (!res.ok) { - const raw = await res.text(); - const response = buildUpstreamErrorResponse(res.status, raw, { - detailLimit: 500, - }); - return applyAuthResponseCookies(response, auth.response); - } - - const data = await res.json(); - const response = buildCachedJsonResponse( - req, - data, - "public, max-age=0, s-maxage=30, stale-while-revalidate=120", - ); - return applyAuthResponseCookies(response, auth.response); - } catch (error) { - return buildProxyExceptionResponse(error, { - publicMessage: "Failed to fetch system status", - }); - } + return proxyBackendJsonGet(req, { + cacheControl: "public, max-age=0, s-maxage=30, stale-while-revalidate=120", + detailLimit: 500, + publicMessage: "Failed to fetch system status", + revalidateSeconds: 30, + url: `${API_BASE}/api/system/status`, + }); } diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx index 55e36765..17494fd5 100644 --- a/frontend/components/dashboard/DetailPanel.tsx +++ b/frontend/components/dashboard/DetailPanel.tsx @@ -5,7 +5,12 @@ import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { ForecastTable } from "@/components/dashboard/PanelSections"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { + useDashboardHistory, + useDashboardModal, + useDashboardStore, + useProAccess, +} from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources"; import { trackAppEvent } from "@/lib/app-analytics"; @@ -31,6 +36,9 @@ export function DetailPanel({ variant?: "overlay" | "rail"; } = {}) { const store = useDashboardStore(); + const modal = useDashboardModal(); + const history = useDashboardHistory(); + const { proAccess } = useProAccess(); const { locale, t } = useI18n(); const router = useRouter(); const isRail = variant === "rail"; @@ -49,12 +57,12 @@ export function DetailPanel({ : null, [store.citySummariesByName, store.selectedCity], ); - const isPro = store.proAccess.subscriptionActive; - const isAuthenticated = store.proAccess.authenticated; + const isPro = proAccess.subscriptionActive; + const isAuthenticated = proAccess.authenticated; const panelRef = useRef(null); const [heavyContentReady, setHeavyContentReady] = useState(false); const isOverlayOpen = - Boolean(store.futureModalDate) || store.historyState.isOpen; + Boolean(modal.futureModalDate) || history.historyState.isOpen; const isVisible = isRail ? Boolean(store.selectedCity) && !isOverlayOpen : store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen; @@ -131,10 +139,10 @@ export function DetailPanel({ if (isPro) { if (feature === "today") { - void store.openTodayModal(); + void modal.openTodayModal(); return; } - void store.openHistory(); + void history.openHistory(); return; } @@ -144,10 +152,10 @@ export function DetailPanel({ } if (feature === "today") { - void store.openTodayModal(); + void modal.openTodayModal(); return; } - void store.openHistory(); + void history.openHistory(); }; useEffect(() => { diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 9ff5579b..0c3323b4 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -1,63 +1,27 @@ "use client"; -import clsx from "clsx"; -import { CSSProperties, useEffect, useMemo, useState } from "react"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { + useDashboardModal, + useDashboardSelection, + useProAccess, +} from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; -import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; -import { getFutureModalView } from "@/lib/dashboard-utils"; -import { getModelView, getProbabilityView } from "@/lib/model-utils"; -import { getTodayPaceView } from "@/lib/pace-utils"; -import { dashboardClient } from "@/lib/dashboard-client"; -import { getWeatherSummary } from "@/lib/weather-summary-utils"; -import { - normalizeObservationSourceCode, - normalizeObservationSourceLabel, -} from "@/lib/source-labels"; -import type { MarketScan } from "@/lib/dashboard-types"; -import { FutureForecastForwardView } from "./FutureForecastForwardView"; -import { - FutureModelForecastPanel, - FutureProbabilityPanel, - FutureTemperaturePathChart, -} from "./FutureForecastModalPanels"; -import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief"; -import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid"; -import { - FutureRefreshLock, - FutureSyncStatusStrip, - type FutureSyncStatusItem, -} from "./FutureForecastModalStatus"; -import { - FutureAnchorStatusCard, - FuturePaceCard, - FuturePaceLoadingCard, - type FuturePaceSignalItem, -} from "./FutureForecastTodayCards"; -import { - TODAY_MARKET_SCAN_AUTO_REFRESH_MS, - clamp, - formatBucketLabel, - formatMarketPercent, - getTrendMetricVisual, - localizedList, - localizedText, - parseBucketBoundaries, - parseClockMinutes, - parseLeadingNumber, -} from "./FutureForecastModal.utils"; +import { FutureForecastModalContent } from "./FutureForecastModalContent"; export function FutureForecastModal() { - const store = useDashboardStore(); + const modal = useDashboardModal(); + const selection = useDashboardSelection(); + const proAccess = useProAccess(); const { locale, t } = useI18n(); - const detail = store.selectedDetail; - const dateStr = store.futureModalDate; + const detail = selection.selectedDetail; + const dateStr = modal.futureModalDate; if (!detail || !dateStr) return null; return ( ); } - -function FutureForecastModalContent({ - store, - locale, - t, - detail, - dateStr, -}: { - store: ReturnType; - locale: ReturnType["locale"]; - t: ReturnType["t"]; - detail: NonNullable["selectedDetail"]>; - dateStr: string; -}) { - const isPro = store.proAccess.subscriptionActive; - const isProLoading = store.proAccess.loading; - const hasModalContext = true; - const [showDeferredTodaySections, setShowDeferredTodaySections] = - useState(false); - const [freshMarketScan, setFreshMarketScan] = useState( - null, - ); - - useEffect(() => { - if (!hasModalContext) { - setShowDeferredTodaySections(false); - return; - } - setShowDeferredTodaySections(false); - if (typeof window === "undefined") { - setShowDeferredTodaySections(true); - return; - } - - let cancelled = false; - let timeoutId: ReturnType | null = null; - let idleId: number | null = null; - const reveal = () => { - if (!cancelled) { - setShowDeferredTodaySections(true); - } - }; - - if ("requestIdleCallback" in window) { - idleId = window.requestIdleCallback(reveal, { timeout: 600 }); - } else { - timeoutId = setTimeout(reveal, 120); - } - - return () => { - cancelled = true; - if (idleId != null && "cancelIdleCallback" in window) { - window.cancelIdleCallback(idleId); - } - if (timeoutId != null) { - clearTimeout(timeoutId); - } - }; - }, [dateStr, detail, hasModalContext]); - - const isToday = - store.forecastModalMode === "today" || - (store.forecastModalMode == null && dateStr === detail?.local_date); - const detailDepth = detail?.detail_depth || "full"; - const isFullDetailReady = detailDepth === "full"; - const isStructureSyncing = - store.loadingState.futureDeep || !isFullDetailReady; - const isAnyLayerSyncing = isStructureSyncing; - const isTodayBlockingRefresh = isToday && isStructureSyncing; - const activeMarketScan = freshMarketScan || detail?.market_scan || null; - - useEffect(() => { - setFreshMarketScan(null); - if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return; - const cityName = String(detail?.name || detail?.display_name || "").trim(); - if (!cityName || !dateStr) return; - - let cancelled = false; - let intervalId: ReturnType | null = null; - - const refreshMarketScan = () => { - dashboardClient - .getCityMarketScan(cityName, { - force: false, - lite: false, - marketSlug: detail?.market_scan?.primary_market?.slug || null, - targetDate: dateStr, - }) - .then((payload) => { - if (cancelled) return; - setFreshMarketScan(payload.market_scan || null); - }) - .catch(() => { - if (!cancelled) { - setFreshMarketScan(null); - } - }); - }; - - refreshMarketScan(); - intervalId = setInterval(() => { - if ( - typeof document !== "undefined" && - document.visibilityState === "hidden" - ) { - return; - } - refreshMarketScan(); - }, TODAY_MARKET_SCAN_AUTO_REFRESH_MS); - - return () => { - cancelled = true; - if (intervalId != null) { - clearInterval(intervalId); - } - }; - }, [ - dateStr, - detail?.display_name, - detail?.local_date, - detail?.market_scan?.primary_market?.slug, - detail?.name, - detail?.updated_at, - hasModalContext, - isFullDetailReady, - isPro, - isToday, - ]); - - const view = getFutureModalView(detail, dateStr, locale); - const scorePosition = `${50 + view.front.score / 2}%`; - const barStyle = { - "--score-position": scorePosition, - } as CSSProperties & { "--score-position": string }; - const weatherSummary = getWeatherSummary(detail, locale); - const paceView = useMemo( - () => - isToday && showDeferredTodaySections - ? getTodayPaceView(detail, locale) - : null, - [detail, isToday, locale, showDeferredTodaySections], - ); - const probabilityView = useMemo( - () => getProbabilityView(detail, dateStr), - [dateStr, detail], - ); - const modelView = useMemo( - () => getModelView(detail, dateStr), - [dateStr, detail], - ); - const probabilityEngineKey = String(probabilityView?.engine || "") - .trim() - .toLowerCase(); - const probabilityCalibrationMode = String( - probabilityView?.calibrationMode || "", - ) - .trim() - .toLowerCase(); - const hasLgbmProbability = useMemo( - () => - Object.keys(modelView?.models || {}).some((name) => - String(name || "") - .toLowerCase() - .replace(/[\s_/-]/g, "") - .includes("lgbm"), - ), - [modelView], - ); - const hasEmosProbability = - probabilityEngineKey === "emos" || - probabilityCalibrationMode.includes("emos"); - const probabilityEngineLabel = hasLgbmProbability - ? locale === "en-US" - ? "LGBM" - : "LGBM" - : hasEmosProbability - ? "EMOS" - : locale === "en-US" - ? "Calibrated model" - : "校准模型"; - const probabilityTitle = hasLgbmProbability - ? locale === "en-US" - ? "LGBM-Calibrated Probability" - : "LGBM 校准概率" - : hasEmosProbability - ? locale === "en-US" - ? "EMOS-Calibrated Probability" - : "EMOS 校准概率" - : locale === "en-US" - ? "Calibrated Model Probability" - : "校准模型概率"; - const topProbabilityBucket = useMemo(() => { - const buckets = Array.isArray(probabilityView?.probabilities) - ? probabilityView.probabilities - : []; - return [...buckets] - .filter((bucket) => Number.isFinite(Number(bucket?.probability))) - .sort((a, b) => Number(b?.probability) - Number(a?.probability))[0]; - }, [probabilityView]); - const modelSpreadView = useMemo(() => { - const values = Object.values(modelView?.models || {}) - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)); - if (!values.length) return null; - const min = Math.min(...values); - const max = Math.max(...values); - const spread = max - min; - return { - count: values.length, - max, - min, - spread, - }; - }, [modelView]); - const boundaryRiskView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || !paceView) return null; - const selectedBucket = topProbabilityBucket || null; - const bounds = parseBucketBoundaries(selectedBucket); - if (!bounds) return null; - const projected = - paceView.paceAdjustedHigh ?? - (detail.deb?.prediction != null ? Number(detail.deb.prediction) : null); - if (projected == null || !Number.isFinite(projected)) return null; - - const distances = [bounds.lower, bounds.upper] - .filter( - (value): value is number => value != null && Number.isFinite(value), - ) - .map((value) => ({ - boundary: value, - gap: Math.abs(projected - value), - })) - .sort((a, b) => a.gap - b.gap); - if (!distances.length) return null; - - const nearest = distances[0]; - const tone = - nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan"; - const status = - nearest.gap <= 0.4 - ? locale === "en-US" - ? "High boundary risk" - : "边界风险高" - : nearest.gap <= 0.8 - ? locale === "en-US" - ? "Watch boundary" - : "边界需观察" - : locale === "en-US" - ? "Boundary buffer" - : "边界缓冲"; - const note = - locale === "en-US" - ? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.` - : `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`; - return { - label: locale === "en-US" ? "Boundary risk" : "边界风险", - note, - status, - tone, - value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`, - }; - }, [ - detail.deb?.prediction, - detail.temp_symbol, - isToday, - locale, - paceView, - showDeferredTodaySections, - topProbabilityBucket, - ]); - const peakWindowStateView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || !paceView) return null; - const firstHour = Number(detail.peak?.first_h); - const lastHour = Number(detail.peak?.last_h); - if ( - !Number.isFinite(firstHour) || - !Number.isFinite(lastHour) || - firstHour < 0 || - lastHour < firstHour - ) { - return null; - } - const currentMinutes = parseClockMinutes(detail.local_time); - const startMinutes = firstHour * 60; - const endMinutes = (lastHour + 1) * 60; - let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值"; - let tone: "amber" | "blue" | "cyan" = "blue"; - if (currentMinutes != null && currentMinutes >= endMinutes) { - status = locale === "en-US" ? "Past peak" : "已过峰值"; - tone = "cyan"; - } else if (currentMinutes != null && currentMinutes >= startMinutes) { - status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中"; - tone = "amber"; - } - const note = - locale === "en-US" - ? `Primary peak window ${paceView.peakWindowText}.` - : `核心峰值窗口 ${paceView.peakWindowText}。`; - return { - label: locale === "en-US" ? "Peak window" : "峰值窗口状态", - note, - status, - tone, - value: paceView.peakWindowText, - }; - }, [ - detail.local_time, - detail.peak?.first_h, - detail.peak?.last_h, - isToday, - locale, - paceView, - showDeferredTodaySections, - ]); - const networkLeadView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday) return null; - const delta = Number(detail.airport_vs_network_delta); - const leadSignal = detail.network_lead_signal; - if (!Number.isFinite(delta)) return null; - const leaderLabel = - String(leadSignal?.leader_station_label || "").trim() || - String(leadSignal?.leader_station_code || "").trim(); - const leaderSyncStatus = String(leadSignal?.leader_sync_status || "") - .trim() - .toLowerCase(); - const leaderSyncDelta = Number( - leadSignal?.leader_time_delta_vs_anchor_minutes, - ); - const syncNote = - leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged" - ? Number.isFinite(leaderSyncDelta) - ? locale === "en-US" - ? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.` - : ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。` - : locale === "en-US" - ? " Nearby observations are not fully synchronized." - : " 周边观测并非完全同步。" - : leaderSyncStatus === "unknown" - ? locale === "en-US" - ? " Nearby station timing is not fully verified." - : " 周边站观测时间尚未完全校验。" - : ""; - const absDelta = Math.abs(delta); - const status = - delta <= -0.4 - ? locale === "en-US" - ? "Airport trailing" - : "机场落后" - : delta >= 0.4 - ? locale === "en-US" - ? "Airport leading" - : "机场领先" - : locale === "en-US" - ? "Tracking network" - : "与站网齐平"; - const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue"; - const note = - delta <= -0.4 - ? locale === "en-US" - ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}` - : `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}` - : delta >= 0.4 - ? locale === "en-US" - ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}` - : `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}` - : locale === "en-US" - ? "Airport anchor and nearby official network are broadly aligned." - : "机场主站与周边官方站网当前大体齐平。"; - return { - label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站", - note, - status, - tone, - value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`, - }; - }, [ - detail.airport_vs_network_delta, - detail.network_lead_signal, - detail.temp_symbol, - isToday, - locale, - showDeferredTodaySections, - ]); - const paceSignalItems = useMemo( - () => - [boundaryRiskView, peakWindowStateView, networkLeadView] - .filter((item) => item != null) - .map((item) => ({ - label: item.label, - note: item.note, - status: item.status, - tone: item.tone, - value: item.value, - })) as FuturePaceSignalItem[], - [boundaryRiskView, networkLeadView, peakWindowStateView], - ); - const isNoaaSettlement = - detail.current?.settlement_source === "noaa" || - detail.current?.settlement_source_label === "NOAA"; - const noaaStationCode = String( - detail.current?.station_code || detail.risk?.icao || "NOAA", - ) - .trim() - .toUpperCase(); - const noaaStationName = - String(detail.current?.station_name || "").trim() || - String(detail.risk?.airport || "").trim() || - noaaStationCode; - const hottestBucketLabel = formatBucketLabel(topProbabilityBucket); - const probabilitySummary = (() => { - if (!topProbabilityBucket) { - return locale === "en-US" - ? "Probability mass is still too dispersed; avoid over-reading a single bracket." - : "当前概率还比较分散,不要只盯单一区间。"; - } - const bucketLabel = formatBucketLabel(topProbabilityBucket); - const bucketProb = formatMarketPercent(topProbabilityBucket.probability); - if (!isToday) { - return locale === "en-US" - ? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.` - : `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`; - } - if (hasLgbmProbability) { - return locale === "en-US" - ? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` - : `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; - } - if (hasEmosProbability) { - return locale === "en-US" - ? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.` - : `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`; - } - return locale === "en-US" - ? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` - : `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; - })(); - const modelSummary = (() => { - if (!modelSpreadView) { - return locale === "en-US" - ? "Model spread is unavailable right now." - : "当前拿不到可用的模型分歧。"; - } - const modelEntries = Object.entries(modelView?.models || {}).filter( - ([, value]) => - value !== null && value !== undefined && Number.isFinite(Number(value)), - ); - if (modelEntries.length === 1) { - const [singleModelName, singleModelValue] = modelEntries[0]; - return locale === "en-US" - ? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.` - : `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`; - } - return locale === "en-US" - ? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.` - : `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`; - })(); - const upperAirSignal = detail.vertical_profile_signal || {}; - const tafSignal = detail.taf?.signal || {}; - const upperAirCue = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || (!upperAirSignal.source && !tafSignal.available)) - return null; - - const setup = String( - upperAirSignal.heating_setup || "neutral", - ).toLowerCase(); - const tafSuppression = String( - tafSignal.suppression_level || "low", - ).toLowerCase(); - const tafDisruption = String( - tafSignal.disruption_level || "low", - ).toLowerCase(); - const reasons: string[] = []; - let score = 0; - - if (setup === "supportive") { - score += 2; - reasons.push( - locale === "en-US" - ? "upper-air structure still supports daytime heating" - : "高空结构仍偏支持白天冲高", - ); - } else if (setup === "suppressed") { - score -= 2; - reasons.push( - locale === "en-US" - ? "upper-air structure still leans toward capping the peak" - : "高空结构更偏向压住峰值", - ); - } - - if (tafSuppression === "high") { - score -= 2; - reasons.push( - locale === "en-US" - ? "TAF flags meaningful cloud/rain suppression near the peak window" - : "TAF 在峰值窗口提示云雨压温风险偏高", - ); - } else if (tafSuppression === "medium") { - score -= 1; - reasons.push( - locale === "en-US" - ? "TAF keeps some cloud/rain suppression risk on the table" - : "TAF 仍提示一定的云雨压温风险", - ); - } - - if (tafDisruption === "high") { - score -= 1; - reasons.push( - locale === "en-US" - ? "TAF also suggests a noisier afternoon regime" - : "TAF 还提示午后扰动偏强", - ); - } else if (tafDisruption === "medium") { - score -= 0.5; - reasons.push( - locale === "en-US" - ? "TAF keeps some afternoon timing noise in play" - : "TAF 提示午后仍可能有时段性扰动", - ); - } - - if (score >= 1.5) { - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early." - : "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ")}.` - : `${reasons.slice(0, 2).join(";")}。`, - tone: "warm", - value: locale === "en-US" ? "Lean warmer" : "偏暖侧", - }; - } - - if (score <= -1.5) { - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets." - : "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ")}.` - : `${reasons.slice(0, 2).join(";")}。`, - tone: "cold", - value: locale === "en-US" ? "Lean cautious" : "偏谨慎", - }; - } - - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side." - : "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.` - : `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`, - tone: "", - value: locale === "en-US" ? "Wait / confirm" : "先观察", - }; - }, [ - tafSignal.available, - tafSignal.disruption_level, - tafSignal.suppression_level, - isToday, - locale, - upperAirSignal.heating_setup, - upperAirSignal.source, - showDeferredTodaySections, - ]); - const topObservedTemp = - detail.current?.max_so_far != null - ? detail.current.max_so_far - : detail.current?.temp; - const currentTempText = - detail.current?.temp != null - ? `${detail.current.temp}${detail.temp_symbol}` - : "--"; - const daylightProgress = (() => { - const now = parseClockMinutes(detail.current?.obs_time); - const sunrise = parseClockMinutes(detail.forecast?.sunrise); - const sunset = parseClockMinutes(detail.forecast?.sunset); - if (now == null || sunrise == null || sunset == null || sunset <= sunrise) { - return null; - } - const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100); - const phase = - now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中"; - return { - phase, - percent, - }; - })(); - const displayedUpperAirSummary = showDeferredTodaySections - ? upperAirCue?.summary || view.front.upperAirSummary - : ""; - const displayedUpperAirMetrics = showDeferredTodaySections - ? (view.front.upperAirMetrics || []).map((metric, index) => - index === 0 && - (metric.label === "Trade cue" || metric.label === "交易动作") && - upperAirCue - ? { - ...metric, - note: upperAirCue.note, - tone: upperAirCue.tone, - value: upperAirCue.value, - } - : metric, - ) - : []; - const localizedAiCommentaryLines = useMemo(() => { - if (!showDeferredTodaySections) return [] as string[]; - const commentary = detail.dynamic_commentary || {}; - const headline = String( - locale === "en-US" - ? commentary.headline_en || "" - : commentary.headline_zh || "", - ).trim(); - const bullets = ( - locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh - ) as string[] | null | undefined; - const cleanedBullets = Array.isArray(bullets) - ? bullets.map((item) => String(item || "").trim()).filter(Boolean) - : []; - return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3); - }, [detail.dynamic_commentary, locale, showDeferredTodaySections]); - const todayTradeSummaryLines = useMemo(() => { - if (!showDeferredTodaySections) return [] as string[]; - if (!isToday) return [] as string[]; - if (localizedAiCommentaryLines.length > 0) { - return localizedAiCommentaryLines; - } - const lines: string[] = []; - if (paceView) { - const headline = - paceView.biasTone === "warm" - ? locale === "en-US" - ? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.` - : `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。` - : paceView.biasTone === "cold" - ? locale === "en-US" - ? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.` - : `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` - : locale === "en-US" - ? "Pace is still on curve; the next move depends on the peak-window push." - : "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。"; - lines.push(headline); - } - if (boundaryRiskView) { - lines.push( - locale === "en-US" - ? `${boundaryRiskView.label}: ${boundaryRiskView.note}` - : `${boundaryRiskView.label}:${boundaryRiskView.note}`, - ); - } - if (networkLeadView) { - lines.push( - locale === "en-US" - ? `${networkLeadView.label}: ${networkLeadView.note}` - : `${networkLeadView.label}:${networkLeadView.note}`, - ); - } - return lines.slice(0, 3); - }, [ - boundaryRiskView, - isToday, - locale, - localizedAiCommentaryLines, - networkLeadView, - paceView, - showDeferredTodaySections, - ]); - const intradayMeteorology = detail.intraday_meteorology || {}; - const meteorologySignals = Array.isArray( - intradayMeteorology.signal_contributions, - ) - ? intradayMeteorology.signal_contributions - : []; - const invalidationRules = localizedList( - locale, - intradayMeteorology.invalidation_rules, - intradayMeteorology.invalidation_rules_en, - ); - const confirmationRules = localizedList( - locale, - intradayMeteorology.confirmation_rules, - intradayMeteorology.confirmation_rules_en, - ); - const meteorologyHeadline = - localizedText( - locale, - intradayMeteorology.headline, - intradayMeteorology.headline_en, - ) || - todayTradeSummaryLines[0] || - (locale === "en-US" - ? "Intraday meteorology layers are still syncing; use the next observation as the anchor." - : "关键日内气象层仍在同步,先以下一次观测作为判断锚点。"); - const baseCaseBucket = - String(intradayMeteorology.base_case_bucket || "").trim() || - formatBucketLabel(topProbabilityBucket); - const nextObservationTime = - String(intradayMeteorology.next_observation_time || "").trim() || "--"; - const baseBucketNumber = parseLeadingNumber(baseCaseBucket); - const referenceObservedTemp = - topObservedTemp != null && Number.isFinite(Number(topObservedTemp)) - ? Number(topObservedTemp) - : detail.current?.temp != null - ? Number(detail.current.temp) - : null; - const gapToBaseBucket = - baseBucketNumber != null && referenceObservedTemp != null - ? Math.max(0, baseBucketNumber - referenceObservedTemp) - : null; - const pathStatus = - gapToBaseBucket == null - ? locale === "en-US" - ? "Awaiting anchor" - : "等待锚点" - : gapToBaseBucket <= 0.05 - ? locale === "en-US" - ? "Base path touched" - : "基准路径已触达" - : gapToBaseBucket <= 1.0 - ? locale === "en-US" - ? "Base path open" - : "基准路径开放" - : locale === "en-US" - ? "Needs peak push" - : "需要峰值推动"; - const peakWindowText = - String(intradayMeteorology.peak_window || "").trim() || - paceView?.peakWindowText || - "--"; - const settlementSourceCode = normalizeObservationSourceCode( - detail.current?.settlement_source || "", - ); - const settlementStationCode = String( - detail.current?.station_code || detail.risk?.icao || "", - ) - .trim() - .toUpperCase(); - const settlementStationName = - String(detail.current?.station_name || detail.risk?.airport || "").trim() || - settlementStationCode || - (locale === "en-US" ? "Anchor station" : "锚点站"); - const airportMetarAnchor = - settlementSourceCode === "metar" || - Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode)); - const anchorSourceLabel = airportMetarAnchor - ? settlementStationCode - ? `${settlementStationCode} METAR` - : "METAR" - : normalizeObservationSourceLabel( - detail.current?.settlement_source_label || - detail.current?.settlement_source, - locale === "en-US" ? "Official observation" : "官方观测", - ); - const anchorRuleText = airportMetarAnchor - ? locale === "en-US" - ? `Airport contract anchor: use the ${anchorSourceLabel} reports. Third-party history pages are display-only when present.` - : `机场合约锚点:以 ${anchorSourceLabel} 报文为准;第三方历史页只作为展示入口。` - : locale === "en-US" - ? `Official anchor: use ${anchorSourceLabel} observations for this contract.` - : `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`; - const nextObservationLabel = airportMetarAnchor - ? locale === "en-US" - ? "Next METAR watch" - : "下一次 METAR 观察" - : locale === "en-US" - ? "Next anchor watch" - : "下一次锚点观察"; - const gapToBaseText = - gapToBaseBucket == null - ? "--" - : `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`; - const syncStatusItems = [ - { - key: "base", - state: isAnyLayerSyncing ? "syncing" : "ready", - label: isAnyLayerSyncing - ? locale === "en-US" - ? "Refreshing base analysis" - : "正在刷新基础分析" - : locale === "en-US" - ? "Base analysis ready" - : "基础分析已加载", - note: isAnyLayerSyncing - ? locale === "en-US" - ? "Latest anchor readings and forecast curve are being rebuilt." - : "正在重建最新锚点读数和预测曲线。" - : locale === "en-US" - ? "Forecast curve, anchor state, and the core intraday view are available." - : "预测曲线、锚点状态和核心日内视图已经可用。", - }, - { - key: "market", - state: isAnyLayerSyncing ? "syncing" : "ready", - label: isAnyLayerSyncing - ? locale === "en-US" - ? "Refreshing probability layer" - : "正在刷新概率层" - : locale === "en-US" - ? "Probability layer ready" - : "概率层已加载", - note: isAnyLayerSyncing - ? locale === "en-US" - ? "Model spread and calibrated buckets are updating." - : "模型分歧和校准概率桶正在更新。" - : locale === "en-US" - ? `Probability buckets are derived from the ${probabilityEngineLabel} layer.` - : `概率桶当前由 ${probabilityEngineLabel} 层推导。`, - }, - ] satisfies FutureSyncStatusItem[]; - - return ( -
{ - if (event.target === event.currentTarget) { - store.closeFutureModal(); - } - }} - > - {isProLoading ? ( -
-
- {t("dashboard.loading")} -
-
- ) : !isPro ? ( - - ) : ( -
-
-
-
- - {locale === "en-US" ? "Analysis workspace" : "分析工作台"} - - - {detail.display_name.toUpperCase()} -
-

- - {isToday - ? t("future.todayTitle", { - city: detail.display_name.toUpperCase(), - }) - : t("future.dateTitle", { - city: detail.display_name.toUpperCase(), - date: dateStr, - })} - - -

-
- {isToday - ? locale === "en-US" - ? "Base signal first, then probability and model layers." - : "先看基础信号,再看概率层和模型层。" - : locale === "en-US" - ? "Forward date view with phased model and structure sync." - : "未来日期视图,模型层与结构层分阶段补齐。"} -
-
- -
-
- {isTodayBlockingRefresh && } - {isToday && ( - - )} - - {isNoaaSettlement && ( -
- {locale === "en-US" - ? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.` - : `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`} -
- )} - {isToday ? ( -
- - -
-
-
-
- {locale === "en-US" ? "Primary view" : "主视图"} -
-

- {locale === "en-US" - ? "Today's temperature path (anchor obs + models)" - : "今日气温路径(锚点观测 + 模型)"} -

-
- -
- - {locale === "en-US" ? "Base" : "基准"} ·{" "} - {baseCaseBucket || "--"} - - - {locale === "en-US" ? "Upside" : "上修"} ·{" "} - {intradayMeteorology.upside_bucket || "--"} - - - {locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "} - {nextObservationTime} - -
-
- - - -
-
-
-
- {locale === "en-US" ? "Probability read" : "概率判断"} -
-

{probabilityTitle}

-
-
- {probabilitySummary} -
-
- -
-
-
-
-
- {locale === "en-US" ? "Model layer" : "模型层"} -
-

- {locale === "en-US" - ? "Model Range & Spread" - : "模型区间与分歧"} -

-
-
- {modelSummary} -
- -
-
-
-
- ) : ( - - )} -
-
- )} -
- ); -} diff --git a/frontend/components/dashboard/FutureForecastModalChart.tsx b/frontend/components/dashboard/FutureForecastModalChart.tsx index 6b5296f6..916da92b 100644 --- a/frontend/components/dashboard/FutureForecastModalChart.tsx +++ b/frontend/components/dashboard/FutureForecastModalChart.tsx @@ -3,7 +3,7 @@ import type { ChartConfiguration } from "chart.js"; import { useMemo } from "react"; import { useChart } from "@/hooks/useChart"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useDashboardSelection } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { getTemperatureChartData } from "@/lib/chart-utils"; import { getFutureModalView } from "@/lib/dashboard-utils"; @@ -16,9 +16,8 @@ export function DailyTemperatureChart({ dateStr: string; forceToday?: boolean; }) { - const store = useDashboardStore(); + const { selectedDetail: detail } = useDashboardSelection(); const { locale, t } = useI18n(); - const detail = store.selectedDetail; const view = detail ? getFutureModalView(detail, dateStr, locale) : null; const isToday = forceToday || (detail ? dateStr === detail.local_date : false); diff --git a/frontend/components/dashboard/FutureForecastModalContent.tsx b/frontend/components/dashboard/FutureForecastModalContent.tsx new file mode 100644 index 00000000..b42313d9 --- /dev/null +++ b/frontend/components/dashboard/FutureForecastModalContent.tsx @@ -0,0 +1,1098 @@ +"use client"; + +import clsx from "clsx"; +import { CSSProperties, useEffect, useMemo, useState } from "react"; +import type { Locale } from "@/lib/i18n"; +import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; +import { getFutureModalView } from "@/lib/dashboard-utils"; +import { getModelView, getProbabilityView } from "@/lib/model-utils"; +import { getTodayPaceView } from "@/lib/pace-utils"; +import { dashboardClient } from "@/lib/dashboard-client"; +import { getWeatherSummary } from "@/lib/weather-summary-utils"; +import { + normalizeObservationSourceCode, + normalizeObservationSourceLabel, +} from "@/lib/source-labels"; +import type { + CityDetail, + ForecastModalMode, + LoadingState, + MarketScan, + ProAccessState, +} from "@/lib/dashboard-types"; +import { FutureForecastForwardView } from "./FutureForecastForwardView"; +import { + FutureModelForecastPanel, + FutureProbabilityPanel, + FutureTemperaturePathChart, +} from "./FutureForecastModalPanels"; +import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief"; +import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid"; +import { FutureForecastModalHeader } from "./FutureForecastModalHeader"; +import { + FutureRefreshLock, + FutureSyncStatusStrip, + type FutureSyncStatusItem, +} from "./FutureForecastModalStatus"; +import { + FutureAnchorStatusCard, + FuturePaceCard, + FuturePaceLoadingCard, + type FuturePaceSignalItem, +} from "./FutureForecastTodayCards"; +import { + TODAY_MARKET_SCAN_AUTO_REFRESH_MS, + clamp, + formatBucketLabel, + formatMarketPercent, + getTrendMetricVisual, + localizedList, + localizedText, + parseBucketBoundaries, + parseClockMinutes, + parseLeadingNumber, +} from "./FutureForecastModal.utils"; + +type FutureForecastModalControls = { + closeFutureModal: () => void; + forecastModalMode: ForecastModalMode | null; + futureModalDate: string | null; + loadingState: LoadingState; + openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; + openTodayModal: (forceRefresh?: boolean) => Promise; +}; + +export function FutureForecastModalContent({ + modal, + proAccess, + locale, + t, + detail, + dateStr, +}: { + modal: FutureForecastModalControls; + proAccess: ProAccessState; + locale: Locale; + t: (key: string, params?: Record) => string; + detail: CityDetail; + dateStr: string; +}) { + const isPro = proAccess.subscriptionActive; + const isProLoading = proAccess.loading; + const hasModalContext = true; + const [showDeferredTodaySections, setShowDeferredTodaySections] = + useState(false); + const [freshMarketScan, setFreshMarketScan] = useState( + null, + ); + + useEffect(() => { + if (!hasModalContext) { + setShowDeferredTodaySections(false); + return; + } + setShowDeferredTodaySections(false); + if (typeof window === "undefined") { + setShowDeferredTodaySections(true); + return; + } + + let cancelled = false; + let timeoutId: ReturnType | null = null; + let idleId: number | null = null; + const reveal = () => { + if (!cancelled) { + setShowDeferredTodaySections(true); + } + }; + + if ("requestIdleCallback" in window) { + idleId = window.requestIdleCallback(reveal, { timeout: 600 }); + } else { + timeoutId = setTimeout(reveal, 120); + } + + return () => { + cancelled = true; + if (idleId != null && "cancelIdleCallback" in window) { + window.cancelIdleCallback(idleId); + } + if (timeoutId != null) { + clearTimeout(timeoutId); + } + }; + }, [dateStr, detail, hasModalContext]); + + const isToday = + modal.forecastModalMode === "today" || + (modal.forecastModalMode == null && dateStr === detail?.local_date); + const detailDepth = detail?.detail_depth || "full"; + const isFullDetailReady = detailDepth === "full"; + const isStructureSyncing = + modal.loadingState.futureDeep || !isFullDetailReady; + const isAnyLayerSyncing = isStructureSyncing; + const isTodayBlockingRefresh = isToday && isStructureSyncing; + const activeMarketScan = freshMarketScan || detail?.market_scan || null; + + useEffect(() => { + setFreshMarketScan(null); + if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return; + const cityName = String(detail?.name || detail?.display_name || "").trim(); + if (!cityName || !dateStr) return; + + let cancelled = false; + let intervalId: ReturnType | null = null; + + const refreshMarketScan = () => { + dashboardClient + .getCityMarketScan(cityName, { + force: false, + lite: false, + marketSlug: detail?.market_scan?.primary_market?.slug || null, + targetDate: dateStr, + }) + .then((payload) => { + if (cancelled) return; + setFreshMarketScan(payload.market_scan || null); + }) + .catch(() => { + if (!cancelled) { + setFreshMarketScan(null); + } + }); + }; + + refreshMarketScan(); + intervalId = setInterval(() => { + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + return; + } + refreshMarketScan(); + }, TODAY_MARKET_SCAN_AUTO_REFRESH_MS); + + return () => { + cancelled = true; + if (intervalId != null) { + clearInterval(intervalId); + } + }; + }, [ + dateStr, + detail?.display_name, + detail?.local_date, + detail?.market_scan?.primary_market?.slug, + detail?.name, + detail?.updated_at, + hasModalContext, + isFullDetailReady, + isPro, + isToday, + ]); + + const view = getFutureModalView(detail, dateStr, locale); + const scorePosition = `${50 + view.front.score / 2}%`; + const barStyle = { + "--score-position": scorePosition, + } as CSSProperties & { "--score-position": string }; + const weatherSummary = getWeatherSummary(detail, locale); + const paceView = useMemo( + () => + isToday && showDeferredTodaySections + ? getTodayPaceView(detail, locale) + : null, + [detail, isToday, locale, showDeferredTodaySections], + ); + const probabilityView = useMemo( + () => getProbabilityView(detail, dateStr), + [dateStr, detail], + ); + const modelView = useMemo( + () => getModelView(detail, dateStr), + [dateStr, detail], + ); + const probabilityEngineKey = String(probabilityView?.engine || "") + .trim() + .toLowerCase(); + const probabilityCalibrationMode = String( + probabilityView?.calibrationMode || "", + ) + .trim() + .toLowerCase(); + const hasLgbmProbability = useMemo( + () => + Object.keys(modelView?.models || {}).some((name) => + String(name || "") + .toLowerCase() + .replace(/[\s_/-]/g, "") + .includes("lgbm"), + ), + [modelView], + ); + const hasEmosProbability = + probabilityEngineKey === "emos" || + probabilityCalibrationMode.includes("emos"); + const probabilityEngineLabel = hasLgbmProbability + ? locale === "en-US" + ? "LGBM" + : "LGBM" + : hasEmosProbability + ? "EMOS" + : locale === "en-US" + ? "Calibrated model" + : "校准模型"; + const probabilityTitle = hasLgbmProbability + ? locale === "en-US" + ? "LGBM-Calibrated Probability" + : "LGBM 校准概率" + : hasEmosProbability + ? locale === "en-US" + ? "EMOS-Calibrated Probability" + : "EMOS 校准概率" + : locale === "en-US" + ? "Calibrated Model Probability" + : "校准模型概率"; + const topProbabilityBucket = useMemo(() => { + const buckets = Array.isArray(probabilityView?.probabilities) + ? probabilityView.probabilities + : []; + return [...buckets] + .filter((bucket) => Number.isFinite(Number(bucket?.probability))) + .sort((a, b) => Number(b?.probability) - Number(a?.probability))[0]; + }, [probabilityView]); + const modelSpreadView = useMemo(() => { + const values = Object.values(modelView?.models || {}) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + if (!values.length) return null; + const min = Math.min(...values); + const max = Math.max(...values); + const spread = max - min; + return { + count: values.length, + max, + min, + spread, + }; + }, [modelView]); + const boundaryRiskView = useMemo(() => { + if (!showDeferredTodaySections) return null; + if (!isToday || !paceView) return null; + const selectedBucket = topProbabilityBucket || null; + const bounds = parseBucketBoundaries(selectedBucket); + if (!bounds) return null; + const projected = + paceView.paceAdjustedHigh ?? + (detail.deb?.prediction != null ? Number(detail.deb.prediction) : null); + if (projected == null || !Number.isFinite(projected)) return null; + + const distances = [bounds.lower, bounds.upper] + .filter( + (value): value is number => value != null && Number.isFinite(value), + ) + .map((value) => ({ + boundary: value, + gap: Math.abs(projected - value), + })) + .sort((a, b) => a.gap - b.gap); + if (!distances.length) return null; + + const nearest = distances[0]; + const tone = + nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan"; + const status = + nearest.gap <= 0.4 + ? locale === "en-US" + ? "High boundary risk" + : "边界风险高" + : nearest.gap <= 0.8 + ? locale === "en-US" + ? "Watch boundary" + : "边界需观察" + : locale === "en-US" + ? "Boundary buffer" + : "边界缓冲"; + const note = + locale === "en-US" + ? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.` + : `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`; + return { + label: locale === "en-US" ? "Boundary risk" : "边界风险", + note, + status, + tone, + value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`, + }; + }, [ + detail.deb?.prediction, + detail.temp_symbol, + isToday, + locale, + paceView, + showDeferredTodaySections, + topProbabilityBucket, + ]); + const peakWindowStateView = useMemo(() => { + if (!showDeferredTodaySections) return null; + if (!isToday || !paceView) return null; + const firstHour = Number(detail.peak?.first_h); + const lastHour = Number(detail.peak?.last_h); + if ( + !Number.isFinite(firstHour) || + !Number.isFinite(lastHour) || + firstHour < 0 || + lastHour < firstHour + ) { + return null; + } + const currentMinutes = parseClockMinutes(detail.local_time); + const startMinutes = firstHour * 60; + const endMinutes = (lastHour + 1) * 60; + let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值"; + let tone: "amber" | "blue" | "cyan" = "blue"; + if (currentMinutes != null && currentMinutes >= endMinutes) { + status = locale === "en-US" ? "Past peak" : "已过峰值"; + tone = "cyan"; + } else if (currentMinutes != null && currentMinutes >= startMinutes) { + status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中"; + tone = "amber"; + } + const note = + locale === "en-US" + ? `Primary peak window ${paceView.peakWindowText}.` + : `核心峰值窗口 ${paceView.peakWindowText}。`; + return { + label: locale === "en-US" ? "Peak window" : "峰值窗口状态", + note, + status, + tone, + value: paceView.peakWindowText, + }; + }, [ + detail.local_time, + detail.peak?.first_h, + detail.peak?.last_h, + isToday, + locale, + paceView, + showDeferredTodaySections, + ]); + const networkLeadView = useMemo(() => { + if (!showDeferredTodaySections) return null; + if (!isToday) return null; + const delta = Number(detail.airport_vs_network_delta); + const leadSignal = detail.network_lead_signal; + if (!Number.isFinite(delta)) return null; + const leaderLabel = + String(leadSignal?.leader_station_label || "").trim() || + String(leadSignal?.leader_station_code || "").trim(); + const leaderSyncStatus = String(leadSignal?.leader_sync_status || "") + .trim() + .toLowerCase(); + const leaderSyncDelta = Number( + leadSignal?.leader_time_delta_vs_anchor_minutes, + ); + const syncNote = + leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged" + ? Number.isFinite(leaderSyncDelta) + ? locale === "en-US" + ? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.` + : ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。` + : locale === "en-US" + ? " Nearby observations are not fully synchronized." + : " 周边观测并非完全同步。" + : leaderSyncStatus === "unknown" + ? locale === "en-US" + ? " Nearby station timing is not fully verified." + : " 周边站观测时间尚未完全校验。" + : ""; + const absDelta = Math.abs(delta); + const status = + delta <= -0.4 + ? locale === "en-US" + ? "Airport trailing" + : "机场落后" + : delta >= 0.4 + ? locale === "en-US" + ? "Airport leading" + : "机场领先" + : locale === "en-US" + ? "Tracking network" + : "与站网齐平"; + const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue"; + const note = + delta <= -0.4 + ? locale === "en-US" + ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}` + : `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}` + : delta >= 0.4 + ? locale === "en-US" + ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}` + : `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}` + : locale === "en-US" + ? "Airport anchor and nearby official network are broadly aligned." + : "机场主站与周边官方站网当前大体齐平。"; + return { + label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站", + note, + status, + tone, + value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`, + }; + }, [ + detail.airport_vs_network_delta, + detail.network_lead_signal, + detail.temp_symbol, + isToday, + locale, + showDeferredTodaySections, + ]); + const paceSignalItems = useMemo( + () => + [boundaryRiskView, peakWindowStateView, networkLeadView] + .filter((item) => item != null) + .map((item) => ({ + label: item.label, + note: item.note, + status: item.status, + tone: item.tone, + value: item.value, + })) as FuturePaceSignalItem[], + [boundaryRiskView, networkLeadView, peakWindowStateView], + ); + const isNoaaSettlement = + detail.current?.settlement_source === "noaa" || + detail.current?.settlement_source_label === "NOAA"; + const noaaStationCode = String( + detail.current?.station_code || detail.risk?.icao || "NOAA", + ) + .trim() + .toUpperCase(); + const noaaStationName = + String(detail.current?.station_name || "").trim() || + String(detail.risk?.airport || "").trim() || + noaaStationCode; + const hottestBucketLabel = formatBucketLabel(topProbabilityBucket); + const probabilitySummary = (() => { + if (!topProbabilityBucket) { + return locale === "en-US" + ? "Probability mass is still too dispersed; avoid over-reading a single bracket." + : "当前概率还比较分散,不要只盯单一区间。"; + } + const bucketLabel = formatBucketLabel(topProbabilityBucket); + const bucketProb = formatMarketPercent(topProbabilityBucket.probability); + if (!isToday) { + return locale === "en-US" + ? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.` + : `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`; + } + if (hasLgbmProbability) { + return locale === "en-US" + ? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` + : `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; + } + if (hasEmosProbability) { + return locale === "en-US" + ? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.` + : `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`; + } + return locale === "en-US" + ? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` + : `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; + })(); + const modelSummary = (() => { + if (!modelSpreadView) { + return locale === "en-US" + ? "Model spread is unavailable right now." + : "当前拿不到可用的模型分歧。"; + } + const modelEntries = Object.entries(modelView?.models || {}).filter( + ([, value]) => + value !== null && value !== undefined && Number.isFinite(Number(value)), + ); + if (modelEntries.length === 1) { + const [singleModelName, singleModelValue] = modelEntries[0]; + return locale === "en-US" + ? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.` + : `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`; + } + return locale === "en-US" + ? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.` + : `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`; + })(); + const upperAirSignal = detail.vertical_profile_signal || {}; + const tafSignal = detail.taf?.signal || {}; + const upperAirCue = useMemo(() => { + if (!showDeferredTodaySections) return null; + if (!isToday || (!upperAirSignal.source && !tafSignal.available)) + return null; + + const setup = String( + upperAirSignal.heating_setup || "neutral", + ).toLowerCase(); + const tafSuppression = String( + tafSignal.suppression_level || "low", + ).toLowerCase(); + const tafDisruption = String( + tafSignal.disruption_level || "low", + ).toLowerCase(); + const reasons: string[] = []; + let score = 0; + + if (setup === "supportive") { + score += 2; + reasons.push( + locale === "en-US" + ? "upper-air structure still supports daytime heating" + : "高空结构仍偏支持白天冲高", + ); + } else if (setup === "suppressed") { + score -= 2; + reasons.push( + locale === "en-US" + ? "upper-air structure still leans toward capping the peak" + : "高空结构更偏向压住峰值", + ); + } + + if (tafSuppression === "high") { + score -= 2; + reasons.push( + locale === "en-US" + ? "TAF flags meaningful cloud/rain suppression near the peak window" + : "TAF 在峰值窗口提示云雨压温风险偏高", + ); + } else if (tafSuppression === "medium") { + score -= 1; + reasons.push( + locale === "en-US" + ? "TAF keeps some cloud/rain suppression risk on the table" + : "TAF 仍提示一定的云雨压温风险", + ); + } + + if (tafDisruption === "high") { + score -= 1; + reasons.push( + locale === "en-US" + ? "TAF also suggests a noisier afternoon regime" + : "TAF 还提示午后扰动偏强", + ); + } else if (tafDisruption === "medium") { + score -= 0.5; + reasons.push( + locale === "en-US" + ? "TAF keeps some afternoon timing noise in play" + : "TAF 提示午后仍可能有时段性扰动", + ); + } + + if (score >= 1.5) { + return { + summary: + locale === "en-US" + ? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early." + : "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ")}.` + : `${reasons.slice(0, 2).join(";")}。`, + tone: "warm", + value: locale === "en-US" ? "Lean warmer" : "偏暖侧", + }; + } + + if (score <= -1.5) { + return { + summary: + locale === "en-US" + ? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets." + : "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ")}.` + : `${reasons.slice(0, 2).join(";")}。`, + tone: "cold", + value: locale === "en-US" ? "Lean cautious" : "偏谨慎", + }; + } + + return { + summary: + locale === "en-US" + ? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side." + : "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。", + note: + locale === "en-US" + ? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.` + : `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`, + tone: "", + value: locale === "en-US" ? "Wait / confirm" : "先观察", + }; + }, [ + tafSignal.available, + tafSignal.disruption_level, + tafSignal.suppression_level, + isToday, + locale, + upperAirSignal.heating_setup, + upperAirSignal.source, + showDeferredTodaySections, + ]); + const topObservedTemp = + detail.current?.max_so_far != null + ? detail.current.max_so_far + : detail.current?.temp; + const currentTempText = + detail.current?.temp != null + ? `${detail.current.temp}${detail.temp_symbol}` + : "--"; + const daylightProgress = (() => { + const now = parseClockMinutes(detail.current?.obs_time); + const sunrise = parseClockMinutes(detail.forecast?.sunrise); + const sunset = parseClockMinutes(detail.forecast?.sunset); + if (now == null || sunrise == null || sunset == null || sunset <= sunrise) { + return null; + } + const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100); + const phase = + now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中"; + return { + phase, + percent, + }; + })(); + const displayedUpperAirSummary = showDeferredTodaySections + ? upperAirCue?.summary || view.front.upperAirSummary + : ""; + const displayedUpperAirMetrics = showDeferredTodaySections + ? (view.front.upperAirMetrics || []).map((metric, index) => + index === 0 && + (metric.label === "Trade cue" || metric.label === "交易动作") && + upperAirCue + ? { + ...metric, + note: upperAirCue.note, + tone: upperAirCue.tone, + value: upperAirCue.value, + } + : metric, + ) + : []; + const localizedAiCommentaryLines = useMemo(() => { + if (!showDeferredTodaySections) return [] as string[]; + const commentary = detail.dynamic_commentary || {}; + const headline = String( + locale === "en-US" + ? commentary.headline_en || "" + : commentary.headline_zh || "", + ).trim(); + const bullets = ( + locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh + ) as string[] | null | undefined; + const cleanedBullets = Array.isArray(bullets) + ? bullets.map((item) => String(item || "").trim()).filter(Boolean) + : []; + return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3); + }, [detail.dynamic_commentary, locale, showDeferredTodaySections]); + const todayTradeSummaryLines = useMemo(() => { + if (!showDeferredTodaySections) return [] as string[]; + if (!isToday) return [] as string[]; + if (localizedAiCommentaryLines.length > 0) { + return localizedAiCommentaryLines; + } + const lines: string[] = []; + if (paceView) { + const headline = + paceView.biasTone === "warm" + ? locale === "en-US" + ? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.` + : `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。` + : paceView.biasTone === "cold" + ? locale === "en-US" + ? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.` + : `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` + : locale === "en-US" + ? "Pace is still on curve; the next move depends on the peak-window push." + : "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。"; + lines.push(headline); + } + if (boundaryRiskView) { + lines.push( + locale === "en-US" + ? `${boundaryRiskView.label}: ${boundaryRiskView.note}` + : `${boundaryRiskView.label}:${boundaryRiskView.note}`, + ); + } + if (networkLeadView) { + lines.push( + locale === "en-US" + ? `${networkLeadView.label}: ${networkLeadView.note}` + : `${networkLeadView.label}:${networkLeadView.note}`, + ); + } + return lines.slice(0, 3); + }, [ + boundaryRiskView, + isToday, + locale, + localizedAiCommentaryLines, + networkLeadView, + paceView, + showDeferredTodaySections, + ]); + const intradayMeteorology = detail.intraday_meteorology || {}; + const meteorologySignals = Array.isArray( + intradayMeteorology.signal_contributions, + ) + ? intradayMeteorology.signal_contributions + : []; + const invalidationRules = localizedList( + locale, + intradayMeteorology.invalidation_rules, + intradayMeteorology.invalidation_rules_en, + ); + const confirmationRules = localizedList( + locale, + intradayMeteorology.confirmation_rules, + intradayMeteorology.confirmation_rules_en, + ); + const meteorologyHeadline = + localizedText( + locale, + intradayMeteorology.headline, + intradayMeteorology.headline_en, + ) || + todayTradeSummaryLines[0] || + (locale === "en-US" + ? "Intraday meteorology layers are still syncing; use the next observation as the anchor." + : "关键日内气象层仍在同步,先以下一次观测作为判断锚点。"); + const baseCaseBucket = + String(intradayMeteorology.base_case_bucket || "").trim() || + formatBucketLabel(topProbabilityBucket); + const nextObservationTime = + String(intradayMeteorology.next_observation_time || "").trim() || "--"; + const baseBucketNumber = parseLeadingNumber(baseCaseBucket); + const referenceObservedTemp = + topObservedTemp != null && Number.isFinite(Number(topObservedTemp)) + ? Number(topObservedTemp) + : detail.current?.temp != null + ? Number(detail.current.temp) + : null; + const gapToBaseBucket = + baseBucketNumber != null && referenceObservedTemp != null + ? Math.max(0, baseBucketNumber - referenceObservedTemp) + : null; + const pathStatus = + gapToBaseBucket == null + ? locale === "en-US" + ? "Awaiting anchor" + : "等待锚点" + : gapToBaseBucket <= 0.05 + ? locale === "en-US" + ? "Base path touched" + : "基准路径已触达" + : gapToBaseBucket <= 1.0 + ? locale === "en-US" + ? "Base path open" + : "基准路径开放" + : locale === "en-US" + ? "Needs peak push" + : "需要峰值推动"; + const peakWindowText = + String(intradayMeteorology.peak_window || "").trim() || + paceView?.peakWindowText || + "--"; + const settlementSourceCode = normalizeObservationSourceCode( + detail.current?.settlement_source || "", + ); + const settlementStationCode = String( + detail.current?.station_code || detail.risk?.icao || "", + ) + .trim() + .toUpperCase(); + const settlementStationName = + String(detail.current?.station_name || detail.risk?.airport || "").trim() || + settlementStationCode || + (locale === "en-US" ? "Anchor station" : "锚点站"); + const airportMetarAnchor = + settlementSourceCode === "metar" || + Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode)); + const anchorSourceLabel = airportMetarAnchor + ? settlementStationCode + ? `${settlementStationCode} METAR` + : "METAR" + : normalizeObservationSourceLabel( + detail.current?.settlement_source_label || + detail.current?.settlement_source, + locale === "en-US" ? "Official observation" : "官方观测", + ); + const anchorRuleText = airportMetarAnchor + ? locale === "en-US" + ? `Airport contract anchor: use the ${anchorSourceLabel} reports. Third-party history pages are display-only when present.` + : `机场合约锚点:以 ${anchorSourceLabel} 报文为准;第三方历史页只作为展示入口。` + : locale === "en-US" + ? `Official anchor: use ${anchorSourceLabel} observations for this contract.` + : `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`; + const nextObservationLabel = airportMetarAnchor + ? locale === "en-US" + ? "Next METAR watch" + : "下一次 METAR 观察" + : locale === "en-US" + ? "Next anchor watch" + : "下一次锚点观察"; + const gapToBaseText = + gapToBaseBucket == null + ? "--" + : `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`; + const syncStatusItems = [ + { + key: "base", + state: isAnyLayerSyncing ? "syncing" : "ready", + label: isAnyLayerSyncing + ? locale === "en-US" + ? "Refreshing base analysis" + : "正在刷新基础分析" + : locale === "en-US" + ? "Base analysis ready" + : "基础分析已加载", + note: isAnyLayerSyncing + ? locale === "en-US" + ? "Latest anchor readings and forecast curve are being rebuilt." + : "正在重建最新锚点读数和预测曲线。" + : locale === "en-US" + ? "Forecast curve, anchor state, and the core intraday view are available." + : "预测曲线、锚点状态和核心日内视图已经可用。", + }, + { + key: "market", + state: isAnyLayerSyncing ? "syncing" : "ready", + label: isAnyLayerSyncing + ? locale === "en-US" + ? "Refreshing probability layer" + : "正在刷新概率层" + : locale === "en-US" + ? "Probability layer ready" + : "概率层已加载", + note: isAnyLayerSyncing + ? locale === "en-US" + ? "Model spread and calibrated buckets are updating." + : "模型分歧和校准概率桶正在更新。" + : locale === "en-US" + ? `Probability buckets are derived from the ${probabilityEngineLabel} layer.` + : `概率桶当前由 ${probabilityEngineLabel} 层推导。`, + }, + ] satisfies FutureSyncStatusItem[]; + + return ( +
{ + if (event.target === event.currentTarget) { + modal.closeFutureModal(); + } + }} + > + {isProLoading ? ( +
+
+ {t("dashboard.loading")} +
+
+ ) : !isPro ? ( + + ) : ( +
+ { + if (isToday) { + void modal.openTodayModal(true); + return; + } + modal.openFutureModal(dateStr, true); + }} + t={t} + /> +
+ {isTodayBlockingRefresh && } + {isToday && ( + + )} + + {isNoaaSettlement && ( +
+ {locale === "en-US" + ? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.` + : `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`} +
+ )} + {isToday ? ( +
+ + +
+
+
+
+ {locale === "en-US" ? "Primary view" : "主视图"} +
+

+ {locale === "en-US" + ? "Today's temperature path (anchor obs + models)" + : "今日气温路径(锚点观测 + 模型)"} +

+
+ +
+ + {locale === "en-US" ? "Base" : "基准"} ·{" "} + {baseCaseBucket || "--"} + + + {locale === "en-US" ? "Upside" : "上修"} ·{" "} + {intradayMeteorology.upside_bucket || "--"} + + + {locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "} + {nextObservationTime} + +
+
+ + + +
+
+
+
+ {locale === "en-US" ? "Probability read" : "概率判断"} +
+

{probabilityTitle}

+
+
+ {probabilitySummary} +
+
+ +
+
+
+
+
+ {locale === "en-US" ? "Model layer" : "模型层"} +
+

+ {locale === "en-US" + ? "Model Range & Spread" + : "模型区间与分歧"} +

+
+
+ {modelSummary} +
+ +
+
+
+
+ ) : ( + + )} +
+
+ )} +
+ ); +} + diff --git a/frontend/components/dashboard/FutureForecastModalHeader.tsx b/frontend/components/dashboard/FutureForecastModalHeader.tsx new file mode 100644 index 00000000..2dc74dd9 --- /dev/null +++ b/frontend/components/dashboard/FutureForecastModalHeader.tsx @@ -0,0 +1,100 @@ +"use client"; + +import clsx from "clsx"; +import type { Locale } from "@/lib/i18n"; + +type FutureForecastModalHeaderProps = { + cityDisplayName: string; + dateStr: string; + isAnyLayerSyncing: boolean; + isPro: boolean; + isProLoading: boolean; + isToday: boolean; + locale: Locale; + onClose: () => void; + onRefresh: () => void; + t: (key: string, params?: Record) => string; +}; + +export function FutureForecastModalHeader({ + cityDisplayName, + dateStr, + isAnyLayerSyncing, + isPro, + isProLoading, + isToday, + locale, + onClose, + onRefresh, + t, +}: FutureForecastModalHeaderProps) { + const cityLabel = cityDisplayName.toUpperCase(); + return ( +
+
+
+ {locale === "en-US" ? "Analysis workspace" : "分析工作台"} + + {cityLabel} +
+

+ + {isToday + ? t("future.todayTitle", { + city: cityLabel, + }) + : t("future.dateTitle", { + city: cityLabel, + date: dateStr, + })} + + +

+
+ {isToday + ? locale === "en-US" + ? "Base signal first, then probability and model layers." + : "先看基础信号,再看概率层和模型层。" + : locale === "en-US" + ? "Forward date view with phased model and structure sync." + : "未来日期视图,模型层与结构层分阶段补齐。"} +
+
+ +
+ ); +} diff --git a/frontend/components/dashboard/HeaderBar.tsx b/frontend/components/dashboard/HeaderBar.tsx index 332a5040..ad7e0755 100644 --- a/frontend/components/dashboard/HeaderBar.tsx +++ b/frontend/components/dashboard/HeaderBar.tsx @@ -14,7 +14,7 @@ import { Sun, } from "lucide-react"; import { useEffect, useState } from "react"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; function parseExpiryInfo(raw?: string | null) { @@ -39,10 +39,11 @@ export function HeaderBar({ refreshSpinning?: boolean; }) { const store = useDashboardStore(); + const { proAccess } = useProAccess(); const { locale, t, toggleLocale } = useI18n(); const pathname = usePathname(); const [theme, setTheme] = useState<"dark" | "light">("dark"); - const isAuthenticated = store.proAccess.authenticated; + const isAuthenticated = proAccess.authenticated; const docsHref = "/docs/intro"; const docsActive = pathname?.startsWith("/docs"); const navItems = [ @@ -66,29 +67,29 @@ export function HeaderBar({ const accountAria = isAuthenticated ? t("header.accountAria") : t("header.signInAria"); - const effectiveExpiry = store.proAccess.subscriptionActive - ? store.proAccess.subscriptionTotalExpiresAt || - store.proAccess.subscriptionExpiresAt - : store.proAccess.subscriptionExpiresAt; + const effectiveExpiry = proAccess.subscriptionActive + ? proAccess.subscriptionTotalExpiresAt || + proAccess.subscriptionExpiresAt + : proAccess.subscriptionExpiresAt; const expiryInfo = parseExpiryInfo(effectiveExpiry); const hasQueuedExtension = Boolean( - store.proAccess.subscriptionActive && - store.proAccess.subscriptionQueuedDays > 0, + proAccess.subscriptionActive && + proAccess.subscriptionQueuedDays > 0, ); const isTrialPlan = /trial/i.test( - String(store.proAccess.subscriptionPlanCode || ""), + String(proAccess.subscriptionPlanCode || ""), ); const showRenewReminder = isAuthenticated && - !store.proAccess.loading && + !proAccess.loading && !hasQueuedExtension && - ((store.proAccess.subscriptionActive && + ((proAccess.subscriptionActive && expiryInfo && expiryInfo.daysLeft <= 3) || - (!store.proAccess.subscriptionActive && Boolean(expiryInfo))); + (!proAccess.subscriptionActive && Boolean(expiryInfo))); const renewReminderLabel = !showRenewReminder ? "" - : !store.proAccess.subscriptionActive + : !proAccess.subscriptionActive ? isTrialPlan ? locale === "en-US" ? "Trial ended" @@ -222,7 +223,7 @@ export function HeaderBar({ href="/account" className={clsx( "account-renew-badge", - !store.proAccess.subscriptionActive && "expired", + !proAccess.subscriptionActive && "expired", )} title={renewReminderLabel} aria-label={renewReminderLabel} @@ -234,3 +235,4 @@ export function HeaderBar({ ); } + diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx index ea4a4978..d893432d 100644 --- a/frontend/components/dashboard/HistoryModal.tsx +++ b/frontend/components/dashboard/HistoryModal.tsx @@ -2,7 +2,12 @@ import dynamic from "next/dynamic"; import { useMemo } from "react"; -import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore"; +import { + useDashboardHistory, + useDashboardSelection, + useHistoryData, + useProAccess, +} from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { getHistorySummary } from "@/lib/dashboard-utils"; @@ -19,28 +24,32 @@ const HistoryChart = dynamic( ); export function HistoryModal() { - const store = useDashboardStore(); + const history = useDashboardHistory(); + const selection = useDashboardSelection(); + const { proAccess } = useProAccess(); const { t, locale } = useI18n(); const { data, error, isLoading, isOpen, isRecordsLoading, meta } = useHistoryData(); - const isPro = store.proAccess.subscriptionActive; - const isProLoading = store.proAccess.loading; + const selectedDetail = selection.selectedDetail; + const selectedCity = selection.selectedCity; + const isPro = proAccess.subscriptionActive; + const isProLoading = proAccess.loading; const isNoaaSettlement = - store.selectedDetail?.current?.settlement_source === "noaa" || - store.selectedDetail?.current?.settlement_source_label === "NOAA"; + selectedDetail?.current?.settlement_source === "noaa" || + selectedDetail?.current?.settlement_source_label === "NOAA"; const noaaStationCode = String( - store.selectedDetail?.current?.station_code || - store.selectedDetail?.risk?.icao || + selectedDetail?.current?.station_code || + selectedDetail?.risk?.icao || "NOAA", ) .trim() .toUpperCase(); const noaaStationName = - String(store.selectedDetail?.current?.station_name || "").trim() || - String(store.selectedDetail?.risk?.airport || "").trim() || + String(selectedDetail?.current?.station_name || "").trim() || + String(selectedDetail?.risk?.airport || "").trim() || noaaStationCode; const summary = useMemo( - () => getHistorySummary(data, store.selectedDetail?.local_date), - [data, store.selectedDetail?.local_date], + () => getHistorySummary(data, selectedDetail?.local_date), + [data, selectedDetail?.local_date], ); const settledPeakRows = useMemo( () => @@ -78,7 +87,7 @@ export function HistoryModal() { aria-labelledby="history-modal-title" onClick={(event) => { if (event.target === event.currentTarget) { - store.closeHistory(); + history.closeHistory(); } }} > @@ -101,7 +110,7 @@ export function HistoryModal() { {t("history.previewDesc")}

- + ) : (
@@ -110,11 +119,11 @@ export function HistoryModal() {
{locale === "en-US" ? "Audit workspace" : "对账工作台"} - {store.selectedCity?.toUpperCase() || ""} + {selectedCity?.toUpperCase() || ""}

{t("history.title", { - city: store.selectedCity?.toUpperCase() || "", + city: selectedCity?.toUpperCase() || "", })}

@@ -144,7 +153,7 @@ export function HistoryModal() { type="button" className="modal-close" aria-label={t("history.closeAria")} - onClick={store.closeHistory} + onClick={history.closeHistory} > × @@ -153,8 +162,8 @@ export function HistoryModal() { {isNoaaSettlement && (
{t("lang") === "en-US" - ? `${store.selectedDetail?.display_name || store.selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.` - : `${store.selectedDetail?.display_name || store.selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}(${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`} + ? `${selectedDetail?.display_name || selectedCity || "This city"} historical actuals are aligned to NOAA ${noaaStationCode} (${noaaStationName}) settlement rules: use the highest rounded whole-degree Celsius reading after the date is finalized.` + : `${selectedDetail?.display_name || selectedCity || "该城市"}历史对账已按 NOAA ${noaaStationCode}(${noaaStationName})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
)} {isLoading ? ( @@ -292,17 +301,17 @@ export function HistoryModal() { {locale === "en-US" ? "Actual" : "最终实测"}{" "} {row.actual} - {store.selectedDetail?.temp_symbol || "°C"} + {selectedDetail?.temp_symbol || "°C"} DEB{" "} {row.model_reference?.deb?.value ?? row.deb ?? "--"} - {store.selectedDetail?.temp_symbol || "°C"} + {selectedDetail?.temp_symbol || "°C"} {row.model_reference?.deb?.error != null - ? ` / ${locale === "en-US" ? "err" : "误差"} ${row.model_reference.deb.error}${store.selectedDetail?.temp_symbol || "°C"}` + ? ` / ${locale === "en-US" ? "err" : "误差"} ${row.model_reference.deb.error}${selectedDetail?.temp_symbol || "°C"}` : ""}
@@ -317,11 +326,11 @@ export function HistoryModal() { {model.value} - {store.selectedDetail?.temp_symbol || "°C"} + {selectedDetail?.temp_symbol || "°C"} {model.error != null - ? `${locale === "en-US" ? "err" : "误差"} ${model.error}${store.selectedDetail?.temp_symbol || "°C"}` + ? `${locale === "en-US" ? "err" : "误差"} ${model.error}${selectedDetail?.temp_symbol || "°C"}` : locale === "en-US" ? "pending" : "待结算"} @@ -364,7 +373,7 @@ export function HistoryModal() { {locale === "en-US" ? "Peak ref" : "峰值参考"}:{" "} {row.actual} - {store.selectedDetail?.temp_symbol || "°C"} @{" "} + {selectedDetail?.temp_symbol || "°C"} @{" "} {row.actual_peak_time}
@@ -372,7 +381,7 @@ export function HistoryModal() { {locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "} {row.deb_at_peak_minus_12h} - {store.selectedDetail?.temp_symbol || "°C"} @{" "} + {selectedDetail?.temp_symbol || "°C"} @{" "} {row.deb_at_peak_minus_12h_time} @@ -380,7 +389,7 @@ export function HistoryModal() { {locale === "en-US" ? "Actual" : "最终实测"}:{" "} {row.actual} - {store.selectedDetail?.temp_symbol || "°C"} + {selectedDetail?.temp_symbol || "°C"}
@@ -394,7 +403,7 @@ export function HistoryModal() { }} > {row.deb_at_peak_minus_12h_error != null - ? `${row.deb_at_peak_minus_12h_error > 0 ? "+" : ""}${row.deb_at_peak_minus_12h_error}${store.selectedDetail?.temp_symbol || "°C"}` + ? `${row.deb_at_peak_minus_12h_error > 0 ? "+" : ""}${row.deb_at_peak_minus_12h_error}${selectedDetail?.temp_symbol || "°C"}` : "--"}
@@ -412,3 +421,4 @@ export function HistoryModal() { ); } + diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index c6b9b231..32c65b1e 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -12,7 +12,11 @@ import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import { startTransition, useMemo } from "react"; import { useChart } from "@/hooks/useChart"; -import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore"; +import { + useCityData, + useCityDetails, + useDashboardModal, +} from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import { CityDetail } from "@/lib/dashboard-types"; import { getTemperatureChartData } from "@/lib/chart-utils"; @@ -291,7 +295,8 @@ export { ProbabilityDistribution } from "./ProbabilityDistribution"; export { ModelForecast } from "./ModelForecast"; export function ForecastTable() { - const store = useDashboardStore(); + const modal = useDashboardModal(); + const { loadingState } = useCityDetails(); const { data } = useCityData(); const { locale, t } = useI18n(); const daily = useMemo(() => { @@ -310,7 +315,7 @@ export function ForecastTable() { if (!data) return null; const isSparseDaily = daily.length <= 1; const isForecastCompleting = - store.loadingState.cityDetail && + loadingState.cityDetail && (data.detail_depth !== "full" || isSparseDaily); const resolveForecastTemp = ( date: string, @@ -344,11 +349,11 @@ export function ForecastTable() { : index === 0; const isSelected = (isToday && - store.forecastModalMode === "today" && - Boolean(store.futureModalDate)) || - (store.forecastModalMode !== "today" && - store.futureModalDate === day.date) || - store.selectedForecastDate === day.date; + modal.forecastModalMode === "today" && + Boolean(modal.futureModalDate)) || + (modal.forecastModalMode !== "today" && + modal.futureModalDate === day.date) || + modal.selectedForecastDate === day.date; return ( - - {userLocalTime} - - {isPro ? null : store.proAccess.authenticated ? ( - - ) : ( - - - {isEn ? "Sign in" : "登录"} - - )} - - {store.proAccess.authenticated ? ( - - - - ) : null} - - - - - + {showAnnouncement ? ( -
-
- {isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"} - - {isEn ? "Scan terminal is upgraded to v1.5.6" : "决策终端已升级到 v1.5.6"} - -

- {isEn - ? "City decision cards have been redesigned with a compact hero layout and consistent DEB data source. Sticky headers are removed for smoother scrolling." - : "城市决策卡 hero 布局重新设计,三指标并列对比更直观;DEB 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"} -

-
-
    -
  • {isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}
  • -
  • {isEn ? "Unified DEB data source" : "统一 DEB 数据源"}
  • -
  • {isEn ? "Light theme coverage" : "亮色主题补全覆盖"}
  • -
  • {isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}
  • -
  • {isEn ? "Smoother scrolling experience" : "滚动体验优化"}
  • -
- -
+ /> ) : null}
@@ -627,22 +525,10 @@ function ScanTerminalScreen() { {}} /> {showScanPaywall ? ( -
{ - if (event.target === event.currentTarget) { - setShowScanPaywall(false); - } - }} - > - setShowScanPaywall(false)} - /> -
+ setShowScanPaywall(false)} + /> ) : null} ); @@ -657,3 +543,4 @@ export function ScanTerminalDashboard() { ); } + diff --git a/frontend/components/dashboard/WeatherAuraLayer.tsx b/frontend/components/dashboard/WeatherAuraLayer.tsx index 8fb18523..57bd2fa4 100644 --- a/frontend/components/dashboard/WeatherAuraLayer.tsx +++ b/frontend/components/dashboard/WeatherAuraLayer.tsx @@ -2,7 +2,7 @@ import { CSSProperties, useEffect, useRef, useState } from "react"; import * as THREE from "three"; -import { useDashboardStore } from "@/hooks/useDashboardStore"; +import { useDashboardSelection } from "@/hooks/useDashboardStore"; import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; import { getWeatherAuraProfile } from "@/lib/weather-aura"; import styles from "./Dashboard.module.css"; @@ -24,12 +24,12 @@ function hexToRgba(hex: string, alpha: number) { } export function WeatherAuraLayer() { - const store = useDashboardStore(); + const { cities, selectedDetail } = useDashboardSelection(); const prefersReducedMotion = usePrefersReducedMotion(); const [isDesktop, setIsDesktop] = useState(false); const containerRef = useRef(null); - const aura = getWeatherAuraProfile(store.selectedDetail, store.cities); + const aura = getWeatherAuraProfile(selectedDetail, cities); useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) { diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index b0a42e9e..d38935b5 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -15,6 +15,7 @@ import { MONITOR_REFRESH_INTERVAL_MS, type MonitorRefreshTrigger, } from "./monitor-refresh-policy"; +import { resolveMonitorTemperature } from "./monitor-temperature"; /* ── Constants ───────────────────────────────────────────────── */ const MONITOR_KEYS = [ @@ -92,8 +93,7 @@ function playNewHighBeep(): void { } function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" { - if (!detail?.airport_current) return "flat"; - const cur = detail.airport_current.temp ?? detail.current?.temp ?? null; + const cur = resolveMonitorTemperature(detail).value; const max = resolveMaxSoFar(detail); if (cur != null && max != null && cur >= max + 0.3) return "rising"; if (cur != null && max != null && cur < max - 1.0) return "falling"; @@ -248,7 +248,7 @@ export default function MonitorPanel({ const changed: MonitorKey[] = []; for (const key of MONITOR_KEYS) { const detail = details[key]; - const cur = detail?.airport_current?.temp ?? detail?.current?.temp ?? null; + const cur = resolveMonitorTemperature(detail).value; const prev = prevTempsRef.current[key]; if (cur != null && prev != null && cur !== prev) changed.push(key); if (cur != null) prevTempsRef.current[key] = cur; @@ -358,8 +358,8 @@ export default function MonitorPanel({ return [...MONITOR_KEYS] .map((k) => ({ key: k, detail: details[k] })) .sort((a, b) => { - const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null; - const tb = b.detail?.airport_current?.temp ?? b.detail?.current?.temp ?? null; + const ta = resolveMonitorTemperature(a.detail).value; + const tb = resolveMonitorTemperature(b.detail).value; if (ta == null && tb == null) return 0; if (ta == null) return 1; if (tb == null) return -1; @@ -393,8 +393,7 @@ export default function MonitorPanel({ if (alerted._day !== today) alerted = { _day: today }; for (const { key, detail } of sorted) { - const ac = detail?.airport_current; - const cur = ac?.temp ?? detail?.current?.temp ?? null; + const cur = resolveMonitorTemperature(detail).value; const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far if (cur != null && max != null && cur >= max + 0.3) { // Key: city + rounded temp, so we only beep once per 0.1°C step @@ -477,19 +476,20 @@ export default function MonitorPanel({ } const ac = detail.airport_current; - const cur = ac?.temp ?? detail.current?.temp ?? null; + const cur = resolveMonitorTemperature(detail).value; const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null; const freshnessInfo = getObservationFreshness(detail); const obs = - ac?.obs_time ?? freshnessInfo?.observed_at_local ?? + ac?.obs_time ?? detail.current?.obs_time ?? detail.local_time ?? ""; const age = - ac?.obs_age_min ?? - (freshnessInfo?.age_sec != null ? Math.round(freshnessInfo.age_sec / 60) : null); + freshnessInfo?.age_sec != null + ? Math.round(freshnessInfo.age_sec / 60) + : ac?.obs_age_min ?? null; const freshness = getMonitorFreshnessLevel(freshnessInfo, age); const tempSymbol = detail.temp_symbol || "°C"; // °F for US cities const newHigh = cur != null && max != null && cur >= max + 0.3; diff --git a/frontend/components/dashboard/monitoring/monitor-temperature.ts b/frontend/components/dashboard/monitoring/monitor-temperature.ts new file mode 100644 index 00000000..80ed26f1 --- /dev/null +++ b/frontend/components/dashboard/monitoring/monitor-temperature.ts @@ -0,0 +1,64 @@ +import type { CityDetail } from "@/lib/dashboard-types"; + +export type MonitorTemperatureSource = + | "amos_runway_median" + | "amos_runway" + | "amos" + | "airport_current" + | "current" + | "missing"; + +export type MonitorTemperature = { + source: MonitorTemperatureSource; + value: number | null; +}; + +function finiteNumber(value: unknown): number | null { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function median(values: number[]) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function getAmosRunwayTemperature(detail?: CityDetail | null) { + const runwayTemps = + detail?.amos?.runway_obs?.temperatures || + detail?.amos?.runway_temps || + []; + const values = runwayTemps + .map((pair) => finiteNumber(pair?.[0])) + .filter((value): value is number => value != null); + const value = median(values); + if (value == null) return null; + return { + source: values.length > 1 ? "amos_runway_median" : "amos_runway", + value, + } satisfies MonitorTemperature; +} + +export function resolveMonitorTemperature( + detail?: CityDetail | null, +): MonitorTemperature { + const runway = getAmosRunwayTemperature(detail); + if (runway) return runway; + + const amosTemp = finiteNumber(detail?.amos?.temp ?? detail?.amos?.temp_c); + if (amosTemp != null && detail?.amos?.temp_source === "runway_median") { + return { source: "amos", value: amosTemp }; + } + + const airport = finiteNumber(detail?.airport_current?.temp); + if (airport != null) return { source: "airport_current", value: airport }; + + const current = finiteNumber(detail?.current?.temp); + if (current != null) return { source: "current", value: current }; + + return { source: "missing", value: null }; +} diff --git a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx new file mode 100644 index 00000000..3b66b973 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx @@ -0,0 +1,229 @@ +"use client"; + +import clsx from "clsx"; +import Link from "next/link"; +import type { Dispatch, SetStateAction } from "react"; +import { + LogIn, + MessageCircle, + Moon, + Sun, + UserRound, +} from "lucide-react"; +import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; +import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; +import type { Locale } from "@/lib/i18n"; + +export type ScanTerminalContentView = "analysis" | "map" | "monitor"; + +type ThemeMode = "dark" | "light"; + +export function ScanTerminalLoadingScreen({ + isEn, + rootClassName, + themeMode, + userLocalTime, +}: { + isEn: boolean; + rootClassName: string; + themeMode: ThemeMode; + userLocalTime: string; +}) { + return ( +
+
+
+
+
+ + {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"} + + + {isEn + ? "Start from the map, then open city cards to verify weather evidence" + : "从地图选城市,再打开决策卡验证天气证据"} + +
+
+ {userLocalTime} +
+
+
+ +
+
+
+
+ ); +} + +export function ScanTerminalTopBar({ + accountHref, + isAuthenticated, + isEn, + isPro, + locale, + onOpenScanPaywall, + setThemeMode, + themeMode, + toggleLocale, + userLocalTime, +}: { + accountHref: string; + isAuthenticated: boolean; + isEn: boolean; + isPro: boolean; + locale: Locale; + onOpenScanPaywall: () => void; + setThemeMode: Dispatch>; + themeMode: ThemeMode; + toggleLocale: () => void; + userLocalTime: string; +}) { + return ( +
+
+ {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"} + + {isEn + ? "Start from the map, then open city cards to verify weather evidence" + : "从地图选城市,再打开决策卡验证天气证据"} + +
+
+ + {userLocalTime} + {isPro ? null : isAuthenticated ? ( + + ) : ( + + + {isEn ? "Sign in" : "登录"} + + )} + + {isAuthenticated ? ( + + + + ) : null} + + + +
+
+ ); +} + +export function ScanUpgradeAnnouncement({ + isEn, + onDismiss, +}: { + isEn: boolean; + onDismiss: () => void; +}) { + return ( +
+
+ {isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"} + + {isEn + ? "Scan terminal is upgraded to v1.5.6" + : "决策终端已升级到 v1.5.6"} + +

+ {isEn + ? "City decision cards have been redesigned with a compact hero layout and consistent DEB data source. Sticky headers are removed for smoother scrolling." + : "城市决策卡 hero 布局重新设计,三指标并列对比更直观;DEB 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"} +

+
+
    +
  • {isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}
  • +
  • {isEn ? "Unified DEB data source" : "统一 DEB 数据源"}
  • +
  • {isEn ? "Light theme coverage" : "亮色主题补全覆盖"}
  • +
  • {isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}
  • +
  • {isEn ? "Smoother scrolling experience" : "滚动体验优化"}
  • +
+ +
+ ); +} + +export function ScanPaywallModal({ + isEn, + onClose, +}: { + isEn: boolean; + onClose: () => void; +}) { + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + +
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts new file mode 100644 index 00000000..56c5095d --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { resolveMonitorTemperature } from "@/components/dashboard/monitoring/monitor-temperature"; +import type { CityDetail } from "@/lib/dashboard-types"; + +function detail(extra: Partial): CityDetail { + return { + current: { temp: null }, + display_name: "Busan", + lat: 0, + local_date: "2026-05-14", + local_time: "15:00", + lon: 0, + name: "busan", + risk: { level: "low" }, + temp_symbol: "°C", + ...extra, + } as CityDetail; +} + +export function runTests() { + const busan = detail({ + airport_current: { + obs_time: "15:00", + source_label: "METAR", + temp: 26, + }, + amos: { + runway_obs: { + runway_pairs: [["18L", "36R"]], + temperatures: [[25.2, 18.1]], + }, + source: "amos", + temp: 26, + temp_c: 26, + temp_source: "metar", + }, + }); + const busanTemp = resolveMonitorTemperature(busan); + assert.equal(busanTemp.value, 25.2); + assert.equal(busanTemp.source, "amos_runway"); + + const seoul = detail({ + airport_current: { obs_time: "15:00", temp: 27 }, + amos: { + runway_obs: { + runway_pairs: [["15L", "33R"], ["15R", "33L"]], + temperatures: [[25.2, 19], [24.8, 18.8]], + }, + source: "amos", + temp_source: "metar", + }, + }); + const seoulTemp = resolveMonitorTemperature(seoul); + assert.equal(seoulTemp.value, 25); + assert.equal(seoulTemp.source, "amos_runway_median"); + + const metarOnly = detail({ + airport_current: { obs_time: "15:00", temp: 30 }, + current: { temp: 29 } as CityDetail["current"], + }); + assert.equal(resolveMonitorTemperature(metarOnly).value, 30); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts index 0cf4dc83..0714b3d2 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/sourceFreshness.test.ts @@ -3,6 +3,7 @@ import { buildObservationFreshness, getMonitorFreshnessLevel, getMonitorRefreshCadenceMs, + getObservationFreshness, getObservationSourceProfile, shouldRefreshMonitorCity, } from "@/lib/source-freshness"; @@ -89,4 +90,23 @@ export function runTests() { assert.equal(getMonitorRefreshCadenceMs(["amos", "metar"]), 60_000); assert.equal(getMonitorRefreshCadenceMs(["metar"]), 300_000); + + const amosDetail = detail({ + airport_current: { + obs_age_min: 30, + obs_time: "11:30", + source_label: "METAR", + temp: 26, + }, + amos: { + observation_time: "2026-05-14T11:59:00Z", + runway_obs: { + runway_pairs: [["18L", "36R"]], + temperatures: [[25.2, 18.1]], + }, + source: "amos", + temp_source: "metar", + }, + }); + assert.equal(getObservationFreshness(amosDetail)?.source_code, "amos"); } diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 4cf26d76..e19aa538 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -73,11 +73,47 @@ interface DashboardStoreValue extends DashboardState { setForecastDate: (dateStr: string | null) => void; } +type DashboardModalContextValue = Pick< + DashboardStoreValue, + | "closeFutureModal" + | "forecastModalMode" + | "futureModalDate" + | "loadingState" + | "openFutureModal" + | "openTodayModal" + | "selectedForecastDate" + | "setForecastDate" +>; +type DashboardHistoryContextValue = Pick< + DashboardStoreValue, + "closeHistory" | "historyState" | "openHistory" +>; +type DashboardProAccessContextValue = Pick< + DashboardStoreValue, + "proAccess" | "refreshProAccess" +>; + const DashboardStoreContext = createContext(null); const DashboardActionsContext = createContext | null>(null); +const DashboardModalContext = + createContext(null); +const DashboardHistoryContext = + createContext(null); +const DashboardProAccessContext = + createContext(null); +const DashboardSelectionContext = createContext | null>(null); const CityDetailsContext = createContext<{ cityDetailsByName: Record; cityDetailMetaByName: Record; @@ -1370,19 +1406,146 @@ export function DashboardStoreProvider({ } }; + const closeFutureModal = () => { + modalOpenSeqRef.current += 1; + setFutureModalDate(null); + setForecastModalMode(null); + }; + + const closeHistory = () => + setHistoryState((current) => ({ ...current, isOpen: false })); + + 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: () => { - modalOpenSeqRef.current += 1; - setFutureModalDate(null); - setForecastModalMode(null); - }, - closeHistory: () => - setHistoryState((current) => ({ ...current, isOpen: false })), + closeFutureModal, + closeHistory, closePanel: () => { setIsPanelOpen(false); }, @@ -1397,129 +1560,9 @@ export function DashboardStoreProvider({ preloadCityFromRow, loadingState, proAccess, - 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, - })); - }); - } - }, + openFutureModal, openHistory, - 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, - })); - }); - }, + openTodayModal, registerMapStopMotion: (stopMotion: () => void) => { mapStopMotionRef.current = stopMotion; }, @@ -1531,8 +1574,7 @@ export function DashboardStoreProvider({ selectedForecastDate, selectCity, setMapInteractionActive: setIsMapInteracting, - setForecastDate: (dateStr: string | null) => - setSelectedForecastDate(dateStr), + setForecastDate, }), [ cities, @@ -1564,13 +1606,80 @@ export function DashboardStoreProvider({ }), [], ); + 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 dashboardHistoryValue = useMemo( + () => ({ + closeHistory, + historyState, + openHistory, + }), + [closeHistory, historyState, openHistory], + ); + const dashboardProAccessValue = useMemo( + () => ({ + proAccess, + refreshProAccess, + }), + [proAccess, refreshProAccess], + ); return ( - - {children} - + + + + + + {children} + + + + + ); @@ -1606,30 +1715,70 @@ export function useDashboardActions() { return context; } +export function useDashboardModal() { + const context = useContext(DashboardModalContext); + if (!context) { + throw new Error( + "useDashboardModal must be used within DashboardStoreProvider", + ); + } + return context; +} + +export function useDashboardHistory() { + const context = useContext(DashboardHistoryContext); + if (!context) { + throw new Error( + "useDashboardHistory 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 store = useDashboardStore(); - const key = name || store.selectedCity; + const selection = useDashboardSelection(); + const details = useCityDetails(); + const key = name || selection.selectedCity; return { - data: key ? findCachedCityDetail(store.cityDetailsByName, key) : null, + data: key ? findCachedCityDetail(details.cityDetailsByName, key) : null, isLoading: - store.loadingState.cityDetail && + details.loadingState.cityDetail && Boolean(key) && - store.selectedCity === key, + selection.selectedCity === key, }; } export function useHistoryData(name?: string | null) { - const store = useDashboardStore(); - const key = name || store.selectedCity; + const history = useDashboardHistory(); + const selection = useDashboardSelection(); + const key = name || selection.selectedCity; return { data: key - ? store.historyState.dataByCity[key] || ([] as HistoryPoint[]) + ? history.historyState.dataByCity[key] || ([] as HistoryPoint[]) : [], - error: store.historyState.error, - isLoading: store.historyState.loading, - isOpen: store.historyState.isOpen, - isRecordsLoading: store.historyState.recordsLoading, - meta: key ? store.historyState.metaByCity[key] || null : null, + error: history.historyState.error, + isLoading: history.historyState.loading, + isOpen: history.historyState.isOpen, + isRecordsLoading: history.historyState.recordsLoading, + meta: key ? history.historyState.metaByCity[key] || null : null, }; } diff --git a/frontend/lib/api-proxy.ts b/frontend/lib/api-proxy.ts index a02688d3..1a9e6386 100644 --- a/frontend/lib/api-proxy.ts +++ b/frontend/lib/api-proxy.ts @@ -1,4 +1,10 @@ +import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { buildCachedJsonResponse } from "@/lib/http-cache"; const PASSTHROUGH_UPSTREAM_STATUSES = new Set([ 400, @@ -70,3 +76,64 @@ export function buildProxyExceptionResponse( return NextResponse.json(body, { status: options.status ?? 500 }); } + +export async function proxyBackendJsonGet( + req: NextRequest, + options: { + cacheControl?: string; + conditionalResponse?: boolean; + detailLimit?: number; + error?: string; + fetchCache?: RequestCache; + includeSupabaseIdentity?: boolean; + publicMessage: string; + revalidateSeconds?: number; + signal?: AbortSignal; + statusOnException?: number; + timeoutPublicMessage?: string; + url: string; + }, +) { + let auth: Awaited> | null = null; + try { + auth = await buildBackendRequestHeaders(req, { + includeSupabaseIdentity: options.includeSupabaseIdentity ?? false, + }); + const res = await fetch(options.url, { + headers: auth.headers, + ...(options.fetchCache + ? { cache: options.fetchCache } + : { next: { revalidate: options.revalidateSeconds ?? 30 } }), + signal: options.signal, + }); + if (!res.ok) { + const raw = await res.text(); + const response = buildUpstreamErrorResponse(res.status, raw, { + detailLimit: options.detailLimit, + error: options.error, + }); + return applyAuthResponseCookies(response, auth.response); + } + + const data = await res.json(); + const response = + options.cacheControl && options.conditionalResponse !== false + ? buildCachedJsonResponse(req, data, options.cacheControl) + : NextResponse.json(data, { + headers: options.cacheControl + ? { "Cache-Control": options.cacheControl } + : undefined, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + const timedOut = options.signal?.aborted === true; + const response = buildProxyExceptionResponse(error, { + publicMessage: + timedOut && options.timeoutPublicMessage + ? options.timeoutPublicMessage + : options.publicMessage, + status: timedOut ? 504 : options.statusOnException, + }); + return auth ? applyAuthResponseCookies(response, auth.response) : response; + } +} diff --git a/frontend/lib/source-freshness.ts b/frontend/lib/source-freshness.ts index 3948c613..358a5977 100644 --- a/frontend/lib/source-freshness.ts +++ b/frontend/lib/source-freshness.ts @@ -204,6 +204,17 @@ export function buildObservationFreshness({ export function getObservationFreshness(detail?: CityDetail | null) { if (!detail) return null; + const hasAmosRunway = + (detail.amos?.runway_obs?.temperatures?.length || 0) > 0 || + (detail.amos?.runway_temps?.length || 0) > 0; + if (hasAmosRunway || detail.amos?.source === "amos") { + return buildObservationFreshness({ + observedAt: detail.amos?.observation_time || null, + observedAtLocal: detail.airport_current?.obs_time || detail.current?.obs_time || null, + sourceCode: "amos", + sourceLabel: detail.amos?.source_label || "AMOS", + }); + } const currentSource = canonicalSourceCode( detail.current?.source_code || detail.current?.settlement_source ||