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.
241 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|