Files
PolyWeather/frontend/components/dashboard/OpportunityTable.tsx
T

346 lines
11 KiB
TypeScript

"use client";
import React from "react";
import { useI18n } from "@/hooks/useI18n";
import type {
DistributionPreviewPoint,
ScanOpportunityRow,
} from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import {
formatTemperatureValue,
normalizeTemperatureLabel,
normalizeTemperatureSymbol,
} from "@/lib/dashboard-utils";
type PhaseMeta = {
label: string;
tone: "green" | "amber" | "blue" | "red";
};
function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
function formatSignedTemperatureDelta(value?: number | null, tempSymbol?: string | null) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
if (Math.abs(numeric) < 0.05) return "0";
const absValue = formatTemperatureValue(Math.abs(numeric), tempSymbol);
return `${numeric > 0 ? "+" : "-"}${absValue}`;
}
function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m left`;
return `${hours}h ${remains}m left`;
}
if (hours <= 0) return `剩余 ${remains} 分钟`;
return `剩余 ${hours}h ${remains}m`;
}
function formatAction(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const formattedTarget = normalizeTemperatureLabel(row.target_label, tempSymbol);
if (row.action) {
return row.target_label
? row.action.replace(String(row.target_label), formattedTarget || String(row.target_label))
: row.action;
}
if (row.side === "yes") {
return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${formattedTarget || ""}`.trim();
}
if (row.side === "no") {
return `${locale === "en-US" ? "Buy No" : "买入 No"} ${formattedTarget || ""}`.trim();
}
return "--";
}
export function getWindowPhaseMeta(
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
locale: string,
): PhaseMeta {
const mode = String(row.window_phase || "").toLowerCase();
if (mode === "city_snapshot") {
return {
label: locale === "en-US" ? "City Snapshot" : "城市概况",
tone: "blue",
};
}
if (mode === "active_peak") {
return {
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
tone: "red",
};
}
if (mode === "setup_today") {
return {
label: locale === "en-US" ? "Touch Play" : "触达博弈",
tone: "red",
};
}
if (mode === "early_today") {
return {
label: locale === "en-US" ? "Early Today" : "日内早段",
tone: "blue",
};
}
if (mode === "tomorrow" || mode === "week_ahead") {
return {
label: locale === "en-US" ? "Early" : "早期机会",
tone: "blue",
};
}
if (mode === "post_peak") {
return {
label: locale === "en-US" ? "Post Peak" : "峰后确认",
tone: "amber",
};
}
if (row.trend_alignment) {
return {
label: locale === "en-US" ? "Trend" : "趋势确认",
tone: "amber",
};
}
return {
label: locale === "en-US" ? "Tradable" : "可交易",
tone: "green",
};
}
function ProbabilityPreview({
row,
locale,
}: {
row: ScanOpportunityRow;
locale: string;
}) {
const preview = Array.isArray(row.distribution_preview)
? row.distribution_preview.filter(
(item): item is DistributionPreviewPoint =>
Boolean(item && (item.label || item.value != null)),
)
: [];
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
if (!preview.length) {
const targetBase =
row.target_value ??
row.target_threshold ??
row.target_lower ??
row.target_upper ??
null;
const targetLabel =
targetBase != null
? formatTemperatureValue(Number(targetBase), tempSymbol)
: normalizeTemperatureLabel(row.target_label, tempSymbol) || "--";
preview.push({
label: targetLabel,
model_probability: row.model_event_probability,
market_probability: row.market_event_probability,
highlighted: true,
});
}
const peak =
preview.reduce<DistributionPreviewPoint | null>((best, item) => {
const probability = Number(item.model_probability ?? -1);
const bestProbability = Number(best?.model_probability ?? -1);
return probability > bestProbability ? item : best;
}, null) || preview.find((item) => item.highlighted) || preview[0];
const peakValue = peak?.value ?? row.peak_value ?? null;
const peakLabel =
peakValue != null
? formatTemperatureValue(Number(peakValue), tempSymbol)
: normalizeTemperatureLabel(peak?.label, tempSymbol) || "--";
const peakProbability =
peak?.model_probability != null
? Number(peak.model_probability) * 100
: row.peak_probability != null
? Number(row.peak_probability) * 100
: null;
const debLabel =
row.deb_prediction != null
? formatTemperatureValue(Number(row.deb_prediction), tempSymbol)
: "--";
const delta =
peakValue != null && row.deb_prediction != null
? Number(peakValue) - Number(row.deb_prediction)
: null;
const targetLabel =
normalizeTemperatureLabel(row.target_label, tempSymbol) ||
(row.target_value != null ? formatTemperatureValue(Number(row.target_value), tempSymbol) : "--");
const targetProbability =
row.model_event_probability != null ? Number(row.model_event_probability) * 100 : null;
return (
<div className="scan-distribution-preview">
<div className="scan-model-compare">
<div className="scan-model-primary">
<span>{locale === "en-US" ? "EMOS peak" : "EMOS峰值"}</span>
<strong>{peakLabel}</strong>
<em>{formatPercent(peakProbability)}</em>
</div>
<div className="scan-model-rows">
<span>
<b>DEB</b>
<strong>{debLabel}</strong>
<em>{delta == null ? "--" : formatSignedTemperatureDelta(delta, tempSymbol)}</em>
</span>
<span>
<b>{locale === "en-US" ? "Target" : "目标"}</b>
<strong>{targetLabel}</strong>
<em>EMOS {formatPercent(targetProbability)}</em>
</span>
</div>
</div>
</div>
);
}
export const OpportunityTable = React.memo(function OpportunityTable({
rows,
status,
stale,
staleReason,
loading,
selectedRowId,
onSelectRow,
}: {
rows: ScanOpportunityRow[];
status?: string | null;
stale?: boolean;
staleReason?: string | null;
loading?: boolean;
selectedRowId?: string | null;
onSelectRow?: (row: ScanOpportunityRow) => void;
}) {
const { locale } = useI18n();
const isEn = locale === "en-US";
const hasRows = rows.length > 0;
const scanInProgress =
loading || status === "partial" || status === "scanning";
if (!hasRows) {
const title =
scanInProgress
? isEn
? "Scanning markets"
: "正在扫描市场"
: status === "failed"
? isEn
? "Scan failed"
: "扫描失败"
: isEn
? "No tradable market right now"
: "当前暂无可交易市场";
const copy =
scanInProgress
? isEn
? "Waiting for the latest market snapshot. Existing data will stay on screen when available."
: "正在等待最新市场快照;如果有旧数据,会继续保留在页面上。"
: status === "failed"
? staleReason || (isEn ? "No valid market snapshot is available." : "当前没有可用的市场快照。")
: isEn
? "The current snapshot does not contain a tradable main signal."
: "当前快照里还没有可交易的主信号。";
return (
<div className="scan-table-shell empty">
<div className="scan-empty-state">
<div className="scan-empty-title">{title}</div>
<div className="scan-empty-copy">{copy}</div>
</div>
</div>
);
}
return (
<div className="scan-table-shell">
{stale ? (
<div className="scan-table-banner">
<strong>{isEn ? "Showing delayed snapshot" : "当前显示延迟快照"}</strong>
<span>{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}</span>
</div>
) : null}
<div className="scan-table-header">
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
<span>{isEn ? "EMOS / DEB" : "EMOS / DEB"}</span>
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
<span>{isEn ? "Edge" : "边际优势"}</span>
</div>
<div className="scan-table-body">
{rows.map((row) => {
const phaseMeta = getWindowPhaseMeta(row, locale);
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
const localizedCityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
);
const selected = selectedRowId === row.id;
return (
<button
key={row.id}
type="button"
className={`scan-table-row ${selected ? "selected" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<div className="scan-city-cell">
<div className="scan-city-copy">
<div className="scan-city-name">{localizedCityName}</div>
<div className="scan-city-sub">
{row.market_question || row.target_label || "--"}
</div>
</div>
</div>
<div className="scan-time-cell">
<div className="scan-time-main">{row.local_time || "--"}</div>
<div className={`scan-phase-badge ${phaseMeta.tone}`}>
{phaseMeta.label}
</div>
<div className="scan-time-remaining">
{formatWindowMinutes(row.remaining_window_minutes, locale)}
</div>
</div>
<ProbabilityPreview row={row} locale={locale} />
<div className="scan-trade-cell">
<div className={`scan-trade-main ${row.side === "no" ? "sell" : "buy"}`}>
{formatAction(row, locale, tempSymbol)}
</div>
<div className="scan-trade-sub">
{isEn ? "Buy" : "买价"} {row.ask != null ? `${Math.round(row.ask * 100)}¢` : "--"} ·{" "}
EMOS {formatPercent(row.model_probability != null ? row.model_probability * 100 : null)}
</div>
<div className="scan-trade-note">
{normalizeTemperatureLabel(row.target_label, tempSymbol) ||
row.market_direction ||
"--"}
</div>
</div>
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}>
{formatPercent(row.edge_percent, true)}
</div>
</button>
);
})}
</div>
</div>
);
});