diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index f06ea6ea..7e53cf9a 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -33,7 +33,7 @@ interface DashboardStoreValue extends DashboardState { ensureCityDetail: ( cityName: string, force?: boolean, - depth?: "panel" | "full", + depth?: "panel" | "nearby" | "full", ) => Promise; futureModalDate: string | null; loadCities: () => Promise; @@ -125,7 +125,7 @@ const EAGER_SUMMARY_PRIORITY_CITY_ORDER = [ "milan", "madrid", ] as const; -type CityDetailDepth = "panel" | "full"; +type CityDetailDepth = "panel" | "nearby" | "full"; function countAvailableModels( detail?: CityDetail | null, @@ -165,7 +165,9 @@ function hasSparseDetailCoverage( } function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth { - return detail?.detail_depth === "panel" ? "panel" : "full"; + if (detail?.detail_depth === "nearby") return "nearby"; + if (detail?.detail_depth === "panel") return "panel"; + return "full"; } function detailSatisfiesDepth( @@ -174,9 +176,17 @@ function detailSatisfiesDepth( ) { if (!detail) return false; if (depth === "panel") return true; + if (depth === "nearby") { + const normalized = normalizeDetailDepth(detail); + return normalized === "nearby" || normalized === "full"; + } return normalizeDetailDepth(detail) === "full"; } +function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) { + return depth === "panel" || depth === "full"; +} + function getStoredSelectedCityName(cities: CityListItem[]) { if (typeof window === "undefined") return null; const stored = String( @@ -413,7 +423,9 @@ export function DashboardStoreProvider({ const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; const hasRequestedDepth = detailSatisfiesDepth(cached, depth); - const cachedIsSparse = hasSparseDetailCoverage(cached, cached?.local_date); + const cachedIsSparse = + shouldCheckSparseCoverageForDepth(depth) && + hasSparseDetailCoverage(cached, cached?.local_date); if ( !force && cached && diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index c88075fe..4c5397b4 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -18,6 +18,7 @@ interface UseLeafletMapArgs { onEnsureCityDetail: ( cityName: string, force?: boolean, + depth?: "panel" | "nearby" | "full", ) => Promise; onMapInteractionChange: (active: boolean) => void; onRegisterStopMotion: (stopMotion: () => void) => void; @@ -512,9 +513,12 @@ export function useLeafletMap({ handlingAutoNearbyRef.current = true; try { if (selectedDetail) { - // Just render stations, no camera move from here - renderNearbyStations(selectedDetail, true); - return; + const selectedNearbyStations = pickMapNearbyStations(selectedDetail); + if (selectedNearbyStations.length) { + // Just render stations, no camera move from here + renderNearbyStations(selectedDetail, true); + return; + } } if (suspendMotion) { @@ -543,7 +547,7 @@ export function useLeafletMap({ } } - const targetCity = best?.cityName || null; + const targetCity = selectedCity || best?.cityName || null; if (!targetCity) { autoNearbyCityRef.current = null; layer.clearLayers(); @@ -559,7 +563,7 @@ export function useLeafletMap({ autoNearbyCityRef.current = targetCity; const cachedDetail = cityDetailsByName[targetCity]; - if (cachedDetail) { + if (cachedDetail && pickMapNearbyStations(cachedDetail).length) { renderNearbyStations(cachedDetail, true); return; } @@ -567,7 +571,11 @@ export function useLeafletMap({ if (loadingAutoNearbyRef.current) return; loadingAutoNearbyRef.current = true; try { - const detail = await onEnsureCityDetailRef.current(targetCity, false); + const detail = await onEnsureCityDetailRef.current( + targetCity, + false, + "nearby", + ); renderNearbyStations(detail, true); } catch { } finally { diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index f0a39631..299e1ec5 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -29,8 +29,10 @@ function normalizeCityName(cityName: string) { return encodeURIComponent(String(cityName).replace(/\s/g, "-")); } -function normalizeDetailDepth(depth?: "panel" | "full") { - return depth === "full" ? "full" : "panel"; +function normalizeDetailDepth(depth?: "panel" | "nearby" | "full") { + if (depth === "full") return "full"; + if (depth === "nearby") return "nearby"; + return "panel"; } async function fetchJson(url: string): Promise { @@ -161,7 +163,7 @@ export const dashboardClient = { async getCityDetail( cityName: string, - options?: { force?: boolean; depth?: "panel" | "full" }, + options?: { force?: boolean; depth?: "panel" | "nearby" | "full" }, ) { const force = options?.force ?? false; const depth = normalizeDetailDepth(options?.depth); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 991b16e8..6399b442 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -327,7 +327,7 @@ export interface AiAnalysisStructured { export interface CityDetail { name: string; display_name: string; - detail_depth?: "panel" | "full"; + detail_depth?: "panel" | "nearby" | "full"; lat: number; lon: number; temp_symbol: string; diff --git a/web/analysis_service.py b/web/analysis_service.py index 8bed6aeb..fb38cd45 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -85,14 +85,20 @@ def _analysis_ttl_for_city(city: str) -> int: def _analysis_cache_key(city: str, detail_mode: str = "full") -> str: - normalized_mode = "panel" if str(detail_mode or "").strip().lower() == "panel" else "full" + normalized_raw = str(detail_mode or "").strip().lower() + if normalized_raw == "panel": + normalized_mode = "panel" + elif normalized_raw == "nearby": + normalized_mode = "nearby" + else: + normalized_mode = "full" return f"{city}::{normalized_mode}" def _get_cached_analysis( city: str, ttl: int, - detail_modes: tuple[str, ...] = ("panel", "full"), + detail_modes: tuple[str, ...] = ("panel", "nearby", "full"), ) -> Optional[Dict[str, Any]]: now_ts = _time.time() freshest_payload: Optional[Dict[str, Any]] = None @@ -1085,7 +1091,13 @@ def _analyze( """Fetch, analyse, and return structured weather data for one city.""" # Check cache ttl = CACHE_TTL_ANKARA if city.lower() in TURKISH_MGM_CITIES else CACHE_TTL - normalized_detail_mode = "panel" if str(detail_mode or "full").strip().lower() == "panel" else "full" + normalized_detail_mode_raw = str(detail_mode or "full").strip().lower() + if normalized_detail_mode_raw == "panel": + normalized_detail_mode = "panel" + elif normalized_detail_mode_raw == "nearby": + normalized_detail_mode = "nearby" + else: + normalized_detail_mode = "full" cache_key = _analysis_cache_key(city, normalized_detail_mode) if not force_refresh: @@ -1114,16 +1126,17 @@ def _analyze( # ── 1. Fetch raw data ── is_panel_mode = normalized_detail_mode == "panel" + is_nearby_mode = normalized_detail_mode == "nearby" raw = _weather.fetch_all_sources( city, lat=lat, lon=lon, force_refresh=force_refresh, - include_taf=not is_panel_mode, + include_taf=not is_panel_mode and not is_nearby_mode, include_nearby=not is_panel_mode, - include_ensemble=not is_panel_mode, - include_multi_model=not is_panel_mode, + include_ensemble=not is_panel_mode and not is_nearby_mode, + include_multi_model=not is_panel_mode and not is_nearby_mode, ) om = raw.get("open-meteo", {}) metar = raw.get("metar", {}) @@ -1615,7 +1628,7 @@ def _analyze( first_peak_h, last_peak_h, ) - if not is_panel_mode + if not is_panel_mode and not is_nearby_mode else {} ) taf_signal = ( @@ -1627,7 +1640,7 @@ def _analyze( first_peak_h, last_peak_h, ) - if not is_panel_mode + if not is_panel_mode and not is_nearby_mode else {"available": False} ) @@ -1777,7 +1790,7 @@ def _analyze( # ── Assemble result ── city_meta = CITIES.get(city, {}) or {} result = { - "detail_depth": "panel" if is_panel_mode else "full", + "detail_depth": "panel" if is_panel_mode else "nearby" if is_nearby_mode else "full", "name": city, "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), "lat": lat, diff --git a/web/routes.py b/web/routes.py index 41023ea9..abad8571 100644 --- a/web/routes.py +++ b/web/routes.py @@ -435,7 +435,12 @@ async def city_detail( _assert_entitlement(request) city = _normalize_city_or_404(name) normalized_depth = str(depth or "panel").strip().lower() - detail_mode = "full" if normalized_depth == "full" else "panel" + if normalized_depth == "full": + detail_mode = "full" + elif normalized_depth == "nearby": + detail_mode = "nearby" + else: + detail_mode = "panel" return await run_in_threadpool(_analyze, city, force_refresh, False, detail_mode)