From 2b9e10384efa562b81fa5b22c4236876bf860ddb Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 25 Apr 2026 02:41:38 +0800 Subject: [PATCH] Use fresh city detail for scan list probabilities --- .../components/dashboard/OpportunityTable.tsx | 121 ++++++++++++++---- .../dashboard/ScanTerminalDashboard.tsx | 33 +++++ frontend/lib/dashboard-utils.ts | 2 +- 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 784d0ee4..8e932e6e 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -219,31 +219,118 @@ function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) { return { label: peakLabel, probability: peakProbability }; } +function normalizeLookupKey(value?: string | null) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); +} + function getDetailForRow( row: Pick, cityDetailsByName?: Record, ) { if (!cityDetailsByName) return null; const rowKeys = [row.city, row.city_display_name, row.display_name] - .map((value) => String(value || "").trim().toLowerCase()) + .map(normalizeLookupKey) .filter(Boolean); return ( Object.entries(cityDetailsByName).find(([name, detail]) => { const detailKeys = [name, detail.name, detail.display_name] - .map((value) => String(value || "").trim().toLowerCase()) + .map(normalizeLookupKey) .filter(Boolean); return rowKeys.some((key) => detailKeys.includes(key)); })?.[1] || null ); } +function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) { + if (!row) return detail.local_date; + const rawDate = row.selected_date || row.local_date || ""; + const phase = String(row.window_phase || "").toLowerCase(); + if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate; + if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) { + return detail.local_date; + } + return detail.local_date || rawDate; +} + +function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) { + return normalizeTemperatureLabel(value, tempSymbol) + .toLowerCase() + .replace(/\s+/g, "") + .replace(/℃/g, "°c"); +} + +function extractNumbers(value?: string | null) { + return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) => + Number(match[0]), + ); +} + +function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) { + return [bucket.label, bucket.bucket, bucket.range] + .map((value) => String(value || "").trim()) + .filter(Boolean); +} + +function bucketMatchesRow( + bucket: { + label?: string | null; + bucket?: string | null; + range?: string | null; + value?: number | string | null; + }, + row: ScanOpportunityRow, + tempSymbol?: string | null, +) { + const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol); + const bucketLabels = getBucketText(bucket).map((label) => + normalizeBucketLabel(label, tempSymbol), + ); + if (targetLabel && bucketLabels.some((label) => label === targetLabel)) { + return true; + } + + const rawTargetLabel = String(row.target_label || ""); + const targetNumbers = extractNumbers(rawTargetLabel); + const targetValue = + row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null; + if (targetValue == null || !Number.isFinite(Number(targetValue))) return false; + + const bucketNumbers = [ + ...(bucket.value != null && Number.isFinite(Number(bucket.value)) + ? [Number(bucket.value)] + : []), + ...getBucketText(bucket).flatMap(extractNumbers), + ]; + const matchesNumber = bucketNumbers.some( + (value) => Math.abs(Number(value) - Number(targetValue)) < 0.05, + ); + if (!matchesNumber) return false; + + const targetIsUpper = + /(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) || + (row.target_lower != null && row.target_upper == null); + const targetIsLower = + /(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) || + (row.target_upper != null && row.target_lower == null); + const bucketRaw = getBucketText(bucket).join(" "); + const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw); + const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw); + + if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper; + if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower; + return true; +} + function getDetailPeak( detail: CityDetail | null, - targetDate?: string | null, + row?: ScanOpportunityRow | null, tempSymbol?: string | null, ) { if (!detail) return null; - const view = getProbabilityView(detail, targetDate); + const view = getProbabilityView(detail, getDetailViewDate(detail, row)); const buckets = Array.isArray(view.probabilitiesAll) ? view.probabilitiesAll : []; @@ -270,30 +357,12 @@ function getDetailBucketEventProbability( tempSymbol?: string | null, ) { if (!detail) return null; - const view = getProbabilityView(detail, row.selected_date || row.local_date); + const view = getProbabilityView(detail, getDetailViewDate(detail, row)); const buckets = Array.isArray(view.probabilitiesAll) ? view.probabilitiesAll : []; if (!buckets.length) return null; - const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol); - const targetValue = - row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? null; - const matched = - buckets.find((bucket) => { - const label = normalizeTemperatureLabel( - bucket.label || bucket.bucket || bucket.range, - tempSymbol, - ); - return Boolean(targetLabel && label && label === targetLabel); - }) || - buckets.find((bucket) => { - const value = bucket.value; - return ( - value != null && - targetValue != null && - Math.abs(Number(value) - Number(targetValue)) < 0.01 - ); - }); + const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol)); return normalizeProbability(matched?.probability); } @@ -325,9 +394,9 @@ function buildOpportunityGroups( row.city_display_name || row.display_name || row.city, locale, ); - const date = row.selected_date || row.local_date || ""; + const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || ""; const key = `${row.city || cityName}|${date}`; - const peak = getDetailPeak(detail, date, tempSymbol) || getEmosPeak(row, tempSymbol); + const peak = getDetailPeak(detail, row, tempSymbol) || getEmosPeak(row, tempSymbol); const modelView = detail ? getModelView(detail, date) : null; const debPrediction = modelView?.deb ?? row.deb_prediction ?? null; const existing = groups.get(key); diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 368666b6..a940d0a6 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -16,6 +16,7 @@ import { useDeferredValue, useEffect, useMemo, + useRef, useState, } from "react"; import styles from "./Dashboard.module.css"; @@ -475,6 +476,7 @@ function ScanTerminalScreen() { const [userLocalTime, setUserLocalTime] = useState("--"); const [themeMode, setThemeMode] = useState("dark"); const deferredRows = useDeferredValue(terminalData?.rows || []); + const preloadedSnapshotRef = useRef(null); const prependAiLogs = (entries: ScanAiLogEntry[]) => { setAiLogs((current) => [...entries, ...current].slice(0, 8)); @@ -736,6 +738,37 @@ function ScanTerminalScreen() { void fetchTerminal(DEFAULT_FILTERS, false); }, [isPro]); + useEffect(() => { + if (!isPro || !terminalData?.rows.length) return; + const snapshotKey = + terminalData.snapshot_id || + terminalData.generated_at || + terminalData.rows.map((row) => row.id).join("|"); + if (!snapshotKey || preloadedSnapshotRef.current === snapshotKey) return; + preloadedSnapshotRef.current = snapshotKey; + + const seen = new Set(); + const cities = sortRowsByUserTime(terminalData.rows) + .map((row) => String(row.city || row.city_display_name || row.display_name || "").trim()) + .filter((city) => { + const key = normalizeCityKey(city); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 12); + + cities.forEach((cityName) => { + void store.ensureCityDetail(cityName, true, "market").catch(() => {}); + }); + }, [ + isPro, + store.ensureCityDetail, + terminalData?.rows, + terminalData?.generated_at, + terminalData?.snapshot_id, + ]); + useEffect(() => { if (!isPro) return; const intervalId = window.setInterval(() => { diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 535730ce..67585828 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1271,7 +1271,7 @@ export function getProbabilityView(detail: CityDetail, targetDate?: string | nul engine: null, mu: daily?.deb?.prediction ?? null, probabilities: daily?.probabilities || [], - probabilitiesAll: daily?.probabilities || [], + probabilitiesAll: daily?.probabilities_all || daily?.probabilities || [], shadowProbabilities: [], shadowProbabilitiesAll: [], };