feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
|
||||
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
|
||||
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
@@ -48,6 +48,7 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows
|
||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
|
||||
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
|
||||
import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||
|
||||
function createEmptyAccess(loading = true): ProAccessState {
|
||||
return {
|
||||
@@ -1024,9 +1025,32 @@ function ScanTerminalScreen() {
|
||||
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
|
||||
tradingRegion: selectedRegionKey,
|
||||
});
|
||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isPro || typeof fetch !== "function") return;
|
||||
const controller = new AbortController();
|
||||
fetch("/api/cities", {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) return null;
|
||||
return response.json() as Promise<{ cities?: CityListItem[] }>;
|
||||
})
|
||||
.then((payload) => {
|
||||
if (!payload || !Array.isArray(payload.cities)) return;
|
||||
setCityFallbackRows(cityListItemsToScanRows(payload.cities));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [isPro]);
|
||||
const rows = useMemo(
|
||||
() => sortRowsByUserTime(terminalData?.rows || []),
|
||||
[terminalData?.rows],
|
||||
() => {
|
||||
const scanRows = terminalData?.rows || [];
|
||||
return sortRowsByUserTime(scanRows.length ? scanRows : cityFallbackRows);
|
||||
},
|
||||
[cityFallbackRows, terminalData?.rows],
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||
import type { CityListItem } from "@/lib/dashboard-types";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cities: CityListItem[] = [
|
||||
{
|
||||
airport: "Taipei Songshan",
|
||||
display_name: "Taipei",
|
||||
icao: "RCSS",
|
||||
lat: 25.0697,
|
||||
lon: 121.5525,
|
||||
name: "taipei",
|
||||
risk_level: "medium",
|
||||
temp_unit: "celsius",
|
||||
utc_offset_seconds: 28800,
|
||||
},
|
||||
{
|
||||
airport: "LaGuardia",
|
||||
display_name: "New York",
|
||||
icao: "KLGA",
|
||||
lat: 40.7769,
|
||||
lon: -73.874,
|
||||
name: "new york",
|
||||
risk_level: "low",
|
||||
temp_unit: "fahrenheit",
|
||||
utc_offset_seconds: -14400,
|
||||
},
|
||||
];
|
||||
|
||||
const rows = cityListItemsToScanRows(cities);
|
||||
|
||||
assert(rows.length === 2, "fallback rows should preserve every city");
|
||||
assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable");
|
||||
assert(rows[0].city === "taipei", "fallback row should keep canonical city key");
|
||||
assert(rows[0].city_display_name === "Taipei", "fallback row should keep display name");
|
||||
assert(rows[0].airport === "Taipei Songshan", "fallback row should expose airport for selector display");
|
||||
assert(rows[0].trading_region === "east_asia", "fallback row should derive region from timezone");
|
||||
assert(rows[0].temp_symbol === "°C", "celsius cities should use °C");
|
||||
assert(rows[1].trading_region === "north_america", "known cities should keep their configured product region");
|
||||
assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F");
|
||||
assert(
|
||||
dashboardSource.includes("cityListItemsToScanRows") &&
|
||||
dashboardSource.includes("/api/cities") &&
|
||||
dashboardSource.includes("cityFallbackRows"),
|
||||
"terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { CityListItem, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import {
|
||||
REGIONS,
|
||||
getCityRegion,
|
||||
type RegionKey,
|
||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||
|
||||
function normalizeCityKey(value: string) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function tempSymbolForCity(city: CityListItem) {
|
||||
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||
}
|
||||
|
||||
function regionFromTzOffset(tzOffsetSeconds: number): (typeof REGIONS)[number] {
|
||||
const hours = tzOffsetSeconds / 3600;
|
||||
if (hours >= 8) return REGIONS[0];
|
||||
if (hours >= 7) return REGIONS[1];
|
||||
if (hours >= 4.5) return REGIONS[2];
|
||||
if (hours >= 2) return REGIONS[3];
|
||||
if (hours >= -2) return REGIONS[4];
|
||||
if (hours >= -4) return REGIONS[5];
|
||||
return REGIONS[6];
|
||||
}
|
||||
|
||||
function resolveRegion(cityKey: string, tzOffsetSeconds: number) {
|
||||
const configuredRegion = getCityRegion({ city: cityKey, id: `region:${cityKey}` } as ScanOpportunityRow);
|
||||
if (configuredRegion) {
|
||||
return REGIONS.find((region) => region.key === configuredRegion) || regionFromTzOffset(tzOffsetSeconds);
|
||||
}
|
||||
return regionFromTzOffset(tzOffsetSeconds);
|
||||
}
|
||||
|
||||
export function cityListItemsToScanRows(cities: CityListItem[]): ScanOpportunityRow[] {
|
||||
return cities
|
||||
.filter((city) => normalizeCityKey(city.name))
|
||||
.map((city) => {
|
||||
const cityKey = normalizeCityKey(city.name);
|
||||
const region = resolveRegion(cityKey, city.utc_offset_seconds ?? 0);
|
||||
return {
|
||||
active: true,
|
||||
airport: city.airport || city.icao || null,
|
||||
city: cityKey,
|
||||
city_display_name: city.display_name || city.name,
|
||||
closed: false,
|
||||
current_temp: null,
|
||||
deb_prediction: null,
|
||||
display_name: city.display_name || city.name,
|
||||
id: `city-fallback:${cityKey}`,
|
||||
is_primary_signal: true,
|
||||
local_time: null,
|
||||
risk_level: city.risk_level || null,
|
||||
temp_symbol: tempSymbolForCity(city),
|
||||
tradable: false,
|
||||
trading_region: region.key as RegionKey,
|
||||
trading_region_label: region.labelEn,
|
||||
trading_region_label_zh: region.labelZh,
|
||||
trading_region_sort: region.sort,
|
||||
tz_offset_seconds: city.utc_offset_seconds ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user