diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index eee8d2ec..296a09fd 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -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(); + 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" && diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index c75146c7..8c33cd42 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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 || ""), ); diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 3b4a4a83..bed93b31 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -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, + 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(); + 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([]); const citySummariesRef = useRef>({}); const selectedCityRef = useRef(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) && diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 8d436e71..b728eca9 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -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; } }); diff --git a/web/analysis_service.py b/web/analysis_service.py index 632a2c65..41e7f9c0 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -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