feat: implement dashboard state management, API proxy layer, and modular UI components for weather monitoring and payments
This commit is contained in:
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user