2b1d7c0b65
The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries. Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement. Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files. Tested: npm run build; npm run test:business; git diff --check Not-tested: Browser visual smoke test after push
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
|
import {
|
|
formatTemperatureValue,
|
|
normalizeTemperatureSymbol,
|
|
} from "@/lib/temperature-utils";
|
|
import {
|
|
formatAirportReportRead,
|
|
formatAirportWeatherRead,
|
|
} from "./opportunity-airport-read";
|
|
import { formatMinuteSpan } from "./opportunity-format";
|
|
import { getTargetRange } from "./opportunity-target";
|
|
|
|
export type ObservationPoint = { time?: string; temp?: number | null };
|
|
|
|
export function normalizeObservationPoints(points?: ObservationPoint[] | null) {
|
|
if (!Array.isArray(points)) return [];
|
|
return points
|
|
.map((point) => ({
|
|
time: String(point?.time || "").trim(),
|
|
temp:
|
|
point?.temp != null && Number.isFinite(Number(point.temp))
|
|
? Number(point.temp)
|
|
: null,
|
|
}))
|
|
.filter((point): point is { time: string; temp: number } =>
|
|
Boolean(point.time && point.temp != null),
|
|
)
|
|
.sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
|
|
}
|
|
|
|
export function getObservationSortMinutes(time: string) {
|
|
const parsed = Date.parse(time);
|
|
if (Number.isFinite(parsed)) {
|
|
const date = new Date(parsed);
|
|
return date.getUTCHours() * 60 + date.getUTCMinutes();
|
|
}
|
|
const match = time.match(/(\d{1,2}):(\d{2})/);
|
|
if (!match) return Number.MAX_SAFE_INTEGER;
|
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
}
|
|
|
|
export function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
|
|
const isEn = locale === "en-US";
|
|
const phase = String(row.window_phase || "").toLowerCase();
|
|
const label = String(row.peak_window_label || "").trim();
|
|
const untilStart =
|
|
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
|
|
? Number(row.minutes_until_peak_start)
|
|
: null;
|
|
const untilEnd =
|
|
row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
|
|
? Number(row.minutes_until_peak_end)
|
|
: null;
|
|
const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
|
|
if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
|
|
return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`;
|
|
}
|
|
if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
|
|
return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
|
|
}
|
|
if (untilStart != null && untilStart > 0) {
|
|
return isEn
|
|
? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
|
|
: `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
|
|
}
|
|
if (phase === "early_today" || phase === "setup_today") {
|
|
return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
|
|
}
|
|
return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null;
|
|
}
|
|
|
|
export function firstNonEmptyPoints(...groups: Array<ReturnType<typeof normalizeObservationPoints>>) {
|
|
return groups.find((group) => group.length > 0) || [];
|
|
}
|
|
|
|
export function getMetarObservationContext(
|
|
row: ScanOpportunityRow,
|
|
detail: CityDetail | null,
|
|
) {
|
|
const context = row.metar_context || {};
|
|
const metarToday = firstNonEmptyPoints(
|
|
normalizeObservationPoints(context.today_obs),
|
|
normalizeObservationPoints(row.metar_today_obs),
|
|
);
|
|
const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
|
|
const metarRecent = firstNonEmptyPoints(
|
|
normalizeObservationPoints(context.recent_obs),
|
|
normalizeObservationPoints(row.metar_recent_obs),
|
|
);
|
|
const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
|
|
const settlementToday = firstNonEmptyPoints(
|
|
normalizeObservationPoints(context.settlement_today_obs),
|
|
normalizeObservationPoints(row.settlement_today_obs),
|
|
);
|
|
const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
|
|
|
|
const primaryPoints =
|
|
metarToday.length
|
|
? metarToday
|
|
: detailMetarToday.length
|
|
? detailMetarToday
|
|
: metarRecent.length
|
|
? metarRecent
|
|
: detailMetarRecent.length
|
|
? detailMetarRecent
|
|
: settlementToday.length
|
|
? settlementToday
|
|
: detailSettlementToday;
|
|
const trendPoints =
|
|
(metarRecent.length
|
|
? metarRecent
|
|
: detailMetarRecent.length
|
|
? detailMetarRecent
|
|
: primaryPoints.slice(-4));
|
|
const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
|
|
const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
|
|
const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
|
|
(best, point) => (!best || point.temp >= best.temp ? point : best),
|
|
null,
|
|
);
|
|
const trendFirst = trendPoints[0] || null;
|
|
const trendLast = trendPoints[trendPoints.length - 1] || null;
|
|
const trendDelta =
|
|
context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
|
|
? Number(context.trend_delta)
|
|
: trendFirst && trendLast && trendPoints.length >= 2
|
|
? trendLast.temp - trendFirst.temp
|
|
: null;
|
|
const lastTemp =
|
|
explicitLast != null && Number.isFinite(Number(explicitLast))
|
|
? Number(explicitLast)
|
|
: lastPoint?.temp ?? null;
|
|
const maxTemp =
|
|
context.max_temp != null && Number.isFinite(Number(context.max_temp))
|
|
? Number(context.max_temp)
|
|
: maxPoint?.temp ?? null;
|
|
const stale =
|
|
context.stale_for_today === true ||
|
|
row.metar_status?.stale_for_today === true ||
|
|
detail?.metar_status?.stale_for_today === true;
|
|
|
|
return {
|
|
points: primaryPoints,
|
|
lastTime: String(context.last_time || lastPoint?.time || ""),
|
|
lastTemp,
|
|
maxTime: String(context.max_time || maxPoint?.time || ""),
|
|
maxTemp,
|
|
trendDelta,
|
|
stale,
|
|
station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
|
|
};
|
|
}
|
|
|
|
export function getMetarGate(
|
|
row: ScanOpportunityRow,
|
|
detail: CityDetail | null,
|
|
locale: string,
|
|
tempSymbol?: string | null,
|
|
) {
|
|
const isEn = locale === "en-US";
|
|
const side = String(row.side || "").toLowerCase();
|
|
const selectedDate = String(row.selected_date || "");
|
|
const localDate = String(row.local_date || detail?.local_date || "");
|
|
const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
|
|
if (futureContract) return null;
|
|
|
|
const obs = getMetarObservationContext(row, detail);
|
|
const evidence: string[] = [];
|
|
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|
const peakTiming = formatPeakWindowTiming(row, locale);
|
|
const airportReport = formatAirportReportRead(row, detail, locale, unit);
|
|
const airportWeatherRead = formatAirportWeatherRead(row, detail, locale);
|
|
if (peakTiming) evidence.push(peakTiming);
|
|
if (airportReport) evidence.push(airportReport);
|
|
if (airportWeatherRead) evidence.push(airportWeatherRead);
|
|
if (obs.lastTemp != null) {
|
|
evidence.push(
|
|
`${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`,
|
|
);
|
|
}
|
|
if (obs.maxTemp != null) {
|
|
evidence.push(
|
|
`${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
|
|
);
|
|
}
|
|
if (obs.trendDelta != null) {
|
|
evidence.push(
|
|
`${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
|
|
);
|
|
}
|
|
if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
|
|
|
|
if (obs.stale || !obs.points.length || obs.maxTemp == null) {
|
|
return {
|
|
decision: "downgrade" as const,
|
|
reason: isEn
|
|
? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
|
|
: "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
|
|
evidence,
|
|
};
|
|
}
|
|
|
|
const { lower, upper } = getTargetRange(row);
|
|
if (lower == null && upper == null) {
|
|
return {
|
|
decision: "watchlist" as const,
|
|
reason: isEn
|
|
? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
|
|
: "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
|
|
evidence,
|
|
};
|
|
}
|
|
|
|
const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
|
|
const trendDelta = obs.trendDelta;
|
|
const isNotRising = trendDelta != null && trendDelta <= epsilon;
|
|
const isFalling = trendDelta != null && trendDelta <= -epsilon;
|
|
const phase = String(row.window_phase || "").toLowerCase();
|
|
const remaining =
|
|
row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
|
|
? Number(row.remaining_window_minutes)
|
|
: null;
|
|
const minutesUntilPeakStart =
|
|
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
|
|
? Number(row.minutes_until_peak_start)
|
|
: null;
|
|
const lateWindow =
|
|
phase === "active_peak" ||
|
|
phase === "post_peak" ||
|
|
(remaining != null && remaining <= 180);
|
|
const beforePeak =
|
|
phase === "early_today" ||
|
|
phase === "setup_today" ||
|
|
phase === "tomorrow" ||
|
|
phase === "week_ahead" ||
|
|
(minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
|
|
const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
|
|
const belowLower = lower != null && obs.maxTemp < lower - epsilon;
|
|
const insideBucket =
|
|
(lower == null || obs.maxTemp >= lower - epsilon) &&
|
|
(upper == null || obs.maxTemp <= upper + epsilon);
|
|
|
|
if (side === "no") {
|
|
if (aboveUpper) {
|
|
return {
|
|
decision: "approve" as const,
|
|
reason: isEn
|
|
? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
|
|
: "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
|
|
evidence,
|
|
};
|
|
}
|
|
if (belowLower && (lateWindow || isFalling || isNotRising)) {
|
|
if (beforePeak && !lateWindow) {
|
|
return {
|
|
decision: "watchlist" as const,
|
|
reason: isEn
|
|
? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
|
|
: "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
|
|
evidence,
|
|
};
|
|
}
|
|
return {
|
|
decision: "approve" as const,
|
|
reason: isEn
|
|
? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
|
|
: "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
|
|
evidence,
|
|
};
|
|
}
|
|
if (insideBucket && lateWindow && isNotRising) {
|
|
return {
|
|
decision: "downgrade" as const,
|
|
reason: isEn
|
|
? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
|
|
: "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
|
|
evidence,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (side === "yes") {
|
|
if (aboveUpper) {
|
|
return {
|
|
decision: "veto" as const,
|
|
reason: isEn
|
|
? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
|
|
: "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
|
|
evidence,
|
|
};
|
|
}
|
|
if (belowLower && (lateWindow || isFalling || isNotRising)) {
|
|
if (beforePeak && !lateWindow) {
|
|
return {
|
|
decision: "watchlist" as const,
|
|
reason: isEn
|
|
? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
|
|
: "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
|
|
evidence,
|
|
};
|
|
}
|
|
return {
|
|
decision: "downgrade" as const,
|
|
reason: isEn
|
|
? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
|
|
: "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
|
|
evidence,
|
|
};
|
|
}
|
|
if (insideBucket) {
|
|
return {
|
|
decision: "approve" as const,
|
|
reason: isEn
|
|
? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
|
|
: "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
|
|
evidence,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
decision: "watchlist" as const,
|
|
reason: isEn
|
|
? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
|
|
: "METAR 还没有给出干净的最终确认,AI 暂列观察。",
|
|
evidence,
|
|
};
|
|
}
|