Skip to content
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
also marked with an orange warning on its header instead of the grey info glyph that plain
limitations use; press Refresh to sign in.

- Windows: **expired activations no longer linger after the PC wakes.** As on macOS, a background
refresh that could not renew a sign-in silently kept the tenant's last-known rows, including
activations that had ended meanwhile. Elevate now drops an activation a minute after its end
time, whether or not the tenant could be read. A tenant waiting for a sign-in to refresh is also
marked with an orange warning on its header instead of the grey info glyph that plain
limitations use; press Refresh to sign in.

- CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read
failed — a sign-in it could not renew silently, a dropped network — the table kept the last rows
it had, including activations whose end time had since passed. `watch` now drops a row as soon as
Expand Down
31 changes: 31 additions & 0 deletions windows/src/Elevate.App.Model/ViewModels/AppModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -604,13 +604,44 @@ private async Task RunClockAsync(CancellationToken ct)
{
Clock = DateTimeOffset.UtcNow;
Touch();
await DropLapsedAssignmentsAsync(Clock);
}
}
catch (OperationCanceledException)
{
}
}

/// <summary>
/// How long past its end an activation is kept, so the "expired" toast — due a few seconds
/// after the end — fires before the reschedule below would withdraw it.
/// </summary>
private static readonly TimeSpan LapseGrace = TimeSpan.FromMinutes(1);

/// <summary>
/// A refresh that cannot read a tenant (a sign-in it cannot renew silently, a failed request)
/// keeps that tenant's known rows. The end times are known, though: once one has passed, the
/// row is gone whatever the service would say, and must not linger with a Deactivate button.
/// </summary>
internal async Task DropLapsedAssignmentsAsync(DateTimeOffset now)
{
var cutoff = now - LapseGrace;
var lapsed = Active.Values.Where(a => a.HasLapsed(cutoff)).Select(a => a.RoleKey).ToList();
if (lapsed.Count == 0)
{
return;
}

foreach (var key in lapsed)
{
Active.Remove(key);
StopWatchingPropagation(key);
}

Touch();
await RescheduleNotificationsAsync();
}

private async Task RunRefreshTimerAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
Expand Down
13 changes: 13 additions & 0 deletions windows/src/Elevate.App/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
<!-- The macOS system orange, so a warning reads the same on both platforms; Fluent's caution colour turns yellow in dark mode. -->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="ElevateWarningBrush" Color="#FF9500" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="ElevateWarningBrush" Color="#FF9F0A" />
</ResourceDictionary>
<!-- High contrast draws the header glyphs in the window text colour whatever brush is set; this keeps the lookup resolving. -->
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="ElevateWarningBrush" Color="{ThemeResource SystemColorWindowTextColor}" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<!-- Icon-only header buttons: no fill or border at rest, the standard subtle hover fill. -->
<Style x:Key="SubtleButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Setter Property="Background" Value="Transparent" />
Expand Down
24 changes: 18 additions & 6 deletions windows/src/Elevate.App/Views/PanelItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ public PanelGroup(string key, GroupKind kind)
private bool _busy;
private string? _signInHelp;
private bool _needsSignIn;
private bool _awaitingSignIn;
private bool _signInEnabled;
private string? _signInTooltip;

Expand Down Expand Up @@ -559,9 +560,16 @@ public IReadOnlyList<TenantIssue> Issues
}
}

/// <summary>A failed discovery or refresh turns the glyph red; limitations alone leave it orange.</summary>
/// <summary>A failed discovery or refresh turns the glyph red; limitations alone leave it grey.</summary>
public bool HasError { get => _hasError; set => Set(ref _hasError, value); }

/// <summary>
/// A background refresh could not renew the tenant's sign-in silently, so the rows shown may be
/// stale until the user presses Refresh. Not an error, but it must not read as a quiet limitation:
/// the glyph turns to an orange warning, as on macOS.
/// </summary>
public bool AwaitingSignIn { get => _awaitingSignIn; set => Set(ref _awaitingSignIn, value); }

public bool Busy { get => _busy; set => Set(ref _busy, value); }

/// <summary>
Expand Down Expand Up @@ -609,14 +617,16 @@ public IReadOnlyList<TenantIssue> Issues

public Visibility IssuesVisibility => Issues.Count == 0 ? Visibility.Collapsed : Visibility.Visible;

