feat: implement dashboard state management, API proxy layer, and modular UI components for weather monitoring and payments

This commit is contained in:
2569718930@qq.com
2026-05-14 15:05:13 +08:00
parent 02add6d994
commit c9d03fd3e1
30 changed files with 2213 additions and 1933 deletions
+7 -35
View File
@@ -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`,
});
}
+9 -37
View File
@@ -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,
});
}
@@ -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,
});
}
+11 -53
View File
@@ -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,
});
}
+7 -38
View File
@@ -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,
});
}
+9 -35
View File
@@ -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`,
});
}
@@ -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)}`,
});
}
+10 -32
View File
@@ -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`,
});
}
+10 -23
View File
@@ -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) {
+9 -39
View File
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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);
}
+8 -37
View File
@@ -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`,
});
}
+16 -8
View File
@@ -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<HTMLElement | null>(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(() => {
File diff suppressed because it is too large Load Diff
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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, string | number>) => string;
};
export function FutureForecastModalHeader({
cityDisplayName,
dateStr,
isAnyLayerSyncing,
isPro,
isProLoading,
isToday,
locale,
onClose,
onRefresh,
t,
}: FutureForecastModalHeaderProps) {
const cityLabel = cityDisplayName.toUpperCase();
return (
<div className="modal-header">
<div className="modal-title-stack">
<div className="modal-overline">
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
<span className="modal-overline-sep"></span>
<span>{cityLabel}</span>
</div>
<h2 id="future-modal-title" className="future-modal-title-with-actions">
<span>
{isToday
? t("future.todayTitle", {
city: cityLabel,
})
: t("future.dateTitle", {
city: cityLabel,
date: dateStr,
})}
</span>
<button
className={clsx("future-refresh-btn", isAnyLayerSyncing && "spinning")}
disabled={!isPro || isProLoading}
onClick={onRefresh}
title={
!isPro
? locale === "en-US"
? "Pro subscription required"
: "需要 Pro 订阅"
: locale === "en-US"
? "Refresh Data"
: "刷新数据"
}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</button>
</h2>
<div className="modal-subtitle">
{isToday
? locale === "en-US"
? "Base signal first, then probability and model layers."
: "先看基础信号,再看概率层和模型层。"
: locale === "en-US"
? "Forward date view with phased model and structure sync."
: "未来日期视图,模型层与结构层分阶段补齐。"}
</div>
</div>
<button
type="button"
className="modal-close"
aria-label={isToday ? t("future.closeTodayAria") : t("future.closeDateAria")}
onClick={onClose}
>
×
</button>
</div>
);
}
+16 -14
View File
@@ -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({
</header>
);
}
+38 -28
View File
@@ -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")}
</p>
</div>
<ProFeaturePaywall feature="history" onClose={store.closeHistory} />
<ProFeaturePaywall feature="history" onClose={history.closeHistory} />
</>
) : (
<div className="modal-content history-modal">
@@ -110,11 +119,11 @@ export function HistoryModal() {
<div className="modal-overline">
<span>{locale === "en-US" ? "Audit workspace" : "对账工作台"}</span>
<span className="modal-overline-sep"></span>
<span>{store.selectedCity?.toUpperCase() || ""}</span>
<span>{selectedCity?.toUpperCase() || ""}</span>
</div>
<h2 id="history-modal-title">
{t("history.title", {
city: store.selectedCity?.toUpperCase() || "",
city: selectedCity?.toUpperCase() || "",
})}
</h2>
<div className="modal-subtitle">
@@ -144,7 +153,7 @@ export function HistoryModal() {
type="button"
className="modal-close"
aria-label={t("history.closeAria")}
onClick={store.closeHistory}
onClick={history.closeHistory}
>
×
</button>
@@ -153,8 +162,8 @@ export function HistoryModal() {
{isNoaaSettlement && (
<div className="modal-callout modal-callout-info">
{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})结算口径对齐:采用该日最终完成质控后的最高整度摄氏值。`}
</div>
)}
{isLoading ? (
@@ -292,17 +301,17 @@ export function HistoryModal() {
{locale === "en-US" ? "Actual" : "最终实测"}{" "}
<strong>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
{selectedDetail?.temp_symbol || "°C"}
</strong>
</span>
<span>
DEB{" "}
<strong>
{row.model_reference?.deb?.value ?? row.deb ?? "--"}
{store.selectedDetail?.temp_symbol || "°C"}
{selectedDetail?.temp_symbol || "°C"}
</strong>
{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"}`
: ""}
</span>
</div>
@@ -317,11 +326,11 @@ export function HistoryModal() {
</span>
<span>
{model.value}
{store.selectedDetail?.temp_symbol || "°C"}
{selectedDetail?.temp_symbol || "°C"}
</span>
<span className="history-model-error">
{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" : "峰值参考"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{selectedDetail?.temp_symbol || "°C"} @{" "}
{row.actual_peak_time}
</span>
</div>
@@ -372,7 +381,7 @@ export function HistoryModal() {
{locale === "en-US" ? "DEB@-12h" : "峰值前12小时 DEB"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.deb_at_peak_minus_12h}
{store.selectedDetail?.temp_symbol || "°C"} @{" "}
{selectedDetail?.temp_symbol || "°C"} @{" "}
{row.deb_at_peak_minus_12h_time}
</span>
</div>
@@ -380,7 +389,7 @@ export function HistoryModal() {
{locale === "en-US" ? "Actual" : "最终实测"}:{" "}
<span style={{ color: "var(--text-primary)" }}>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
{selectedDetail?.temp_symbol || "°C"}
</span>
</div>
<div>
@@ -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"}`
: "--"}
</span>
</div>
@@ -412,3 +421,4 @@ export function HistoryModal() {
</div>
);
}
+15 -10
View File
@@ -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 (
<button
key={day.date}
@@ -361,10 +366,10 @@ export function ForecastTable() {
onClick={() => {
startTransition(() => {
if (isToday) {
store.openTodayModal();
modal.openTodayModal();
return;
}
store.openFutureModal(day.date);
modal.openFutureModal(day.date);
});
}}
>
@@ -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";
@@ -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<string | null>(null);
const [activeView, setActiveView] = useState<ContentView>("map");
const [activeView, setActiveView] = useState<ScanTerminalContentView>("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(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 (
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<span className="scan-topbar-time">{userLocalTime}</span>
</div>
</div>
<div className="scan-loading-state">
<LoadingSignal
title={isEn ? "Preparing decision workspace" : "正在准备决策工作台"}
description={
isEn
? "Checking access, city context and today's tradable weather windows."
: "正在检查权限、城市上下文和今日可交易天气窗口。"
}
/>
</div>
</main>
</div>
</div>
<ScanTerminalLoadingScreen
isEn={isEn}
rootClassName={scanTerminalRootClassName}
themeMode={themeMode}
userLocalTime={userLocalTime}
/>
);
}
return (
<div className={scanTerminalRootClassName}>
<div
@@ -406,108 +382,30 @@ function ScanTerminalScreen() {
)}
>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<button
type="button"
className="scan-locale-switch"
aria-label={isEn ? "Switch to Chinese" : "切换到英文"}
title={isEn ? "Switch to Chinese" : "切换到英文"}
onClick={toggleLocale}
>
<span className={clsx(locale === "zh-CN" && "active")}></span>
<span className={clsx(locale === "en-US" && "active")}>EN</span>
</button>
<span className="scan-topbar-time">
{userLocalTime}
</span>
{isPro ? null : store.proAccess.authenticated ? (
<button
type="button"
className="scan-primary-button"
onClick={openScanPaywall}
>
<UserRound size={14} />
{isEn ? "Upgrade Pro" : "升级 Pro"}
</button>
) : (
<Link href={accountHref} className="scan-primary-button">
<LogIn size={14} />
{isEn ? "Sign in" : "登录"}
</Link>
)}
<button
type="button"
className="scan-theme-button"
aria-label={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
onClick={() => setThemeMode((current) => (current === "light" ? "dark" : "light"))}
>
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
</button>
{store.proAccess.authenticated ? (
<Link
href={accountHref}
className="scan-account-button"
aria-label={isEn ? "Account" : "账户"}
title={isEn ? "Account" : "账户"}
>
<UserRound size={15} />
</Link>
) : null}
<a
href="https://t.me/+nMG7SjziUKYyZmM1"
target="_blank"
rel="noopener noreferrer"
className="scan-account-button"
aria-label={isEn ? "Feedback" : "反馈"}
title={isEn ? "Join Telegram for feedback" : "加入 Telegram 反馈"}
>
<MessageCircle size={15} />
</a>
</div>
</div>
<ScanTerminalTopBar
accountHref={accountHref}
isAuthenticated={proAccess.authenticated}
isEn={isEn}
isPro={isPro}
locale={locale}
onOpenScanPaywall={openScanPaywall}
setThemeMode={setThemeMode}
themeMode={themeMode}
toggleLocale={toggleLocale}
userLocalTime={userLocalTime}
/>
{showAnnouncement ? (
<section className="scan-upgrade-announcement" aria-label={isEn ? "Upgrade announcement" : "升级公告"}>
<div className="scan-upgrade-announcement-copy">
<span>{isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"}</span>
<strong>
{isEn ? "Scan terminal is upgraded to v1.5.6" : "决策终端已升级到 v1.5.6"}
</strong>
<p>
{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 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"}
</p>
</div>
<ul>
<li>{isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}</li>
<li>{isEn ? "Unified DEB data source" : "统一 DEB 数据源"}</li>
<li>{isEn ? "Light theme coverage" : "亮色主题补全覆盖"}</li>
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
</ul>
<button
type="button"
className="scan-announcement-dismiss"
aria-label={isEn ? "Dismiss" : "关闭"}
onClick={() => {
localStorage.setItem("polyweather_v156_announcement_seen_at", String(Date.now() + 90 * 24 * 60 * 60 * 1000));
<ScanUpgradeAnnouncement
isEn={isEn}
onDismiss={() => {
localStorage.setItem(
"polyweather_v156_announcement_seen_at",
String(Date.now() + 90 * 24 * 60 * 60 * 1000),
);
setShowAnnouncement(false);
}}
>
</button>
</section>
/>
) : null}
<section className="scan-list-section">
@@ -627,22 +525,10 @@ function ScanTerminalScreen() {
<WelcomeOverlay locale={locale} onDismiss={() => {}} />
<FutureForecastModal />
{showScanPaywall ? (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={isEn ? "Unlock market scan" : "解锁市场扫描"}
onClick={(event) => {
if (event.target === event.currentTarget) {
setShowScanPaywall(false);
}
}}
>
<ProFeaturePaywall
feature="scan"
onClose={() => setShowScanPaywall(false)}
/>
</div>
<ScanPaywallModal
isEn={isEn}
onClose={() => setShowScanPaywall(false)}
/>
) : null}
</div>
);
@@ -657,3 +543,4 @@ export function ScanTerminalDashboard() {
</I18nProvider>
);
}
@@ -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<HTMLDivElement | null>(null);
const aura = getWeatherAuraProfile(store.selectedDetail, store.cities);
const aura = getWeatherAuraProfile(selectedDetail, cities);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
@@ -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;
@@ -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 };
}
@@ -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 (
<div className={rootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>
{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}
</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<span className="scan-topbar-time">{userLocalTime}</span>
</div>
</div>
<div className="scan-loading-state">
<LoadingSignal
title={isEn ? "Preparing decision workspace" : "正在准备决策工作台"}
description={
isEn
? "Checking access, city context and today's tradable weather windows."
: "正在检查权限、城市上下文和今日可交易天气窗口。"
}
/>
</div>
</main>
</div>
</div>
);
}
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<SetStateAction<ThemeMode>>;
themeMode: ThemeMode;
toggleLocale: () => void;
userLocalTime: string;
}) {
return (
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<button
type="button"
className="scan-locale-switch"
aria-label={isEn ? "Switch to Chinese" : "切换到英文"}
title={isEn ? "Switch to Chinese" : "切换到英文"}
onClick={toggleLocale}
>
<span className={clsx(locale === "zh-CN" && "active")}></span>
<span className={clsx(locale === "en-US" && "active")}>EN</span>
</button>
<span className="scan-topbar-time">{userLocalTime}</span>
{isPro ? null : isAuthenticated ? (
<button
type="button"
className="scan-primary-button"
onClick={onOpenScanPaywall}
>
<UserRound size={14} />
{isEn ? "Upgrade Pro" : "升级 Pro"}
</button>
) : (
<Link href={accountHref} className="scan-primary-button">
<LogIn size={14} />
{isEn ? "Sign in" : "登录"}
</Link>
)}
<button
type="button"
className="scan-theme-button"
aria-label={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
title={themeMode === "light" ? "切换到暗色模式" : "切换到明亮模式"}
onClick={() =>
setThemeMode((current) => (current === "light" ? "dark" : "light"))
}
>
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
</button>
{isAuthenticated ? (
<Link
href={accountHref}
className="scan-account-button"
aria-label={isEn ? "Account" : "账户"}
title={isEn ? "Account" : "账户"}
>
<UserRound size={15} />
</Link>
) : null}
<a
href="https://t.me/+nMG7SjziUKYyZmM1"
target="_blank"
rel="noopener noreferrer"
className="scan-account-button"
aria-label={isEn ? "Feedback" : "反馈"}
title={isEn ? "Join Telegram for feedback" : "加入 Telegram 反馈"}
>
<MessageCircle size={15} />
</a>
</div>
</div>
);
}
export function ScanUpgradeAnnouncement({
isEn,
onDismiss,
}: {
isEn: boolean;
onDismiss: () => void;
}) {
return (
<section
className="scan-upgrade-announcement"
aria-label={isEn ? "Upgrade announcement" : "升级公告"}
>
<div className="scan-upgrade-announcement-copy">
<span>{isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"}</span>
<strong>
{isEn
? "Scan terminal is upgraded to v1.5.6"
: "决策终端已升级到 v1.5.6"}
</strong>
<p>
{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 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"}
</p>
</div>
<ul>
<li>{isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}</li>
<li>{isEn ? "Unified DEB data source" : "统一 DEB 数据源"}</li>
<li>{isEn ? "Light theme coverage" : "亮色主题补全覆盖"}</li>
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
</ul>
<button
type="button"
className="scan-announcement-dismiss"
aria-label={isEn ? "Dismiss" : "关闭"}
onClick={onDismiss}
>
</button>
</section>
);
}
export function ScanPaywallModal({
isEn,
onClose,
}: {
isEn: boolean;
onClose: () => void;
}) {
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label={isEn ? "Unlock market scan" : "解锁市场扫描"}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<ProFeaturePaywall feature="scan" onClose={onClose} />
</div>
);
}
@@ -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>): 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);
}
@@ -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");
}
+296 -147
View File
@@ -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<DashboardStoreValue | null>(null);
const DashboardActionsContext = createContext<Pick<
DashboardStoreValue,
"ensureCityDetail"
> | null>(null);
const DashboardModalContext =
createContext<DashboardModalContextValue | null>(null);
const DashboardHistoryContext =
createContext<DashboardHistoryContextValue | null>(null);
const DashboardProAccessContext =
createContext<DashboardProAccessContextValue | null>(null);
const DashboardSelectionContext = createContext<Pick<
DashboardStoreValue,
| "cities"
| "forecastModalMode"
| "futureModalDate"
| "isPanelOpen"
| "selectedCity"
| "selectedDetail"
| "selectedForecastDate"
> | null>(null);
const CityDetailsContext = createContext<{
cityDetailsByName: Record<string, CityDetail>;
cityDetailMetaByName: Record<string, { cachedAt: number; revision: string }>;
@@ -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<DashboardStoreValue>(
() => ({
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<React.ContextType<typeof DashboardSelectionContext>>
>(
() => ({
cities,
forecastModalMode,
futureModalDate,
isPanelOpen,
selectedCity,
selectedDetail,
selectedForecastDate,
}),
[
cities,
forecastModalMode,
futureModalDate,
isPanelOpen,
selectedCity,
selectedDetail,
selectedForecastDate,
],
);
const dashboardModalValue = useMemo<DashboardModalContextValue>(
() => ({
closeFutureModal,
forecastModalMode,
futureModalDate,
loadingState,
openFutureModal,
openTodayModal,
selectedForecastDate,
setForecastDate,
}),
[
closeFutureModal,
forecastModalMode,
futureModalDate,
loadingState,
openFutureModal,
openTodayModal,
selectedForecastDate,
setForecastDate,
],
);
const dashboardHistoryValue = useMemo<DashboardHistoryContextValue>(
() => ({
closeHistory,
historyState,
openHistory,
}),
[closeHistory, historyState, openHistory],
);
const dashboardProAccessValue = useMemo<DashboardProAccessContextValue>(
() => ({
proAccess,
refreshProAccess,
}),
[proAccess, refreshProAccess],
);
return (
<DashboardStoreContext.Provider value={value}>
<DashboardActionsContext.Provider value={dashboardActionsValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
<DashboardProAccessContext.Provider value={dashboardProAccessValue}>
<DashboardModalContext.Provider value={dashboardModalValue}>
<DashboardHistoryContext.Provider value={dashboardHistoryValue}>
<DashboardSelectionContext.Provider value={dashboardSelectionValue}>
<CityDetailsContext.Provider value={cityDetailsValue}>
{children}
</CityDetailsContext.Provider>
</DashboardSelectionContext.Provider>
</DashboardHistoryContext.Provider>
</DashboardModalContext.Provider>
</DashboardProAccessContext.Provider>
</DashboardActionsContext.Provider>
</DashboardStoreContext.Provider>
);
@@ -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,
};
}
+67
View File
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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;
}
}
+11
View File
@@ -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 ||