From 3f09ad3cfdc8cdc71dd9802c407e395544167cf3 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 02:32:40 +0000 Subject: [PATCH 1/4] fix: force cache refresh when switching locations (#264, #266) - Pass force_refresh=True to get_weather_data when location changes, ensuring fresh data instead of stale cached results - Add logging when set_current_location fails to save, helping diagnose location persistence issues - Fixes #264 (stale weather data after location switch) - Helps diagnose #266 (location not persisting after restart) --- src/accessiweather/ui/main_window.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index eba67bc8..6c6028f9 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -268,7 +268,7 @@ def _on_location_changed(self, event) -> None: if selected: logger.info(f"Location changed to: {selected}") self._set_current_location(selected) - self.refresh_weather_async() + self.refresh_weather_async(force_refresh=True) def on_add_location(self) -> None: """Handle add location button click.""" @@ -566,14 +566,21 @@ def _minimize_to_tray(self) -> None: logger.error(f"Failed to minimize to tray: {e}") def _set_current_location(self, location_name: str) -> None: - """Set the current location.""" + """Set the current location and persist to config.""" try: # set_current_location expects a string (location name), not a Location object - self.app.config_manager.set_current_location(location_name) + result = self.app.config_manager.set_current_location(location_name) + if not result: + logger.error( + f"Failed to set current location '{location_name}': " + "location not found or save failed" + ) + else: + logger.info(f"Current location set and saved: {location_name}") except Exception as e: logger.error(f"Failed to set current location: {e}") - def refresh_weather_async(self) -> None: + def refresh_weather_async(self, force_refresh: bool = False) -> None: """Refresh weather data asynchronously.""" if self.app.is_updating: logger.debug("Already updating, skipping refresh") @@ -584,9 +591,9 @@ def refresh_weather_async(self) -> None: self.refresh_button.Disable() # Run async weather fetch - self.app.run_async(self._fetch_weather_data()) + self.app.run_async(self._fetch_weather_data(force_refresh=force_refresh)) - async def _fetch_weather_data(self) -> None: + async def _fetch_weather_data(self, force_refresh: bool = False) -> None: """Fetch weather data in background.""" try: location = self.app.config_manager.get_current_location() @@ -595,7 +602,10 @@ async def _fetch_weather_data(self) -> None: return # Fetch weather data - pass the Location object directly - weather_data = await self.app.weather_client.get_weather_data(location) + # force_refresh=True bypasses cache (used when switching locations) + weather_data = await self.app.weather_client.get_weather_data( + location, force_refresh=force_refresh + ) # Update UI on main thread wx.CallAfter(self._on_weather_data_received, weather_data) From 92ac2cf6e4e07104bbdeb042898d6e3ebbcea1d6 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 02:42:15 +0000 Subject: [PATCH 2/4] fix: replace ComboBox with Choice for accessible location switching (#268) wx.ComboBox with CB_READONLY doesn't fire EVT_COMBOBOX when using arrow keys in collapsed state with screen readers (NVDA). Users had to expand the dropdown and press Enter to change locations. wx.Choice fires EVT_CHOICE on every selection change including arrow key navigation, making location switching work intuitively with screen readers. --- src/accessiweather/ui/main_window.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 6c6028f9..6a3a2827 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -68,9 +68,8 @@ def _create_widgets(self) -> None: location_panel.SetSizerProps(expand=True) wx.StaticText(location_panel, label="Location:") - self.location_dropdown = wx.ComboBox( + self.location_dropdown = wx.Choice( location_panel, - style=wx.CB_READONLY, name="Location selection", ) self.location_dropdown.SetSizerProps(expand=True, proportion=1) @@ -136,7 +135,7 @@ def _bind_events(self) -> None: self.Bind(wx.EVT_SHOW, self._on_window_shown) # Location dropdown - self.location_dropdown.Bind(wx.EVT_COMBOBOX, self._on_location_changed) + self.location_dropdown.Bind(wx.EVT_CHOICE, self._on_location_changed) # Buttons self.add_button.Bind(wx.EVT_BUTTON, lambda e: self.on_add_location()) From 755aad7c3ec37b71efe5f1b25da5d6fe4233e38a Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 02:54:24 +0000 Subject: [PATCH 3/4] perf: instant location switching with cache + background pre-warming - Show cached weather data immediately when switching locations, then refresh with fresh data in the background - Pre-warm cache for all saved locations after initial startup fetch, so subsequent switches are instant - Net effect: location switch goes from 2-5s (full API round-trip) to near-instant when cached data exists --- src/accessiweather/ui/main_window.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 6a3a2827..74123706 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from ..app import AccessiWeatherApp + from ..models.location import Location logger = logging.getLogger(__name__) @@ -267,6 +268,15 @@ def _on_location_changed(self, event) -> None: if selected: logger.info(f"Location changed to: {selected}") self._set_current_location(selected) + + # Show cached data instantly if available, then refresh in background + location = self.app.config_manager.get_current_location() + if location and hasattr(self.app, "weather_client") and self.app.weather_client: + cached = self.app.weather_client.get_cached_weather(location) + if cached and cached.has_any_data(): + logger.info(f"Showing cached data for {selected} while refreshing") + self._on_weather_data_received(cached) + self.refresh_weather_async(force_refresh=True) def on_add_location(self) -> None: @@ -609,10 +619,28 @@ async def _fetch_weather_data(self, force_refresh: bool = False) -> None: # Update UI on main thread wx.CallAfter(self._on_weather_data_received, weather_data) + # Pre-warm cache for other locations in background (non-blocking) + if not force_refresh: + await self._pre_warm_other_locations(location) + except Exception as e: logger.error(f"Failed to fetch weather data: {e}") wx.CallAfter(self._on_weather_error, str(e)) + async def _pre_warm_other_locations(self, current_location: Location) -> None: + """Pre-warm cache for non-current locations so switching is instant.""" + try: + all_locations = self.app.config_manager.get_all_locations() + for loc in all_locations: + if loc.name != current_location.name: + # Check if already cached + cached = self.app.weather_client.get_cached_weather(loc) + if not cached or not cached.has_any_data(): + logger.debug(f"Pre-warming cache for {loc.name}") + await self.app.weather_client.pre_warm_cache(loc) + except Exception as e: + logger.debug(f"Cache pre-warm failed (non-critical): {e}") + def _on_weather_data_received(self, weather_data) -> None: """Handle received weather data (called on main thread).""" try: From 5f7b95632b0a2f6182e86dc1093e6186dc249b53 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sun, 8 Feb 2026 03:02:10 +0000 Subject: [PATCH 4/4] fix: debounce rapid location switching to prevent stale data flicker - Add 500ms debounce timer on location change events so rapid arrow key presses only trigger one API fetch (for the final selection) - Add generation counter to discard stale fetch results: if the user switches location again before a fetch completes, the old result is silently discarded instead of updating the UI - Allow force_refresh fetches to proceed even when is_updating is set, preventing location switches from being blocked by in-flight fetches --- src/accessiweather/ui/main_window.py | 59 ++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 74123706..43b10c09 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -45,6 +45,7 @@ def __init__(self, app: AccessiWeatherApp, title: str = "AccessiWeather", **kwar super().__init__(parent=None, title=title, **kwargs) self.app = app self._escape_id = None + self._fetch_generation = 0 # Tracks which fetch is current (prevents stale updates) # Create the UI self._create_widgets() @@ -263,21 +264,35 @@ def _create_menu_bar(self) -> None: self.Bind(wx.EVT_MENU, lambda e: self._on_about(), about_item) def _on_location_changed(self, event) -> None: - """Handle location selection change.""" + """Handle location selection change with debounce for rapid switching.""" selected = self.location_dropdown.GetStringSelection() - if selected: - logger.info(f"Location changed to: {selected}") - self._set_current_location(selected) + if not selected: + return - # Show cached data instantly if available, then refresh in background - location = self.app.config_manager.get_current_location() - if location and hasattr(self.app, "weather_client") and self.app.weather_client: - cached = self.app.weather_client.get_cached_weather(location) - if cached and cached.has_any_data(): - logger.info(f"Showing cached data for {selected} while refreshing") - self._on_weather_data_received(cached) + logger.info(f"Location changed to: {selected}") + self._set_current_location(selected) + + # Show cached data instantly if available + location = self.app.config_manager.get_current_location() + if location and hasattr(self.app, "weather_client") and self.app.weather_client: + cached = self.app.weather_client.get_cached_weather(location) + if cached and cached.has_any_data(): + logger.info(f"Showing cached data for {selected} while refreshing") + self._on_weather_data_received(cached) + + # Debounce: cancel pending fetch, wait 500ms before fetching + if hasattr(self, "_location_debounce_timer") and self._location_debounce_timer.IsRunning(): + self._location_debounce_timer.Stop() + + if not hasattr(self, "_location_debounce_timer"): + self._location_debounce_timer = wx.Timer(self) + self.Bind(wx.EVT_TIMER, self._on_debounced_location_fetch, self._location_debounce_timer) - self.refresh_weather_async(force_refresh=True) + self._location_debounce_timer.StartOnce(500) + + def _on_debounced_location_fetch(self, event) -> None: + """Fetch weather data after debounce period for the currently selected location.""" + self.refresh_weather_async(force_refresh=True) def on_add_location(self) -> None: """Handle add location button click.""" @@ -591,7 +606,10 @@ def _set_current_location(self, location_name: str) -> None: def refresh_weather_async(self, force_refresh: bool = False) -> None: """Refresh weather data asynchronously.""" - if self.app.is_updating: + # Increment generation to invalidate any in-flight fetches + self._fetch_generation += 1 + + if self.app.is_updating and not force_refresh: logger.debug("Already updating, skipping refresh") return @@ -599,10 +617,11 @@ def refresh_weather_async(self, force_refresh: bool = False) -> None: self.set_status("Updating weather data...") self.refresh_button.Disable() - # Run async weather fetch - self.app.run_async(self._fetch_weather_data(force_refresh=force_refresh)) + # Run async weather fetch with current generation + generation = self._fetch_generation + self.app.run_async(self._fetch_weather_data(force_refresh=force_refresh, generation=generation)) - async def _fetch_weather_data(self, force_refresh: bool = False) -> None: + async def _fetch_weather_data(self, force_refresh: bool = False, generation: int = 0) -> None: """Fetch weather data in background.""" try: location = self.app.config_manager.get_current_location() @@ -616,6 +635,14 @@ async def _fetch_weather_data(self, force_refresh: bool = False) -> None: location, force_refresh=force_refresh ) + # Only update UI if this fetch is still current (not superseded by a newer one) + if generation != self._fetch_generation: + logger.debug( + f"Discarding stale fetch for {location.name} " + f"(gen {generation} < {self._fetch_generation})" + ) + return + # Update UI on main thread wx.CallAfter(self._on_weather_data_received, weather_data)