/// <summary>A filled warning triangle for a failure, an info glyph for mere limitations.</summary>
public string IssuesGlyph => HasError ? "" : "";
/// <summary>A filled warning triangle for a failure or a pending sign-in, an info glyph for mere limitations.</summary>
public string IssuesGlyph => HasError || AwaitingSignIn ? "" : "";

public Brush IssuesBrush => (Brush)Application.Current.Resources[HasError ? "SystemFillColorCriticalBrush" : "TextFillColorSecondaryBrush"];
public Brush IssuesBrush => (Brush)Application.Current.Resources[
HasError ? "SystemFillColorCriticalBrush" : AwaitingSignIn ? "ElevateWarningBrush" : "TextFillColorSecondaryBrush"];

public string IssuesTooltip => string.Join("\n", Issues.Select(i => i.Title));

public string IssuesLabel => Issues.Count == 1 ? "1 limitation" : $"{Issues.Count} limitations";
public string IssuesLabel => !HasError && AwaitingSignIn ? "Sign-in needed"
: Issues.Count == 1 ? "1 limitation" : $"{Issues.Count} limitations";

public Visibility BusyVisibility => Busy ? Visibility.Visible : Visibility.Collapsed;

Expand All @@ -642,6 +652,7 @@ public void CopyFrom(PanelGroup other)
Manual = other.Manual;
Issues = other.Issues;
HasError = other.HasError;
AwaitingSignIn = other.AwaitingSignIn;
Busy = other.Busy;
NeedsSignIn = other.NeedsSignIn;
SignInEnabled = other.SignInEnabled;
Expand Down Expand Up @@ -905,7 +916,8 @@ private static void ApplyStatus(AppModel model, PanelGroup group, TenantContext
issues.Add(new TenantIssue("Discovery or refresh failed", error));
}

if (model.TenantsAwaitingSignIn.Contains(tenant.Key))
group.AwaitingSignIn = model.TenantsAwaitingSignIn.Contains(tenant.Key);
if (group.AwaitingSignIn)
{
issues.Add(new TenantIssue(
"Sign-in needed to refresh",
Expand Down
49 changes: 49 additions & 0 deletions windows/tests/Elevate.App.Tests/AppModelLapsedAssignmentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Elevate.App.Tests.Support;
using Elevate.Core.Models;
using Elevate.Core.Storage;
using FluentAssertions;

namespace Elevate.App.Tests;

/// <summary>
/// Port of the macOS fix in #201. A background refresh that cannot read a tenant keeps its known
/// rows; the clock tick drops the ones whose end has passed, so an activation that ended while the
/// machine slept does not linger in Active now with a Deactivate that can only fail.
/// </summary>
public class AppModelLapsedAssignmentTests
{
private static AppState StateWithTenant() => new()
{
Identities = [Sample.Identity()],
Tenants = [Sample.Tenant()],
};

[Fact]
public async Task DropsActiveRowsAMinutePastTheirEnd()
{
using var test = await TestModel.BootstrappedAsync(StateWithTenant(), online: false);
var model = test.Model;
var now = DateTimeOffset.UtcNow;
model.Active[Sample.EntraKey] = Sample.Assignment(Sample.EntraKey, ends: now.AddMinutes(-2));
model.Active[Sample.AzureKey] = Sample.Assignment(Sample.AzureKey, ends: now.AddHours(1));
model.Active[Sample.GroupKey] = new ActiveAssignment(Sample.GroupKey, "p", now.AddHours(-3), null, AssignmentStatus.PendingApproval);

await model.DropLapsedAssignmentsAsync(now);

model.Active.Keys.Should().BeEquivalentTo([Sample.AzureKey, Sample.GroupKey]);
}

/// <summary>The "expired" toast is due five seconds after the end; dropping the row sooner would withdraw it.</summary>
[Fact]
public async Task KeepsARowThatEndedLessThanAMinuteAgo()
{
using var test = await TestModel.BootstrappedAsync(StateWithTenant(), online: false);
var model = test.Model;
var now = DateTimeOffset.UtcNow;
model.Active[Sample.EntraKey] = Sample.Assignment(Sample.EntraKey, ends: now.AddSeconds(-30));

await model.DropLapsedAssignmentsAsync(now);

model.Active.Should().ContainKey(Sample.EntraKey);
}
}
Loading