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,
- })}
-
- {
- if (isToday) {
- void store.openTodayModal(true);
- return;
- }
- store.openFutureModal(dateStr, true);
- }}
- title={
- !isPro
- ? locale === "en-US"
- ? "Pro subscription required"
- : "需要 Pro 订阅"
- : locale === "en-US"
- ? "Refresh Data"
- : "刷新数据"
- }
- >
-
-
-
-
-
-
-
- {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 ? (
-
-
-
-
- {showDeferredTodaySections && paceView ? (
-
- ) : isToday ? (
-
- ) : null}
-
-
-
-
-
-
- {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 ? (
+
+
+
+
+ {showDeferredTodaySections && paceView ? (
+
+ ) : isToday ? (
+
+ ) : null}
+
+
+
+
+
+
+ {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 (
{
startTransition(() => {
if (isToday) {
- store.openTodayModal();
+ modal.openTodayModal();
return;
}
- store.openFutureModal(day.date);
+ modal.openFutureModal(day.date);
});
}}
>
diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx
index 58a44f0a..440dada2 100644
--- a/frontend/components/dashboard/ProFeaturePaywall.tsx
+++ b/frontend/components/dashboard/ProFeaturePaywall.tsx
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useI18n } from "@/hooks/useI18n";
-import { useDashboardStore } from "@/hooks/useDashboardStore";
+import { useProAccess } from "@/hooks/useDashboardStore";
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
import { trackAppEvent } from "@/lib/app-analytics";
@@ -24,7 +24,7 @@ export function ProFeaturePaywall({
}: ProFeaturePaywallProps) {
const router = useRouter();
const { locale } = useI18n();
- const { proAccess } = useDashboardStore();
+ const { proAccess } = useProAccess();
const [usePoints, setUsePoints] = useState(true);
const isEn = locale === "en-US";
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index c230dd57..d2eba62f 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -2,15 +2,7 @@
import clsx from "clsx";
import dynamic from "next/dynamic";
-import Link from "next/link";
-import {
- LogIn,
- MessageCircle,
- Moon,
- RefreshCw,
- Sun,
- UserRound,
-} from "lucide-react";
+import { RefreshCw } from "lucide-react";
import {
useCallback,
useEffect,
@@ -24,6 +16,7 @@ import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
DashboardStoreProvider,
useDashboardStore,
+ useProAccess,
} from "@/hooks/useDashboardStore";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
import type {
@@ -31,8 +24,14 @@ import type {
ScanOpportunityRow,
} from "@/lib/dashboard-types";
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
-import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
import { WelcomeOverlay } from "@/components/dashboard/scan-terminal/WelcomeOverlay";
+import {
+ ScanPaywallModal,
+ ScanTerminalLoadingScreen,
+ ScanTerminalTopBar,
+ ScanUpgradeAnnouncement,
+ type ScanTerminalContentView,
+} from "@/components/dashboard/scan-terminal/ScanTerminalShellParts";
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import {
findRowForCity,
@@ -51,8 +50,6 @@ const MonitorPanel = dynamic(
{ ssr: false },
);
-type ContentView = "analysis" | "map" | "monitor";
-
const CityDetailPanel = dynamic(
() =>
import("@/components/dashboard/DetailPanel").then(
@@ -79,10 +76,11 @@ const MapCanvas = dynamic(
function ScanTerminalScreen() {
const store = useDashboardStore();
+ const { proAccess } = useProAccess();
const { locale, toggleLocale } = useI18n();
const isEn = locale === "en-US";
- const isPro = store.proAccess.subscriptionActive;
- const accountHref = store.proAccess.authenticated
+ const isPro = proAccess.subscriptionActive;
+ const accountHref = proAccess.authenticated
? "/account"
: "/auth/login?next=%2Faccount";
const {
@@ -92,10 +90,10 @@ function ScanTerminalScreen() {
terminalData,
} = useScanTerminalQuery({
isPro,
- proAccessLoading: store.proAccess.loading,
+ proAccessLoading: proAccess.loading,
});
const [selectedRowId, setSelectedRowId] = useState(null);
- const [activeView, setActiveView] = useState("map");
+ const [activeView, setActiveView] = useState("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState(null);
const [showScanPaywall, setShowScanPaywall] = useState(false);
const [showAnnouncement, setShowAnnouncement] = useState(false);
@@ -240,7 +238,7 @@ function ScanTerminalScreen() {
store.selectedDetail,
]);
- const resolvedView: ContentView = activeView;
+ const resolvedView: ScanTerminalContentView = activeView;
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
const activeDetailRow =
resolvedView === "map" && mapFocusedCity
@@ -360,40 +358,18 @@ function ScanTerminalScreen() {
return null;
};
- if (store.proAccess.loading) {
+ if (proAccess.loading) {
return (
-
-
-
-
-
- {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}
-
- {isEn
- ? "Start from the map, then open city cards to verify weather evidence"
- : "从地图选城市,再打开决策卡验证天气证据"}
-
-
-
- {userLocalTime}
-
-
-
-
-
-
-
-
+
);
}
+
return (
-
-
- {isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}
-
- {isEn
- ? "Start from the map, then open city cards to verify weather evidence"
- : "从地图选城市,再打开决策卡验证天气证据"}
-
-
-
-
- 中文
- EN
-
-
- {userLocalTime}
-
- {isPro ? null : store.proAccess.authenticated ? (
-
-
- {isEn ? "Upgrade Pro" : "升级 Pro"}
-
- ) : (
-
-
- {isEn ? "Sign in" : "登录"}
-
- )}
-
setThemeMode((current) => (current === "light" ? "dark" : "light"))}
- >
- {themeMode === "light" ? : }
-
- {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" : "滚动体验优化"}
-
- {
- localStorage.setItem("polyweather_v156_announcement_seen_at", String(Date.now() + 90 * 24 * 60 * 60 * 1000));
+ {
+ localStorage.setItem(
+ "polyweather_v156_announcement_seen_at",
+ String(Date.now() + 90 * 24 * 60 * 60 * 1000),
+ );
setShowAnnouncement(false);
}}
- >
- ✕
-
-
+ />
) : 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"
+ : "从地图选城市,再打开决策卡验证天气证据"}
+
+
+
+
+ 中文
+ EN
+
+
{userLocalTime}
+ {isPro ? null : isAuthenticated ? (
+
+
+ {isEn ? "Upgrade Pro" : "升级 Pro"}
+
+ ) : (
+
+
+ {isEn ? "Sign in" : "登录"}
+
+ )}
+
+ setThemeMode((current) => (current === "light" ? "dark" : "light"))
+ }
+ >
+ {themeMode === "light" ? : }
+
+ {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 ||