feat: implement ScanTerminalDashboard with AI-driven city forecast streaming and opportunity tracking

This commit is contained in:
2569718930@qq.com
2026-04-26 08:12:31 +08:00
parent 763f131850
commit 1595f4a92e
5 changed files with 115 additions and 31 deletions
@@ -1491,9 +1491,20 @@ export function ForecastTable() {
const store = useDashboardStore();
const { data } = useCityData();
const { locale, t } = useI18n();
const daily = useMemo(() => {
if (!data) return [];
const rawDaily = Array.isArray(data.forecast?.daily)
? data.forecast?.daily || []
: [];
const seen = new Set<string>();
return rawDaily.filter((day) => {
const date = String(day?.date || "").trim();
if (!date || seen.has(date)) return false;
seen.add(date);
return true;
});
}, [data]);
if (!data) return null;
const daily = data.forecast?.daily || [];
const isSparseDaily = daily.length <= 1;
const isForecastCompleting =
store.loadingState.cityDetail &&
@@ -1525,7 +1536,9 @@ export function ForecastTable() {
) : (
daily
.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
const isToday = data.local_date
? day.date === data.local_date
: index === 0;
const isSelected =
(isToday &&
store.forecastModalMode === "today" &&
@@ -1635,7 +1635,7 @@ function ScanTerminalScreen() {
useEffect(() => {
if (!activeDetailRow) return;
if (!store.cityDetailsByName[activeDetailRow.city]) {
if (!findDetailForCity(store.cityDetailsByName, activeDetailRow.city)) {
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
}
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
@@ -1765,7 +1765,7 @@ function ScanTerminalScreen() {
const selectedCityKey = normalizeCityKey(store.selectedCity);
const rowCityKey = normalizeCityKey(cityName);
const hasCachedDetail =
Boolean(store.cityDetailsByName[cityName]) ||
Boolean(findDetailForCity(store.cityDetailsByName, cityName)) ||
Object.values(store.cityDetailsByName).some((detail) =>
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
);
+50 -11
View File
@@ -285,7 +285,33 @@ function countAvailableModels(
function countForecastDays(detail?: CityDetail | null): number {
const daily = detail?.forecast?.daily;
return Array.isArray(daily) ? daily.length : 0;
if (!Array.isArray(daily)) return 0;
return new Set(
daily
.map((day) => String(day?.date || "").trim())
.filter(Boolean),
).size;
}
function normalizeCityLookupKey(value?: string | null): string {
return String(value || "").trim().toLowerCase();
}
function findCachedCityDetail(
detailsByName: Record<string, CityDetail>,
cityName?: string | null,
) {
const key = normalizeCityLookupKey(cityName);
if (!key) return null;
return (
detailsByName[cityName || ""] ||
Object.entries(detailsByName).find(([storedName, detail]) =>
[storedName, detail?.name, detail?.display_name].some(
(value) => normalizeCityLookupKey(value) === key,
),
)?.[1] ||
null
);
}
function hasSparseModelCoverage(
@@ -402,10 +428,21 @@ function pickRicherForecast(
currentValue: CityDetail["forecast"] | undefined,
incomingValue: CityDetail["forecast"] | undefined,
) {
return countForecastDays({ forecast: incomingValue } as CityDetail) >=
const picked = countForecastDays({ forecast: incomingValue } as CityDetail) >=
countForecastDays({ forecast: currentValue } as CityDetail)
? incomingValue || currentValue
: currentValue;
if (!picked?.daily || !Array.isArray(picked.daily)) return picked;
const seen = new Set<string>();
return {
...picked,
daily: picked.daily.filter((day) => {
const date = String(day?.date || "").trim();
if (!date || seen.has(date)) return false;
seen.add(date);
return true;
}),
};
}
function pickPreferredNearbyStations(
@@ -562,7 +599,9 @@ export function DashboardStoreProvider({
const citiesRef = useRef<CityListItem[]>([]);
const citySummariesRef = useRef<Record<string, CitySummary>>({});
const selectedCityRef = useRef<string | null>(null);
const selectedDetail = selectedCity ? cityDetailsByName[selectedCity] || null : null;
const selectedDetail = selectedCity
? findCachedCityDetail(cityDetailsByName, selectedCity)
: null;
useEffect(() => {
if (proAccess.loading) return;
if (!proAccess.authenticated || !proAccess.subscriptionActive) {
@@ -635,7 +674,7 @@ export function DashboardStoreProvider({
force = false,
depth: CityDetailDepth = "panel",
) => {
const cached = cityDetailsByName[cityName];
const cached = findCachedCityDetail(cityDetailsByName, cityName);
const cachedMeta = cityDetailMetaByName[cityName];
const marketTargetDate =
depth === "market" ? selectedForecastDate || cached?.local_date : null;
@@ -713,7 +752,7 @@ export function DashboardStoreProvider({
targetDate?: string | null;
},
) => {
let cached = cityDetailsByName[cityName];
let cached = findCachedCityDetail(cityDetailsByName, cityName);
try {
if (!cached) {
cached = await ensureCityDetail(cityName, false, "panel");
@@ -748,7 +787,7 @@ export function DashboardStoreProvider({
if (proAccess.loading) return;
if (!selectedCity) return;
if (!isPanelOpen) return;
if (cityDetailsByName[selectedCity]) return;
if (findCachedCityDetail(cityDetailsByName, selectedCity)) return;
let cancelled = false;
setLoadingState((current) => ({ ...current, cityDetail: true }));
@@ -1026,7 +1065,7 @@ export function DashboardStoreProvider({
const selectCity = async (cityName: string) => {
const wasSelectedCity = selectedCityRef.current === cityName;
const cached = cityDetailsByName[cityName];
const cached = findCachedCityDetail(cityDetailsByName, cityName);
selectedCityRef.current = cityName;
setSelectedCity(cityName);
setIsPanelOpen(true);
@@ -1073,7 +1112,7 @@ export function DashboardStoreProvider({
};
const focusCity = async (cityName: string) => {
const cached = cityDetailsByName[cityName];
const cached = findCachedCityDetail(cityDetailsByName, cityName);
selectedCityRef.current = cityName;
setSelectedCity(cityName);
setIsPanelOpen(false);
@@ -1328,7 +1367,7 @@ export function DashboardStoreProvider({
const isLatestModalRequest = () =>
modalOpenSeqRef.current === modalSeq &&
selectedCityRef.current === cityName;
let cachedDetail = cityDetailsByName[selectedCity];
let cachedDetail = findCachedCityDetail(cityDetailsByName, selectedCity);
if (!cachedDetail) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
@@ -1382,7 +1421,7 @@ export function DashboardStoreProvider({
const isLatestModalRequest = () =>
modalOpenSeqRef.current === modalSeq &&
selectedCityRef.current === cityName;
let cachedDetail = cityDetailsByName[cityName];
let cachedDetail = findCachedCityDetail(cityDetailsByName, cityName);
if (!cachedDetail) {
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
@@ -1494,7 +1533,7 @@ export function useCityData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key ? store.cityDetailsByName[key] || null : null,
data: key ? findCachedCityDetail(store.cityDetailsByName, key) : null,
isLoading:
store.loadingState.cityDetail &&
Boolean(key) &&
+28 -14
View File
@@ -765,8 +765,15 @@ export function getTemperatureChartData(
locale: Locale = "zh-CN",
) {
const hourly = detail.hourly || {};
const times = hourly.times || [];
const temps = hourly.temps || [];
const rawTimes = Array.isArray(hourly.times) ? hourly.times : [];
const rawTemps = Array.isArray(hourly.temps) ? hourly.temps : [];
const times = rawTimes
.map((time) => String(time || "").trim())
.filter(Boolean);
const temps = times.map((_, index) => {
const value = Number(rawTemps[index]);
return Number.isFinite(value) ? value : null;
});
const suppressAnkaraMgmObservation = isTurkishMgmCity(detail);
if (!times.length) return null;
@@ -777,7 +784,9 @@ export function getTemperatureChartData(
const offset =
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
const debTemps = temps.map((temp) =>
temp != null ? Number((temp + offset).toFixed(1)) : null,
temp != null && Number.isFinite(temp)
? Number((temp + offset).toFixed(1))
: null,
);
const debPast = debTemps.map((temp, index) =>
currentIndex >= 0 && index <= currentIndex ? temp : null,
@@ -889,23 +898,23 @@ export function getTemperatureChartData(
const metarPoints = new Array(times.length).fill(null);
observationSource.forEach((item) => {
const index = findNearestTimeIndex(times, String(item.time || ""));
const temp = item.temp ?? null;
if (index >= 0 && temp != null) {
const temp = Number(item.temp);
if (index >= 0 && Number.isFinite(temp)) {
const existing = metarPoints[index];
// Multiple reports can land in the same hour bucket. Keep the peak
// value so an intrahour high is not hidden by a later weaker report.
metarPoints[index] =
existing == null ? temp : Math.max(Number(existing), Number(temp));
existing == null ? temp : Math.max(Number(existing), temp);
}
});
const airportMetarPoints = new Array(times.length).fill(null);
airportMetarSource.forEach((item) => {
const index = findNearestTimeIndex(times, String(item.time || ""));
const temp = item.temp ?? null;
if (index >= 0 && temp != null) {
const temp = Number(item.temp);
if (index >= 0 && Number.isFinite(temp)) {
const existing = airportMetarPoints[index];
airportMetarPoints[index] =
existing == null ? temp : Math.max(Number(existing), Number(temp));
existing == null ? temp : Math.max(Number(existing), temp);
}
});
@@ -916,17 +925,22 @@ export function getTemperatureChartData(
detail.mgm?.time
) {
const index = findNearestTimeIndex(times, detail.mgm.time);
if (index >= 0) {
mgmPoints[index] = detail.mgm.temp;
const temp = Number(detail.mgm.temp);
if (index >= 0 && Number.isFinite(temp)) {
mgmPoints[index] = temp;
}
}
const mgmHourlyPoints = new Array(times.length).fill(null);
let hasMgmHourly = false;
detail.mgm?.hourly?.forEach((item) => {
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
? detail.mgm?.hourly || []
: [];
mgmHourlyRows.forEach((item) => {
const index = findNearestTimeIndex(times, String(item.time || ""));
if (index >= 0) {
mgmHourlyPoints[index] = item.temp ?? null;
const temp = Number(item.temp);
if (index >= 0 && Number.isFinite(temp)) {
mgmHourlyPoints[index] = temp;
hasMgmHourly = true;
}
});
+19 -1
View File
@@ -54,6 +54,22 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int(
)
def _dedupe_forecast_daily(rows: Any) -> list[Dict[str, Any]]:
if not isinstance(rows, list):
return []
seen = set()
out = []
for row in rows:
if not isinstance(row, dict):
continue
date = str(row.get("date") or "").strip()
if not date or date in seen:
continue
seen.add(date)
out.append(row)
return out
def _format_observation_time_local(value: Any, utc_offset: int) -> str:
raw = str(value or "").strip()
if not raw:
@@ -1810,7 +1826,9 @@ def _analyze(
sunshine = daily.get("sunshine_duration", [])
om_today = _sf(maxtemps[0]) if maxtemps else None
forecast_daily = [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
forecast_daily = _dedupe_forecast_daily(
[{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)]
)
if om_today is None:
nws_high = _sf(raw.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high")) if mgm else None