Files
PolyWeather/frontend/components/dashboard/scan-terminal/city-decision-state.ts
T
2569718930@qq.com 7130f8cf2a Model city decision states before rendering cards
The city card had accumulated independent checks for AI status, market availability, stale observations, breakouts, peak timing, model consensus, and next-bulletin waits. This introduces a CityDecisionState builder so UI components consume one coherent recommendation, urgency, evidence quality, AI status, market status, badges, and primary reason.

Constraint: Keep the current card copy and badge priority behavior while moving decision-state ownership out of the component.

Rejected: Refactor calendar and opportunity lanes in the same commit | the shared model should land behind the city card first, then other views can migrate with smaller visual-risk diffs.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Tested: npm run build

Not-tested: Browser visual regression and dedicated unit tests for every decision-state combination.
2026-04-28 10:10:07 +08:00

241 lines
7.2 KiB
TypeScript

import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
export type CityDecisionUrgency = "now" | "soon" | "later" | "past";
export type CityDecisionRecommendation = "watch" | "wait" | "avoid" | "background";
export type CityDecisionEvidenceQuality = "fresh" | "mixed" | "stale";
export type CityDecisionAiStatus =
| "fast-ready"
| "deepseek-loading"
| "complete"
| "fallback";
export type CityDecisionMarketStatus = "ready" | "loading" | "unavailable";
export type StatusBadgeTone = "green" | "blue" | "amber" | "red" | "muted";
export type StatusBadge = {
label: string;
tone: StatusBadgeTone;
};
export type CityDecisionState = {
urgency: CityDecisionUrgency;
recommendation: CityDecisionRecommendation;
evidenceQuality: CityDecisionEvidenceQuality;
aiStatus: CityDecisionAiStatus;
aiStatusLabel: string;
aiStatusTone: StatusBadgeTone;
marketStatus: CityDecisionMarketStatus;
marketStatusTone: StatusBadgeTone;
badges: StatusBadge[];
primaryReason: string;
};
function uniqueStatusBadges(badges: Array<StatusBadge | null | undefined>) {
const seen = new Set<string>();
return badges.filter((badge): badge is StatusBadge => {
if (!badge?.label || seen.has(badge.label)) return false;
seen.add(badge.label);
return true;
});
}
function resolveAiStatus({
aiCityForecast,
aiForecast,
aiRuleEvidenceMode,
}: {
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
aiForecast: AiCityForecastState;
aiRuleEvidenceMode: boolean;
}): CityDecisionAiStatus {
if (aiRuleEvidenceMode || aiForecast.status === "failed") return "fallback";
if (aiForecast.status === "loading") return "deepseek-loading";
if (aiForecast.status === "ready" && aiCityForecast) return "complete";
return "fast-ready";
}
function resolveAiStatusView(aiStatus: CityDecisionAiStatus, isEn: boolean) {
if (aiStatus === "deepseek-loading") {
return {
label: isEn ? "Fast read ready" : "快速判断已完成",
tone: "blue" as const,
};
}
if (aiStatus === "complete") {
return {
label: isEn ? "AI read complete" : "AI 解读已完成",
tone: "green" as const,
};
}
if (aiStatus === "fallback") {
return {
label: isEn ? "Rule evidence" : "规则证据模式",
tone: "amber" as const,
};
}
return {
label: isEn ? "AI pending" : "AI 待返回",
tone: "muted" as const,
};
}
function resolveMarketTone(status: CityDecisionMarketStatus): StatusBadgeTone {
if (status === "ready") return "green";
if (status === "loading") return "blue";
return "muted";
}
export function buildCityDecisionState({
aiCityForecast,
aiForecast,
aiRuleEvidenceMode,
isEn,
isHkoObservation,
marketDecisionView,
modelHighlyConsistent,
needsNextBulletin,
observationStale,
observedHighBreak,
observedLowBreak,
observedLowLag,
peakHasPassed,
}: {
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
aiForecast: AiCityForecastState;
aiRuleEvidenceMode: boolean;
isEn: boolean;
isHkoObservation: boolean;
marketDecisionView: MarketDecisionView;
modelHighlyConsistent: boolean;
needsNextBulletin: boolean;
observationStale: boolean;
observedHighBreak: boolean;
observedLowBreak: boolean;
observedLowLag: boolean;
peakHasPassed: boolean;
}): CityDecisionState {
const aiStatus = resolveAiStatus({ aiCityForecast, aiForecast, aiRuleEvidenceMode });
const aiStatusView = resolveAiStatusView(aiStatus, isEn);
const marketStatus = marketDecisionView.status;
const marketStatusTone = resolveMarketTone(marketStatus);
const evidenceQuality: CityDecisionEvidenceQuality = observationStale
? "stale"
: aiStatus === "fallback" || marketStatus === "unavailable"
? "mixed"
: "fresh";
const urgency: CityDecisionUrgency = peakHasPassed
? "past"
: observedHighBreak
? "now"
: needsNextBulletin
? "soon"
: "later";
const recommendation: CityDecisionRecommendation = peakHasPassed
? "avoid"
: observationStale
? "background"
: needsNextBulletin
? "wait"
: "watch";
const primaryReason = observedHighBreak
? isEn
? "Worth watching now: observation has broken above the model range."
: "当前值得关注:实测已突破模型上沿。"
: peakHasPassed
? isEn
? "Avoid chasing now: peak window has passed; wait to confirm no new high."
: "当前不宜追高:峰值窗口已过,等待确认是否还有新高。"
: observationStale
? isEn
? "Use as background only: observation is stale and needs the next report."
: "当前仅作背景:观测已过旧,需要下一报文确认。"
: marketStatus === "unavailable"
? isEn
? "Weather evidence remains usable, but no tradable quote is available yet."
: "当前可参考天气:暂无可交易价格。"
: modelHighlyConsistent
? isEn
? "Worth watching now: models agree; wait for observation confirmation."
: "当前值得关注:模型高度一致,等待实测确认。"
: needsNextBulletin
? isEn
? "Wait for confirmation: the next bulletin should decide direction."
: "当前建议等待:下一报文更适合决定方向。"
: isEn
? "Watch the peak window and compare observations against the expected high."
: "当前重点:盯住峰值窗口,把实测与预计高点对照。";
const badges = uniqueStatusBadges([
observedHighBreak
? {
label: isEn ? "Observed breakout" : "实测突破",
tone: "red",
}
: null,
peakHasPassed
? {
label: isEn ? "Peak window passed" : "峰值窗口已过",
tone: "muted",
}
: null,
observationStale
? {
label: isEn
? isHkoObservation
? "HKO stale"
: "METAR stale"
: isHkoObservation
? "观测过旧"
: "METAR 过旧",
tone: "amber",
}
: null,
observedLowBreak
? {
label: isEn ? "Peak revised down" : "峰值下修",
tone: "blue",
}
: null,
aiStatus === "deepseek-loading"
? {
label: isEn ? "Fast read ready" : "快速判断已完成",
tone: aiStatusView.tone,
}
: null,
marketStatus === "unavailable"
? {
label: isEn ? "Market unavailable" : "市场价暂不可用",
tone: marketStatusTone,
}
: null,
modelHighlyConsistent
? {
label: isEn ? "Models agree" : "模型高度一致",
tone: "green",
}
: null,
observedLowLag || needsNextBulletin
? {
label: isEn ? "Wait next report" : "需要等待下一报文",
tone: "amber",
}
: null,
]).slice(0, 3);
return {
urgency,
recommendation,
evidenceQuality,
aiStatus,
aiStatusLabel: aiStatusView.label,
aiStatusTone: aiStatusView.tone,
marketStatus,
marketStatusTone,
badges,
primaryReason,
};
}