diff --git a/WeatherExtension.Tests/DockBandCardSyncTests.cs b/WeatherExtension.Tests/DockBandCardSyncTests.cs index 313ad55..ccc0255 100644 --- a/WeatherExtension.Tests/DockBandCardSyncTests.cs +++ b/WeatherExtension.Tests/DockBandCardSyncTests.cs @@ -143,6 +143,43 @@ public void PinnedWeatherBand_ContentPageField_IsReadonly() "_contentPage should be readonly to guarantee the reference is stable across timer ticks"); } + // --------------------------------------------------------------- + // Structural: Stale Data Indicator tracking fields + // --------------------------------------------------------------- + + [TestMethod] + public void PinnedWeatherBand_HasStalenessTrackingFields() + { + var type = typeof(PinnedWeatherBand); + + var attemptField = type.GetField("_lastUpdateAttempt", PrivateInstance); + Assert.IsNotNull(attemptField, "PinnedWeatherBand must track _lastUpdateAttempt"); + Assert.AreEqual(typeof(DateTime), attemptField!.FieldType); + + var successField = type.GetField("_lastSuccessfulFetch", PrivateInstance); + Assert.IsNotNull(successField, "PinnedWeatherBand must track _lastSuccessfulFetch"); + Assert.AreEqual(typeof(DateTime), successField!.FieldType); + } + + [TestMethod] + public void PinnedWeatherBand_HasMarkAsStaleIfNeededMethod() + { + var method = typeof(PinnedWeatherBand) + .GetMethod("MarkAsStaleIfNeeded", PrivateInstance); + + Assert.IsNotNull(method, "PinnedWeatherBand must have MarkAsStaleIfNeeded method for issue #120"); + } + + [TestMethod] + public void PinnedWeatherBand_OnTimerElapsed_CallsMarkAsStaleIfNeeded() + { + AssertAsyncMethodCallsTarget( + typeof(PinnedWeatherBand), + "OnTimerElapsed", + typeof(PinnedWeatherBand), + "MarkAsStaleIfNeeded"); + } + // --------------------------------------------------------------- // Helper: scan async state-machine IL to verify a target call // --------------------------------------------------------------- diff --git a/WeatherExtension/DockBands/PinnedWeatherBand.cs b/WeatherExtension/DockBands/PinnedWeatherBand.cs index d97df9c..e4bfd01 100644 --- a/WeatherExtension/DockBands/PinnedWeatherBand.cs +++ b/WeatherExtension/DockBands/PinnedWeatherBand.cs @@ -9,6 +9,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using System.Globalization; +using System.Net.NetworkInformation; using Timer = System.Timers.Timer; namespace Microsoft.CmdPal.Ext.Weather.DockBands; @@ -23,6 +24,8 @@ internal sealed partial class PinnedWeatherBand : ListItem, IDisposable private readonly CancellationTokenSource _cts = new(); private bool _isDisposed; private int _isUpdating; + private DateTime _lastUpdateAttempt = DateTime.MinValue; + private DateTime _lastSuccessfulFetch = DateTime.MinValue; internal bool IsDisposed => _isDisposed; @@ -44,16 +47,39 @@ public PinnedWeatherBand( Title = Resources.dock_band_loading; Subtitle = _location.DisplayName; - var intervalMs = _settings.UpdateIntervalMinutes * 60 * 1000; - _updateTimer = new Timer(intervalMs); + // Tick every 1 minute to check staleness without fetching + _updateTimer = new Timer(60 * 1000); _updateTimer.Elapsed += OnTimerElapsed; _updateTimer.Start(); _settings.Settings.SettingsChanged += OnSettingsChanged; + NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; _ = UpdateWeatherAsync(); } + private void OnNetworkAddressChanged(object? sender, EventArgs e) + { + // Network address change guarantees an IP assignment (e.g. WiFi connected). + if (_lastUpdateAttempt > _lastSuccessfulFetch || _lastSuccessfulFetch == DateTime.MinValue) + { + _ = UpdateWeatherAsync(); + } + } + + private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) + { + if (e.IsAvailable) + { + // If our last attempt failed (or we never succeeded), fetch instantly! + if (_lastUpdateAttempt > _lastSuccessfulFetch || _lastSuccessfulFetch == DateTime.MinValue) + { + _ = UpdateWeatherAsync(); + } + } + } + private async void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) { // async void on a Timer.Elapsed handler is unavoidable, but we must @@ -62,7 +88,18 @@ private async void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs // try/catch and log anything that still escapes. try { - await UpdateWeatherAsync().ConfigureAwait(false); + var intervalMinutes = _settings.UpdateIntervalMinutes; + var timeSinceLastUpdate = DateTime.UtcNow - _lastUpdateAttempt; + var lastAttemptFailed = _lastUpdateAttempt > _lastSuccessfulFetch; + + if (timeSinceLastUpdate.TotalMinutes >= intervalMinutes || lastAttemptFailed) + { + await UpdateWeatherAsync().ConfigureAwait(false); + } + else + { + MarkAsStaleIfNeeded(); + } } catch (Exception ex) { @@ -81,6 +118,8 @@ private async Task UpdateWeatherAsync() try { + _lastUpdateAttempt = DateTime.UtcNow; + var weather = await _weatherService.GetCurrentWeatherAsync( _location.Latitude, _location.Longitude, @@ -99,6 +138,7 @@ private async Task UpdateWeatherAsync() if (weather?.Current != null) { + _lastSuccessfulFetch = DateTime.UtcNow; var tempUnit = _settings.TemperatureUnit; var current = weather.Current; var condition = Icons.GetWeatherDescription(current.WeatherCode); @@ -109,11 +149,6 @@ private async Task UpdateWeatherAsync() condition); Icon = Icons.GetIconForWeatherCode(current.WeatherCode); - if (DockItem is CommandItem dockCommandItem) - { - dockCommandItem.Icon = Icon; - } - if (_settings.DockBandSubtitle == "highlow") { var forecast = await _weatherService.GetForecastAsync( @@ -152,10 +187,19 @@ private async Task UpdateWeatherAsync() } else { - Title = "--"; - Subtitle = $"{_location.DisplayName} — {Resources.weather_service_error}"; + if (Title == Resources.dock_band_loading) + { + Title = "⚠️ --"; + Subtitle = $"{_location.DisplayName} — {Resources.weather_service_error}"; + } + else + { + MarkAsStaleIfNeeded(); + } } + SyncDockItem(); + // Refresh the expanded content page to stay in sync with the band if (!_isDisposed) { @@ -180,9 +224,13 @@ private async Task UpdateWeatherAsync() if (Title == Resources.dock_band_loading) { - Title = "--"; + Title = "⚠️ --"; Subtitle = $"{_location.DisplayName} — {Resources.network_error}"; } + else + { + MarkAsStaleIfNeeded(); + } } catch (Exception ex) { @@ -192,9 +240,14 @@ private async Task UpdateWeatherAsync() if (Title == Resources.dock_band_loading) { - Title = "--"; + Title = "⚠️ --"; Subtitle = $"{_location.DisplayName} — {Resources.unavailable}"; } + else + { + MarkAsStaleIfNeeded(); + } + SyncDockItem(); } finally { @@ -233,6 +286,8 @@ public void Dispose() // the inner content page so its own subscriptions get released too. _isDisposed = true; _settings.Settings.SettingsChanged -= OnSettingsChanged; + NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; _updateTimer.Elapsed -= OnTimerElapsed; _updateTimer.Stop(); _updateTimer.Dispose(); @@ -249,4 +304,38 @@ public void Dispose() _cts.Dispose(); _contentPage.Dispose(); } + + private void MarkAsStaleIfNeeded() + { + if (_lastSuccessfulFetch == DateTime.MinValue || _isDisposed) + { + return; + } + + // Don't mark generic error or loading states as stale + if (Title == "--" || Title == Resources.dock_band_loading) + { + return; + } + + var age = DateTime.UtcNow - _lastSuccessfulFetch; + if (age.TotalMinutes >= 15) + { + if (Subtitle != null && !Subtitle.StartsWith("⚠ ", StringComparison.Ordinal)) + { + Subtitle = "⚠ " + Subtitle; + } + } + SyncDockItem(); + } + + private void SyncDockItem() + { + if (DockItem is CommandItem dockCommandItem) + { + dockCommandItem.Icon = Icon; + dockCommandItem.Title = Title; + dockCommandItem.Subtitle = Subtitle; + } + } }