清理旧 Dashboard 全栈死代码:useDashboardStore、dashboardClient、22个关联组件
删除 6650+ 行死代码:useDashboardStore.tsx(1666)、dashboardClient.ts(576)、CitySidebar/DetailPanel/PanelSections/ProbabilityDistribution 及关联 CSS、9个 opportunity-* 模块、ModelEvidencePanel/AiPinnedCityCard/MobileDecisionCard/AiPinnedForecastView/useAiPinnedCityWorkspace。scan-root-styles 清理 6 个无用 CSS 导入。
This commit is contained in:
@@ -1,567 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
||||
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader";
|
||||
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard";
|
||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand";
|
||||
import {
|
||||
buildWeatherDecisionView,
|
||||
resolveExpectedHighCandidate,
|
||||
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
||||
import {
|
||||
getAiReadCopy,
|
||||
getCityLoadingCopy,
|
||||
} from "@/components/dashboard/scan-terminal/decision-copy";
|
||||
import { getPeakWindowLabel, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||
import {
|
||||
useAiCityForecast,
|
||||
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data";
|
||||
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getModelView } from "@/lib/model-utils";
|
||||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||
|
||||
function toFiniteDecisionNumber(value: unknown) {
|
||||
if (value == null || value === "") return null;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function getRowModelEntries(row: ScanOpportunityRow | null) {
|
||||
const sources = row?.model_cluster_sources;
|
||||
if (!sources || typeof sources !== "object") return [];
|
||||
return Object.entries(sources)
|
||||
.map(([name, value]) => [name, Number(value)] as const)
|
||||
.filter(([, value]) => Number.isFinite(value));
|
||||
}
|
||||
|
||||
function parseEpochMs(value: unknown) {
|
||||
if (value == null || value === "") return null;
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric > 1_000_000_000_000 ? numeric : numeric * 1000;
|
||||
const parsed = new Date(String(value));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
|
||||
}
|
||||
|
||||
function formatMetarReportTime(detail: CityDetail | null, report: string, isEn: boolean) {
|
||||
const offsetSeconds = Number(detail?.utc_offset_seconds);
|
||||
const epochMs =
|
||||
parseEpochMs(detail?.airport_current?.report_time) ??
|
||||
parseEpochMs(detail?.airport_current?.obs_time_epoch) ??
|
||||
parseEpochMs(detail?.airport_current?.obs_time) ??
|
||||
parseEpochMs(detail?.current?.report_time) ??
|
||||
parseEpochMs(detail?.current?.obs_time_epoch) ??
|
||||
parseEpochMs(detail?.current?.obs_time);
|
||||
if (epochMs != null) {
|
||||
const utc = new Date(epochMs);
|
||||
const zText = `${String(utc.getUTCHours()).padStart(2, "0")}:${String(
|
||||
utc.getUTCMinutes(),
|
||||
).padStart(2, "0")}Z`;
|
||||
if (Number.isFinite(offsetSeconds)) {
|
||||
const local = new Date(epochMs + offsetSeconds * 1000);
|
||||
const localText = `${String(local.getUTCHours()).padStart(2, "0")}:${String(
|
||||
local.getUTCMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
|
||||
}
|
||||
return zText;
|
||||
}
|
||||
|
||||
const rawToken = String(report || "").match(/\b(\d{2})(\d{2})(\d{2})Z\b/i);
|
||||
if (!rawToken) return "";
|
||||
const zText = `${rawToken[2]}:${rawToken[3]}Z`;
|
||||
if (!Number.isFinite(offsetSeconds)) return zText;
|
||||
const utcMinutes = Number(rawToken[2]) * 60 + Number(rawToken[3]);
|
||||
if (!Number.isFinite(utcMinutes)) return zText;
|
||||
const localMinutes = Math.round(
|
||||
((utcMinutes + offsetSeconds / 60) % 1440 + 1440) % 1440,
|
||||
);
|
||||
const localText = `${String(Math.floor(localMinutes / 60)).padStart(2, "0")}:${String(
|
||||
localMinutes % 60,
|
||||
).padStart(2, "0")}`;
|
||||
return isEn ? `${zText} / local ${localText}` : `${zText} / 当地 ${localText}`;
|
||||
}
|
||||
|
||||
function normalizeMetarReadTime(text: string, displayTime: string, isEn: boolean) {
|
||||
if (!text || !displayTime) return text;
|
||||
const timeLabel = isEn ? `report time ${displayTime}` : `报文时间 ${displayTime}`;
|
||||
return text
|
||||
.replace(/报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
|
||||
.replace(/report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, timeLabel)
|
||||
.replace(/\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi, `at ${displayTime}`);
|
||||
}
|
||||
|
||||
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||
const source = String(
|
||||
detail?.current?.settlement_source ||
|
||||
detail?.settlement_station?.settlement_source ||
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return source === "hko";
|
||||
}
|
||||
|
||||
function formatFreshnessAge(value: unknown, isEn: boolean) {
|
||||
const minutes = Number(value);
|
||||
if (!Number.isFinite(minutes) || minutes < 0) return "";
|
||||
if (minutes < 1) return isEn ? "just now" : "刚刚";
|
||||
if (minutes < 60) {
|
||||
const rounded = Math.max(1, Math.round(minutes));
|
||||
return isEn ? `${rounded}m ago` : `${rounded} 分钟前`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remaining = Math.round(minutes % 60);
|
||||
if (remaining <= 0) return isEn ? `${hours}h ago` : `${hours} 小时前`;
|
||||
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
|
||||
}
|
||||
|
||||
function formatUpdateTime(value: unknown, locale: string) {
|
||||
const epochMs = parseEpochMs(value);
|
||||
if (epochMs == null) return "";
|
||||
const date = new Date(epochMs);
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (locale === "en-US") {
|
||||
const day = sameDay
|
||||
? "today"
|
||||
: date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
return `${day} ${time} updated`;
|
||||
}
|
||||
const day = sameDay
|
||||
? "今日"
|
||||
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||
return `${day} ${time} 更新`;
|
||||
}
|
||||
|
||||
function buildObservationFreshnessValue({
|
||||
detail,
|
||||
displayTime,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
}: {
|
||||
detail: CityDetail | null;
|
||||
displayTime: string;
|
||||
isEn: boolean;
|
||||
isHkoObservation: boolean;
|
||||
}) {
|
||||
const stale = Boolean(
|
||||
detail?.metar_status?.stale_for_today ||
|
||||
detail?.airport_current?.stale_for_today ||
|
||||
detail?.current?.observation_status === "stale",
|
||||
);
|
||||
if (stale) {
|
||||
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
|
||||
}
|
||||
const ageLabel = formatFreshnessAge(
|
||||
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
|
||||
isEn,
|
||||
);
|
||||
if (ageLabel) return ageLabel;
|
||||
if (displayTime) return displayTime;
|
||||
return isEn ? "time pending" : "时间待确认";
|
||||
}
|
||||
|
||||
function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) {
|
||||
return (
|
||||
formatUpdateTime(detail?.updated_at, locale) ||
|
||||
(isEn ? "latest run loaded" : "已加载最新模型")
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPinnedCityCard({
|
||||
item,
|
||||
detail,
|
||||
row,
|
||||
locale,
|
||||
collapsed,
|
||||
removing,
|
||||
onRefreshCityDetail,
|
||||
onRemove,
|
||||
onToggleCollapsed,
|
||||
}: {
|
||||
item: AiPinnedCity;
|
||||
detail: CityDetail | null;
|
||||
row: ScanOpportunityRow | null;
|
||||
locale: string;
|
||||
collapsed: boolean;
|
||||
removing?: boolean;
|
||||
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
||||
onRemove: () => void;
|
||||
onToggleCollapsed: () => void;
|
||||
}) {
|
||||
const isEn = locale === "en-US";
|
||||
const displayName =
|
||||
detail?.display_name ||
|
||||
row?.city_display_name ||
|
||||
row?.display_name ||
|
||||
item.displayName ||
|
||||
item.cityName;
|
||||
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
|
||||
const modelView = detail ? getModelView(detail, detail.local_date) : null;
|
||||
const detailModelEntries = modelView
|
||||
? Object.entries(modelView.models || {})
|
||||
.map(([name, value]) => [name, Number(value)] as const)
|
||||
.filter(([, value]) => Number.isFinite(value))
|
||||
: [];
|
||||
const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row);
|
||||
const modelValues = modelEntries.map(([, value]) => value);
|
||||
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
|
||||
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
|
||||
const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null;
|
||||
const peakWindow =
|
||||
paceView?.peakWindowText ||
|
||||
(row ? getPeakWindowLabel(row) : null) ||
|
||||
"--";
|
||||
const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null;
|
||||
const isHkoObservation = isHkoObservationCity(detail);
|
||||
const displayAirportPrimary = getDisplayAirportPrimary(detail);
|
||||
const currentTemp =
|
||||
(isHkoObservation
|
||||
? detail?.current?.temp ?? row?.current_temp
|
||||
: displayAirportPrimary?.temp ??
|
||||
detail?.airport_current?.temp ??
|
||||
detail?.current?.temp ??
|
||||
row?.current_temp) ?? null;
|
||||
const debNumber = toFiniteDecisionNumber(deb);
|
||||
const currentTempNumber = toFiniteDecisionNumber(currentTemp);
|
||||
const modelRange =
|
||||
modelMin != null && modelMax != null
|
||||
? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}`
|
||||
: "--";
|
||||
const paceTone = paceView?.biasTone || "neutral";
|
||||
const paceText =
|
||||
paceView?.summary ||
|
||||
(isEn
|
||||
? "Waiting for intraday observations to compare against the DEB path."
|
||||
: "等待更多日内实测,用来对照 DEB 预测路径。");
|
||||
const report = isHkoObservation
|
||||
? ""
|
||||
: detail?.current?.raw_metar || detail?.airport_current?.raw_metar || "";
|
||||
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测";
|
||||
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations";
|
||||
const detailCityName = detail?.name || item.cityName;
|
||||
const [refreshingDetail, setRefreshingDetail] = useState(false);
|
||||
const { aiForecast, refreshAiForecast } = useAiCityForecast({
|
||||
detail,
|
||||
detailCityName,
|
||||
enabled: Boolean(detail),
|
||||
isEn,
|
||||
locale,
|
||||
report,
|
||||
});
|
||||
const isRefreshing = refreshingDetail || aiForecast.status === "loading";
|
||||
const [isCompactCard, setIsCompactCard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const media = window.matchMedia("(max-width: 820px)");
|
||||
const syncCompactMode = () => setIsCompactCard(media.matches);
|
||||
syncCompactMode();
|
||||
media.addEventListener("change", syncCompactMode);
|
||||
return () => media.removeEventListener("change", syncCompactMode);
|
||||
}, []);
|
||||
|
||||
const aiCityForecast = aiForecast.payload?.city_forecast || null;
|
||||
const localizedFinalJudgment =
|
||||
(isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh) ||
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedMetarRead =
|
||||
(isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh) ||
|
||||
"";
|
||||
const localizedReasoning =
|
||||
(isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh) ||
|
||||
"";
|
||||
const localizedModelNote =
|
||||
(isEn
|
||||
? aiCityForecast?.model_cluster_note_en
|
||||
: aiCityForecast?.model_cluster_note_zh) || "";
|
||||
const modelPreview = modelEntries
|
||||
.slice(0, 4)
|
||||
.map(([name, value]) => `${name} ${formatTemperatureValue(value, tempSymbol, { digits: 1 })}`)
|
||||
.join(isEn ? " / " : " / ");
|
||||
const localModelSupportNote = modelEntries.length
|
||||
? isEn
|
||||
? modelEntries.length <= 2
|
||||
? `Model support is sparse: only ${modelEntries.length} sources are available${modelPreview ? ` (${modelPreview})` : ""}, so the read should lean more on DEB path and ${observationSourceEn}.`
|
||||
: `Model support: ${modelEntries.length} sources cluster between ${modelRange}; ${modelPreview}.`
|
||||
: modelEntries.length <= 2
|
||||
? `多模型支撑偏少:当前只有 ${modelEntries.length} 个模型${modelPreview ? `(${modelPreview})` : ""},需要更重视 DEB 路径和${observationSourceZh}。`
|
||||
: `多模型支撑:${modelEntries.length} 个模型集中在 ${modelRange},代表模型为 ${modelPreview}。`
|
||||
: isEn
|
||||
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
|
||||
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}。`;
|
||||
const aiPredictedMax =
|
||||
aiForecast.status === "ready"
|
||||
? toFiniteDecisionNumber(aiCityForecast?.predicted_max)
|
||||
: null;
|
||||
const aiRangeLow =
|
||||
toFiniteDecisionNumber(aiCityForecast?.range_low) ??
|
||||
toFiniteDecisionNumber(row?.ai_predicted_low) ??
|
||||
modelMin;
|
||||
const aiRangeHigh =
|
||||
toFiniteDecisionNumber(aiCityForecast?.range_high) ??
|
||||
toFiniteDecisionNumber(row?.ai_predicted_high) ??
|
||||
modelMax;
|
||||
const aiConfidence =
|
||||
String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() || null;
|
||||
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
|
||||
aiPredictedMax,
|
||||
currentTemp: currentTempNumber,
|
||||
deb: debNumber,
|
||||
modelMax,
|
||||
modelMin,
|
||||
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
|
||||
});
|
||||
const decisionView = buildWeatherDecisionView({
|
||||
aiCityForecast,
|
||||
currentTemp: currentTempNumber,
|
||||
deb: debNumber,
|
||||
isEn,
|
||||
localModelSupportNote,
|
||||
modelEntries,
|
||||
modelMax,
|
||||
modelMin,
|
||||
paceTone,
|
||||
paceView,
|
||||
peakWindow,
|
||||
tempSymbol,
|
||||
});
|
||||
const expectedHighText =
|
||||
decisionExpectedHighNumber != null
|
||||
? formatTemperatureValue(decisionExpectedHighNumber, tempSymbol, { digits: 1 })
|
||||
: "--";
|
||||
const debText =
|
||||
debNumber != null
|
||||
? formatTemperatureValue(debNumber, tempSymbol, { digits: 1 })
|
||||
: "--";
|
||||
const aiMeta = aiCityForecast?._polyweather_meta || null;
|
||||
const guardReason = aiMeta?.deterministic_guard_reason || {};
|
||||
const observationStale = Boolean(
|
||||
detail?.metar_status?.stale_for_today ||
|
||||
detail?.airport_current?.stale_for_today ||
|
||||
detail?.current?.observation_status === "stale" ||
|
||||
guardReason.observation_stale,
|
||||
);
|
||||
const observedHighBreak = Boolean(
|
||||
guardReason.observed_high_break ||
|
||||
(currentTempNumber != null &&
|
||||
modelMax != null &&
|
||||
currentTempNumber > modelMax + 0.2),
|
||||
);
|
||||
const observedLowBreak = Boolean(guardReason.observed_low_break);
|
||||
const observedLowLag = Boolean(guardReason.observed_low_lag);
|
||||
const peakHasPassed = Boolean(
|
||||
guardReason.peak_has_passed ||
|
||||
["past", "post_peak", "after_peak"].includes(
|
||||
String((row as { window_phase?: string | null } | null)?.window_phase || "").toLowerCase(),
|
||||
),
|
||||
);
|
||||
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
|
||||
const modelHighlyConsistent =
|
||||
modelEntries.length >= 4 && modelSpread != null && modelSpread <= 2;
|
||||
const needsNextBulletin =
|
||||
!observationStale &&
|
||||
!observedHighBreak &&
|
||||
!observedLowBreak &&
|
||||
!peakHasPassed &&
|
||||
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
|
||||
const aiRuleEvidenceMode = Boolean(
|
||||
aiForecast.status === "failed" ||
|
||||
(aiForecast.status === "ready" && !aiCityForecast) ||
|
||||
aiForecast.payload?.degraded ||
|
||||
aiMeta?.fallback,
|
||||
);
|
||||
const aiReadCopy = getAiReadCopy({ isEn, isHkoObservation });
|
||||
const aiReadInProgressText = aiReadCopy.inProgress;
|
||||
const aiReadCompleteText = aiReadCopy.complete;
|
||||
const aiRuleEvidenceText = aiReadCopy.ruleEvidence;
|
||||
const decisionState = buildCityDecisionState({
|
||||
aiCityForecast,
|
||||
aiForecast,
|
||||
aiRuleEvidenceMode,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
modelHighlyConsistent,
|
||||
needsNextBulletin,
|
||||
observationStale,
|
||||
observedHighBreak,
|
||||
observedLowBreak,
|
||||
observedLowLag,
|
||||
peakHasPassed,
|
||||
});
|
||||
const dataFreshnessRows = [
|
||||
{
|
||||
label: isEn ? "Models" : "模型",
|
||||
value: buildModelFreshnessValue(detail, locale, isEn),
|
||||
tone: "fresh" as const,
|
||||
},
|
||||
];
|
||||
const freshnessSeparator = isEn ? ": " : ":";
|
||||
const localizedRisksRaw =
|
||||
(isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh) || [];
|
||||
const localizedRisks = Array.isArray(localizedRisksRaw)
|
||||
? localizedRisksRaw
|
||||
: localizedRisksRaw
|
||||
? [String(localizedRisksRaw)]
|
||||
: [];
|
||||
const aiBullets = [
|
||||
localizedMetarRead,
|
||||
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "",
|
||||
localizedModelNote || localModelSupportNote,
|
||||
...localizedRisks,
|
||||
].filter((line) => String(line || "").trim());
|
||||
const fallbackAiReason =
|
||||
(isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh) ||
|
||||
aiForecast.payload?.reason ||
|
||||
"";
|
||||
const collapseId = `ai-city-body-${normalizeCityKey(item.cityName) || item.addedAt}`;
|
||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
||||
const handleRefresh = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (refreshingDetail) return;
|
||||
setRefreshingDetail(true);
|
||||
void onRefreshCityDetail(detailCityName)
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
refreshAiForecast();
|
||||
setRefreshingDetail(false);
|
||||
});
|
||||
};
|
||||
const handleRemove = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
"scan-ai-city-card",
|
||||
isCompactCard && "scan-mobile-decision-card",
|
||||
collapsed && !isCompactCard && "collapsed",
|
||||
removing && "removing",
|
||||
)}
|
||||
tabIndex={-1}
|
||||
data-ai-status={decisionState.aiStatus}
|
||||
data-evidence-quality={decisionState.evidenceQuality}
|
||||
data-recommendation={decisionState.recommendation}
|
||||
data-urgency={decisionState.urgency}
|
||||
>
|
||||
{isCompactCard ? (
|
||||
<MobileDecisionCard
|
||||
aiBullets={aiBullets}
|
||||
aiCityForecast={aiCityForecast}
|
||||
aiConfidence={aiConfidence}
|
||||
aiForecast={aiForecast}
|
||||
aiPredictedMax={aiPredictedMax}
|
||||
aiRangeHigh={aiRangeHigh}
|
||||
aiRangeLow={aiRangeLow}
|
||||
aiReadCompleteText={aiReadCompleteText}
|
||||
aiReadInProgressText={aiReadInProgressText}
|
||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||
debPrediction={debNumber}
|
||||
decisionState={decisionState}
|
||||
detail={detail}
|
||||
displayName={displayName}
|
||||
expectedHighText={expectedHighText}
|
||||
fallbackAiReason={fallbackAiReason}
|
||||
isEn={isEn}
|
||||
isHkoObservation={isHkoObservation}
|
||||
isRefreshing={isRefreshing}
|
||||
localModelSupportNote={localModelSupportNote}
|
||||
localizedFinalJudgment={localizedFinalJudgment}
|
||||
onRefresh={handleRefresh}
|
||||
onRemove={handleRemove}
|
||||
peakWindow={peakWindow}
|
||||
removing={removing}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CityCardHeader
|
||||
aiStatusLabel={decisionState.aiStatusLabel}
|
||||
aiStatusTone={decisionState.aiStatusTone}
|
||||
collapseId={collapseId}
|
||||
collapsed={collapsed}
|
||||
debText={debText}
|
||||
detailLocalTime={detail?.local_time}
|
||||
displayName={displayName}
|
||||
expectedHighText={expectedHighText}
|
||||
isEn={isEn}
|
||||
isRefreshing={isRefreshing}
|
||||
modelRange={modelRange}
|
||||
onRefresh={handleRefresh}
|
||||
onRemove={handleRemove}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
peakWindow={peakWindow}
|
||||
removing={removing}
|
||||
rowLocalTime={row?.local_time}
|
||||
statusTags={decisionState.badges}
|
||||
/>
|
||||
|
||||
{detail && !collapsed ? (
|
||||
<div className="scan-ai-city-body" id={collapseId}>
|
||||
<WeatherDecisionBand
|
||||
decisionView={decisionView}
|
||||
decisionWhyText={decisionState.primaryReason}
|
||||
isEn={isEn}
|
||||
paceDeltaText={paceView?.deltaText || "--"}
|
||||
/>
|
||||
|
||||
<div className="scan-ai-city-analysis-grid">
|
||||
<AiCityTemperatureChart detail={detail} />
|
||||
<AiEvidencePanel
|
||||
aiBullets={aiBullets}
|
||||
aiCityForecast={aiCityForecast}
|
||||
aiConfidence={aiConfidence}
|
||||
aiForecast={aiForecast}
|
||||
aiPredictedMax={aiPredictedMax}
|
||||
aiRangeHigh={aiRangeHigh}
|
||||
aiRangeLow={aiRangeLow}
|
||||
aiReadCompleteText={aiReadCompleteText}
|
||||
aiReadInProgressText={aiReadInProgressText}
|
||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||
debPrediction={debNumber}
|
||||
fallbackAiReason={fallbackAiReason}
|
||||
isEn={isEn}
|
||||
isHkoObservation={isHkoObservation}
|
||||
localModelSupportNote={localModelSupportNote}
|
||||
localizedFinalJudgment={localizedFinalJudgment}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelEvidencePanel detail={detail} isEn={isEn} />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className="scan-ai-city-loading">
|
||||
<LoadingSignal
|
||||
title={loadingCopy.title}
|
||||
description={loadingCopy.description}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AiPinnedCityCard } from "@/components/dashboard/scan-terminal/AiPinnedCityCard";
|
||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import { findRowForCity, normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
||||
export function AiPinnedForecastView({
|
||||
items,
|
||||
rows,
|
||||
detailsByName,
|
||||
locale,
|
||||
onRefreshCityDetail,
|
||||
onRemoveCity,
|
||||
}: {
|
||||
items: AiPinnedCity[];
|
||||
rows: ScanOpportunityRow[];
|
||||
detailsByName: Record<string, CityDetail>;
|
||||
locale: string;
|
||||
onRefreshCityDetail: (cityName: string) => Promise<void>;
|
||||
onRemoveCity: (cityName: string) => void;
|
||||
}) {
|
||||
const isEn = locale === "en-US";
|
||||
const [expandedCityKey, setExpandedCityKey] = useState<string | null>(null);
|
||||
const [removingCities, setRemovingCities] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const cityCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const knownCityKeysRef = useRef<Set<string>>(new Set());
|
||||
const previousFirstCityKeyRef = useRef<string | null>(null);
|
||||
const removeTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const activeKeyList = items.map(
|
||||
(item) => normalizeCityKey(item.cityName) || item.cityName,
|
||||
);
|
||||
const activeKeys = new Set(activeKeyList);
|
||||
const firstCityKey = activeKeyList[0] ?? null;
|
||||
const hasNewCity = activeKeyList.some((key) => !knownCityKeysRef.current.has(key));
|
||||
const firstCityChanged = firstCityKey !== previousFirstCityKeyRef.current;
|
||||
|
||||
setExpandedCityKey((current) => {
|
||||
if (!firstCityKey) return null;
|
||||
if (hasNewCity || firstCityChanged) return firstCityKey;
|
||||
if (current && activeKeys.has(current)) return current;
|
||||
return firstCityKey;
|
||||
});
|
||||
Object.keys(cityCardRefs.current).forEach((key) => {
|
||||
if (!activeKeys.has(key)) {
|
||||
delete cityCardRefs.current[key];
|
||||
}
|
||||
});
|
||||
knownCityKeysRef.current = activeKeys;
|
||||
previousFirstCityKeyRef.current = firstCityKey;
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
removeTimersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeCityWithMotion = useCallback(
|
||||
(item: AiPinnedCity, stableKey: string) => {
|
||||
if (removeTimersRef.current.has(stableKey)) return;
|
||||
setRemovingCities((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(stableKey);
|
||||
return next;
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
onRemoveCity(item.cityName);
|
||||
setRemovingCities((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(stableKey);
|
||||
return next;
|
||||
});
|
||||
removeTimersRef.current.delete(stableKey);
|
||||
}, 260);
|
||||
removeTimersRef.current.set(stableKey, timer);
|
||||
},
|
||||
[onRemoveCity],
|
||||
);
|
||||
|
||||
const scrollToCity = useCallback((stableKey: string) => {
|
||||
cityCardRefs.current[stableKey]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const focusCity = useCallback((stableKey: string) => {
|
||||
setExpandedCityKey(stableKey);
|
||||
window.requestAnimationFrame(() => scrollToCity(stableKey));
|
||||
}, [scrollToCity]);
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="scan-ai-workspace empty">
|
||||
<div className="scan-empty-state">
|
||||
<div className="scan-empty-title">
|
||||
{isEn ? "Select a city from the market list" : "从市场列表选择城市"}
|
||||
</div>
|
||||
<div className="scan-empty-copy">
|
||||
{isEn
|
||||
? "Selected cities will appear here as deep analysis blocks."
|
||||
: "选中的城市会加入深度分析页,并保留为城市分析区块。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scan-ai-workspace">
|
||||
<div className="scan-ai-workspace-head">
|
||||
<div>
|
||||
<span>{isEn ? "Selected city workspace" : "城市分析工作区"}</span>
|
||||
<strong>
|
||||
{isEn
|
||||
? `${items.length} cities under deep analysis`
|
||||
: `${items.length} 个城市正在深度分析`}
|
||||
</strong>
|
||||
</div>
|
||||
<p>
|
||||
{isEn
|
||||
? "Market-list selections add cities here. City analysis stays here until you remove it."
|
||||
: "市场列表选择会把城市加入这里;城市分析会保留,直到你手动移除。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="scan-ai-city-jumpbar" aria-label={isEn ? "Selected city shortcuts" : "已选城市快捷跳转"}>
|
||||
{items.map((item) => {
|
||||
const stableKey = normalizeCityKey(item.cityName) || item.cityName;
|
||||
return (
|
||||
<button
|
||||
key={stableKey}
|
||||
type="button"
|
||||
onClick={() => focusCity(stableKey)}
|
||||
>
|
||||
{item.cityName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="scan-ai-city-stack">
|
||||
{items.map((item) => {
|
||||
const detail = findDetailForCity(detailsByName, item.cityName);
|
||||
const row = findRowForCity(rows, item.cityName);
|
||||
const key = normalizeCityKey(item.cityName);
|
||||
const stableKey = key || item.cityName;
|
||||
return (
|
||||
<div
|
||||
key={stableKey}
|
||||
ref={(node) => {
|
||||
cityCardRefs.current[stableKey] = node;
|
||||
}}
|
||||
className="scan-ai-city-anchor"
|
||||
>
|
||||
<AiPinnedCityCard
|
||||
item={item}
|
||||
detail={detail}
|
||||
row={row}
|
||||
locale={locale}
|
||||
collapsed={expandedCityKey !== stableKey}
|
||||
removing={removingCities.has(stableKey)}
|
||||
onRefreshCityDetail={onRefreshCityDetail}
|
||||
onRemove={() => removeCityWithMotion(item, stableKey)}
|
||||
onToggleCollapsed={() => {
|
||||
setExpandedCityKey((current) =>
|
||||
current === stableKey ? null : stableKey,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart";
|
||||
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel";
|
||||
import {
|
||||
CityStatusTags,
|
||||
type CityStatusTag,
|
||||
type StatusTone,
|
||||
} from "@/components/dashboard/scan-terminal/CityStatusTags";
|
||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel";
|
||||
import type { CityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
||||
import {
|
||||
getCityLoadingCopy,
|
||||
getMobileDecisionCopy,
|
||||
} from "@/components/dashboard/scan-terminal/decision-copy";
|
||||
import type {
|
||||
AiCityForecastPayload,
|
||||
AiCityForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
export function MobileDecisionCard({
|
||||
aiBullets,
|
||||
aiCityForecast,
|
||||
aiConfidence,
|
||||
aiForecast,
|
||||
aiPredictedMax,
|
||||
aiRangeHigh,
|
||||
aiRangeLow,
|
||||
aiReadCompleteText,
|
||||
aiReadInProgressText,
|
||||
aiRuleEvidenceMode,
|
||||
aiRuleEvidenceText,
|
||||
debPrediction,
|
||||
decisionState,
|
||||
detail,
|
||||
displayName,
|
||||
expectedHighText,
|
||||
fallbackAiReason,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
isRefreshing,
|
||||
localModelSupportNote,
|
||||
localizedFinalJudgment,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
peakWindow,
|
||||
removing,
|
||||
tempSymbol,
|
||||
}: {
|
||||
aiBullets: string[];
|
||||
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
|
||||
aiConfidence: string | null;
|
||||
aiForecast: AiCityForecastState;
|
||||
aiPredictedMax: number | null;
|
||||
aiRangeHigh: number | null;
|
||||
aiRangeLow: number | null;
|
||||
aiReadCompleteText: string;
|
||||
aiReadInProgressText: string;
|
||||
aiRuleEvidenceMode: boolean;
|
||||
aiRuleEvidenceText: string;
|
||||
debPrediction: number | null;
|
||||
decisionState: CityDecisionState;
|
||||
detail: CityDetail | null;
|
||||
displayName: string;
|
||||
expectedHighText: string;
|
||||
fallbackAiReason: string;
|
||||
isEn: boolean;
|
||||
isHkoObservation: boolean;
|
||||
isRefreshing: boolean;
|
||||
localModelSupportNote: string;
|
||||
localizedFinalJudgment: string;
|
||||
onRefresh: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
peakWindow: string;
|
||||
removing?: boolean;
|
||||
tempSymbol: string;
|
||||
}) {
|
||||
const copy = getMobileDecisionCopy(isEn);
|
||||
const loadingCopy = getCityLoadingCopy({ isEn, isHkoObservation });
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [chartOpen, setChartOpen] = useState(false);
|
||||
const statusTags: CityStatusTag[] = decisionState.badges.length
|
||||
? decisionState.badges
|
||||
: [{ label: decisionState.aiStatusLabel, tone: decisionState.aiStatusTone as StatusTone }];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="scan-mobile-decision-head">
|
||||
<div>
|
||||
<span className="scan-ai-city-kicker">
|
||||
{isEn ? "Mobile action card" : "移动端行动卡"}
|
||||
</span>
|
||||
<h3>{displayName}</h3>
|
||||
</div>
|
||||
<div className="scan-ai-city-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="scan-ai-city-icon-button"
|
||||
onClick={onRefresh}
|
||||
aria-label={`${copy.refresh} ${displayName}`}
|
||||
title={copy.refresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="scan-ai-city-icon-button danger"
|
||||
onClick={onRemove}
|
||||
aria-label={`${copy.remove} ${displayName}`}
|
||||
title={copy.remove}
|
||||
disabled={removing}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="scan-mobile-decision-metrics">
|
||||
<span className="primary">
|
||||
<small>{copy.expectedHigh}</small>
|
||||
<b>{expectedHighText}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{copy.peakWindow}</small>
|
||||
<b>{peakWindow}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="scan-mobile-decision-reason">{decisionState.primaryReason}</p>
|
||||
<CityStatusTags tags={statusTags} />
|
||||
|
||||
{!detail ? (
|
||||
<div className="scan-ai-city-loading">
|
||||
<LoadingSignal
|
||||
title={loadingCopy.title}
|
||||
description={loadingCopy.description}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="scan-mobile-decision-folds">
|
||||
<AiEvidencePanel
|
||||
aiBullets={aiBullets}
|
||||
aiCityForecast={aiCityForecast}
|
||||
aiConfidence={aiConfidence}
|
||||
aiForecast={aiForecast}
|
||||
aiPredictedMax={aiPredictedMax}
|
||||
aiRangeHigh={aiRangeHigh}
|
||||
aiRangeLow={aiRangeLow}
|
||||
aiReadCompleteText={aiReadCompleteText}
|
||||
aiReadInProgressText={aiReadInProgressText}
|
||||
aiRuleEvidenceMode={aiRuleEvidenceMode}
|
||||
aiRuleEvidenceText={aiRuleEvidenceText}
|
||||
debPrediction={debPrediction}
|
||||
fallbackAiReason={fallbackAiReason}
|
||||
isEn={isEn}
|
||||
isHkoObservation={isHkoObservation}
|
||||
localModelSupportNote={localModelSupportNote}
|
||||
localizedFinalJudgment={localizedFinalJudgment}
|
||||
tempSymbol={tempSymbol}
|
||||
/>
|
||||
|
||||
<details
|
||||
className="scan-ai-city-section scan-mobile-fold"
|
||||
open={modelOpen}
|
||||
onToggle={(event) => setModelOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="scan-ai-city-section-title">{copy.modelEvidence}</summary>
|
||||
{modelOpen ? <ModelEvidencePanel detail={detail} isEn={isEn} /> : null}
|
||||
</details>
|
||||
|
||||
<details
|
||||
className="scan-ai-city-section scan-mobile-fold"
|
||||
open={chartOpen}
|
||||
onToggle={(event) => setChartOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="scan-ai-city-section-title">{copy.chart}</summary>
|
||||
{chartOpen ? <AiCityTemperatureChart detail={detail} /> : null}
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ModelForecast } from "@/components/dashboard/PanelSections";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
export function ModelEvidencePanel({
|
||||
detail,
|
||||
isEn,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
isEn: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="scan-ai-city-section models">
|
||||
<div className="scan-ai-city-section-title">
|
||||
{isEn ? "Evidence · multi-model support" : "证据 · 多模型支撑"}
|
||||
</div>
|
||||
<ModelForecast detail={detail} targetDate={detail.local_date} hideTitle />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import {
|
||||
findDetailForCity,
|
||||
isFullEnoughForDeepAnalysis,
|
||||
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import {
|
||||
findRowForCity,
|
||||
normalizeCityKey,
|
||||
prettifyCityName,
|
||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||
|
||||
type DashboardStore = ReturnType<typeof useDashboardStore>;
|
||||
|
||||
export function useAiPinnedCityWorkspace({
|
||||
locale,
|
||||
store,
|
||||
timeSortedRows,
|
||||
}: {
|
||||
locale: string;
|
||||
store: DashboardStore;
|
||||
timeSortedRows: ScanOpportunityRow[];
|
||||
}) {
|
||||
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
||||
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
||||
const aiHydrationQueueRef = useRef<string[]>([]);
|
||||
const aiHydrationRunningRef = useRef(false);
|
||||
const aiHydrationRetriesRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const runAiHydrationQueue = useCallback(async () => {
|
||||
if (aiHydrationRunningRef.current) return;
|
||||
aiHydrationRunningRef.current = true;
|
||||
try {
|
||||
while (aiHydrationQueueRef.current.length > 0) {
|
||||
const nextCity = aiHydrationQueueRef.current.shift();
|
||||
const key = normalizeCityKey(nextCity || "");
|
||||
if (!nextCity || !key) continue;
|
||||
try {
|
||||
const detail = await store.ensureCityDetail(
|
||||
nextCity,
|
||||
false,
|
||||
"full",
|
||||
);
|
||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
||||
if (retries >= 3) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationRetriesRef.current.delete(key);
|
||||
} else {
|
||||
aiHydrationRetriesRef.current.set(key, retries + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
||||
if (retries >= 3) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationRetriesRef.current.delete(key);
|
||||
} else {
|
||||
aiHydrationRetriesRef.current.set(key, retries + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
aiHydrationRunningRef.current = false;
|
||||
if (aiHydrationQueueRef.current.length > 0) {
|
||||
void runAiHydrationQueue();
|
||||
}
|
||||
}
|
||||
}, [store.ensureCityDetail]);
|
||||
|
||||
const queueAiFullHydration = useCallback(
|
||||
(cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||
aiFullHydrationRef.current.add(key);
|
||||
aiHydrationQueueRef.current.push(cityName);
|
||||
void runAiHydrationQueue();
|
||||
},
|
||||
[runAiHydrationQueue],
|
||||
);
|
||||
|
||||
const addAiPinnedCity = useCallback((cityName: string) => {
|
||||
const cleanName = String(cityName || "").trim();
|
||||
const key = normalizeCityKey(cleanName);
|
||||
if (!key) return;
|
||||
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
||||
const prettyName = prettifyCityName(cleanName);
|
||||
const displayName =
|
||||
matchedRow?.city_display_name ||
|
||||
matchedRow?.display_name ||
|
||||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
||||
prettyName ||
|
||||
cleanName;
|
||||
// Clear the hydration guard so that re-selecting this city always
|
||||
// triggers a fresh hydration attempt (fixes second-city loading failure).
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationRetriesRef.current.delete(key);
|
||||
setAiPinnedCities((current) => {
|
||||
const existing = current.findIndex(
|
||||
(item) => normalizeCityKey(item.cityName) === key,
|
||||
);
|
||||
const nextItem = {
|
||||
cityName: matchedRow?.city || cleanName,
|
||||
displayName,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
if (existing >= 0) {
|
||||
const next = [...current];
|
||||
next[existing] = { ...next[existing], ...nextItem };
|
||||
return [
|
||||
next[existing],
|
||||
...next.filter((_, index) => index !== existing),
|
||||
];
|
||||
}
|
||||
return [nextItem, ...current].slice(0, 8);
|
||||
});
|
||||
queueAiFullHydration(matchedRow?.city || cleanName);
|
||||
}, [locale, queueAiFullHydration, timeSortedRows]);
|
||||
|
||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
||||
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
||||
);
|
||||
setAiPinnedCities((current) =>
|
||||
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshAiPinnedCityDetail = useCallback(
|
||||
async (cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
if (key) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
await store.ensureCityDetail(cityName, true, "full");
|
||||
},
|
||||
[store.ensureCityDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
aiPinnedCities.forEach((item) => {
|
||||
const key = normalizeCityKey(item.cityName);
|
||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||
const retries = aiHydrationRetriesRef.current.get(key) || 0;
|
||||
if (retries >= 3) return;
|
||||
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
||||
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
||||
if (!needsFullHydration) return;
|
||||
queueAiFullHydration(item.cityName);
|
||||
});
|
||||
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
||||
|
||||
return {
|
||||
addAiPinnedCity,
|
||||
aiPinnedCities,
|
||||
refreshAiPinnedCityDetail,
|
||||
removeAiPinnedCity,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user