Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions WeatherExtension.Tests/DockBandCardSyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------
Expand Down
113 changes: 101 additions & 12 deletions WeatherExtension/DockBands/PinnedWeatherBand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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
Expand All @@ -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)
{
Expand All @@ -81,6 +118,8 @@ private async Task UpdateWeatherAsync()

try
{
_lastUpdateAttempt = DateTime.UtcNow;

var weather = await _weatherService.GetCurrentWeatherAsync(
_location.Latitude,
_location.Longitude,
Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
}
}
Loading