Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ Each entry links to the commit or pull request that introduced the change.

## [Unreleased]

### Fixed

- Apply the weekly meaningful-change threshold to the recent six-hour window and preserve near-term forecast times in chart alternative text. ([PR #26](https://github.com/TheBeems/CodexUsageDock/pull/26))

### Changed

- Make weekly estimates conditional on at least 30 minutes of measurements, show their observation basis and remaining daily budget, and use representative recent history with less influence from short bursts further ahead. Break chart lines at measurement gaps and use the same adaptive model for weekly alerts. Add chronological synthetic forecast benchmarks. ([PR #26](https://github.com/TheBeems/CodexUsageDock/pull/26))
- Explain where to set the optional Windows CLI path environment variable, its lookup priority, and how to apply changes after restarting PowerToys. ([commit c8c7628](https://github.com/TheBeems/CodexUsageDock/commit/c8c762852e66ed3be5411b4614516110ab8a98a4))
- Simplify the README with a quick start, settings and troubleshooting tables, and a separate user guide for detailed usage and data handling. ([commit 84e04a4](https://github.com/TheBeems/CodexUsageDock/commit/84e04a4e0e38ecf15831175c65c6afb91d9b8bb8))

Expand Down
2 changes: 1 addition & 1 deletion CodexUsageDock.Tests/ReliabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ public void TextAndChartBothPauseForecastAfterMeasurementGap()
[Fact]
public void ForecastUsesOnlyContinuousMeasurementsAfterGap()
{
UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), new(Now.AddMinutes(-5), 75), new(Now, 70)];
UsageHistoryEntry[] history = [new(Now.AddDays(-1), 100), .. WeeklyForecastTests.Series(Now.AddMinutes(-30), Now, 100, 70)];
var result = UsageTrendAnalyzer.Analyze(history, Now.AddDays(-4), Now.AddDays(3), Now, true, TimeSpan.FromMinutes(5));
Assert.Equal(Now.AddMinutes(70), result.Forecast!.EndsAt);
}
Expand Down
31 changes: 31 additions & 0 deletions CodexUsageDock.Tests/UsageAlertTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,37 @@ private static IReadOnlyList<UsageAlert> Evaluate(
UsageAlertOptions? options = null) =>
evaluator.Evaluate(presentation, now ?? Now, RefreshInterval, options ?? EnabledOptions);

[Theory]
[InlineData(true)]
[InlineData(false)]
public void WeeklyWarningUsesTheSameAdaptiveChoiceAsTheDashboard(bool adaptive)
{
var reset = Now.AddDays(5);
var learned = WeeklyForecastTests.History(_ => 0, reset);
var evaluator = new UsageAlertEvaluator();
var options = EnabledOptions with { LowRemainingPercent = 1, AdaptiveWeeklyForecastEnabled = adaptive };
var baseline = Presentation(primaryRemaining: null, secondaryRemaining: 6, secondaryReset: reset,
updatedAt: Now.AddMinutes(-1)) with { AdaptiveWeeklyHistory = learned };
Assert.Empty(Evaluate(evaluator, baseline, Now.AddMinutes(-1), options));
var samples = WeeklyForecastTests.Series(Now.AddMinutes(-30), Now, 8.5, 5.5);
var current = Presentation(primaryRemaining: null, secondaryRemaining: 5.5, secondaryReset: reset,
weeklyHistory: samples) with { AdaptiveWeeklyHistory = learned };
var forecast = UsageTrendAnalyzer.Analyze(samples, reset.AddDays(-7), reset, Now, true,
TimeSpan.FromMinutes(5), adaptive, learned).Forecast!;
var alerts = Evaluate(evaluator, current, Now, options);

if (adaptive)
{
Assert.True(forecast.EndsAt > Now.AddHours(1));
Assert.Empty(alerts);
}
else
{
Assert.True(forecast.EndsAt <= Now.AddHours(1));
Assert.StartsWith("forecast:", Assert.Single(alerts).Key, StringComparison.Ordinal);
}
}

private static UsagePresentation Presentation(
double? primaryRemaining = 80,
double? secondaryRemaining = 80,
Expand Down
73 changes: 25 additions & 48 deletions CodexUsageDock.Tests/UsageDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
}

[Fact]
public void ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary()

Check warning on line 74 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var weekly = new RateLimitWindow(3, 10080, DateTimeOffset.Now.AddDays(7));

Expand All @@ -91,7 +91,7 @@
}

[Fact]
public void Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive()

Check warning on line 94 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = CodexUsageSnapshot.Loading with
Expand All @@ -108,7 +108,7 @@
}

[Fact]
public void MissingFiveHourWindow_CanBeDescribedAsInactive()

Check warning on line 111 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.MissingFiveHourWindow_CanBeDescribedAsInactive() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var text = CodexUsageDockPage.FormatWindow("5-hour window", null, DateTimeOffset.Now, "Currently inactive");

Expand All @@ -117,7 +117,7 @@
}

[Fact]
public void AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand()

Check warning on line 120 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var workingDirectory = CodexAppServerReader.GetSafeWorkingDirectory("codex.exe");

Expand Down Expand Up @@ -260,7 +260,7 @@
now,
isLoading: false,
ContinuousHistory(now.AddMinutes(-30), 90, now, 80),
ContinuousHistory(now.AddHours(-12), 99, now, 98),
ContinuousHistory(now.AddHours(-12), 100, now, 98),
TimeSpan.FromMinutes(1));
var details = CodexUsageDockPage.FormatDetailsBody(snapshot, now);
using var mainData = JsonDocument.Parse(main);
Expand All @@ -279,8 +279,8 @@
Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyElapsedBarUrl").GetString(), StringComparison.Ordinal);
Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean());
Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal);
Assert.Contains("Solid line connects sampled values", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal);
Assert.Equal("Forecast: current pace only.", root.GetProperty("weeklyForecastStatus").GetString());
Assert.Contains("Solid line connects continuous measurements", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal);
Assert.StartsWith("Forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal);
Assert.Equal("On track", root.GetProperty("fiveHourPaceStatus").GetString());
Assert.Equal("Comfortably on track", root.GetProperty("weeklyPaceStatus").GetString());
Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal);
Expand Down Expand Up @@ -346,7 +346,7 @@

Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean());
Assert.Equal("250000", thursdayTokenBar.Attribute("data-tokens")?.Value);
Assert.Equal(2, observedLines.Length);
Assert.Equal(3, observedLines.Length);
}

[Fact]
Expand All @@ -364,34 +364,21 @@
now,
UsageDataSource.AppServer,
null);
var cycles = Enumerable.Range(1, 3)
.Select(offset => new AdaptiveWeeklyUsageCycle(
reset.AddDays(-7 * offset),
10080,
60,
6,
[new AdaptiveWeeklyUsageBucket(1, 60, 12)]))
.ToArray();
var cycles = WeeklyForecastTests.History(index => index == 1 ? 0.2 : 0.001, reset).CompletedCycles;

var data = CodexUsageDockPage.FormatMainDataJson(
snapshot,
now,
isLoading: false,
primaryHistory: [],
weeklyHistory:
[
new UsageHistoryEntry(now.AddMinutes(-10), 90),
new UsageHistoryEntry(now, 80),
],
weeklyHistory: ContinuousHistory(now.AddMinutes(-30), 90, now, 80),
refreshInterval: TimeSpan.FromMinutes(1),
adaptiveWeeklyForecastEnabled: true,
adaptiveWeeklyHistory: new AdaptiveWeeklyUsageHistory(cycles, null));

using var document = JsonDocument.Parse(data);

Assert.Equal(
"Forecast: current pace + local history (3/8 cycles).",
document.RootElement.GetProperty("weeklyForecastStatus").GetString());
Assert.Contains("3 usable weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal);
}

[Fact]
Expand Down Expand Up @@ -537,7 +524,7 @@
Assert.Equal(["1M", "500K"], root.Descendants(Svg + "g")
.Where(group => group.Attribute("data-axis")?.Value == "tokens")
.Select(group => group.Attribute("data-axis-label")!.Value));
Assert.Contains("Solid line connects sampled values", result.AltText, StringComparison.Ordinal);
Assert.Contains("Solid line connects continuous measurements", result.AltText, StringComparison.Ordinal);
Assert.Contains("locally observed total tokens", result.AltText, StringComparison.Ordinal);
Assert.DoesNotContain("NaN", result.ImageUrl, StringComparison.Ordinal);
Assert.DoesNotContain("Infinity", result.ImageUrl, StringComparison.Ordinal);
Expand Down Expand Up @@ -608,7 +595,7 @@
}

[Fact]
public void WeeklyTrendChartConnectsAllowanceGapsWithoutChangingDailyTokens()
public void WeeklyTrendChartBreaksAllowanceGapsWithoutChangingDailyTokens()
{
var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero);
var reset = windowStart.AddDays(7);
Expand Down Expand Up @@ -637,10 +624,8 @@
root.Descendants(Svg + "rect"),
bar => bar.Attribute("data-series")?.Value == "daily-tokens");

var observedPoints = Assert.Single(observedLines)
.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);

Assert.Equal(4, observedPoints.Length);
Assert.Equal(2, observedLines.Length);
Assert.All(observedLines, line => Assert.Equal(2, line.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length));
Assert.Equal("250000", tokenBar.Attribute("data-tokens")?.Value);
}

Expand Down Expand Up @@ -676,15 +661,15 @@

Assert.Equal(2, observedLines.Length);
Assert.Equal("125000", dailyTokenBar.Attribute("data-tokens")?.Value);
Assert.Contains("line breaks mark allowance increases or resets", result.AltText, StringComparison.Ordinal);
Assert.Contains("gaps and allowance increases break the line", result.AltText, StringComparison.Ordinal);
}

[Fact]
public void WeeklyRestorationAppearsInChartCardAndDetails()
{
var windowStart = new DateTimeOffset(2026, 7, 18, 9, 0, 0, TimeSpan.Zero);
var reset = windowStart.AddDays(7);
var now = windowStart.AddMinutes(20);
var now = windowStart.AddMinutes(45);
UsageHistoryEntry[] history =
[
new(windowStart.AddMinutes(1), 100, reset, 10080),
Expand All @@ -694,6 +679,7 @@
new(windowStart.AddMinutes(15), 92, reset, 10080),
new(now, 90, reset, 10080),
];
history = history[..^1].Concat(ContinuousHistory(windowStart.AddMinutes(15), 92, now, 90)).ToArray();
var snapshot = CodexUsageSnapshot.Loading with
{
Secondary = new RateLimitWindow(10, 10080, reset),
Expand Down Expand Up @@ -1014,7 +1000,7 @@
}

[Fact]
public void WeeklyTrendChartConnectsGapIsolatedObservationsAsASampledLine()
public void WeeklyTrendChartShowsGapIsolatedObservationsAsPoints()
{
var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero);
var reset = windowStart.AddDays(7);
Expand All @@ -1032,11 +1018,8 @@
var result = Assert.IsType<WeeklyUsageTrendChart>(chart);
var svg = ParseSvg(result.ImageUrl);

var observed = Assert.Single(svg.Descendants(Svg + "polyline"));
var points = observed.Attribute("points")!.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);

Assert.Equal(2, points.Length);
Assert.Single(svg.Descendants(Svg + "circle"));
Assert.Empty(svg.Descendants(Svg + "polyline"));
Assert.Equal(2, svg.Descendants(Svg + "circle").Count());
}

[Fact]
Expand Down Expand Up @@ -1184,7 +1167,7 @@
}

[Fact]
public void ParseResetCredits_UsesAvailableCountAndExpiryDetails()

Check warning on line 1170 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_UsesAvailableCountAndExpiryDetails() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""
{
Expand All @@ -1207,7 +1190,7 @@
}

[Fact]
public void ParseResetCredits_PreservesCountWhenDetailsAreNull()

Check warning on line 1193 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseResetCredits_PreservesCountWhenDetailsAreNull() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var json = JsonDocument.Parse("""{ "rateLimitResetCredits": { "availableCount": 3, "credits": null } }""");

Expand Down Expand Up @@ -1299,7 +1282,7 @@
}

[Fact]
public void ParseCredits_HandlesBalanceAndUnlimitedAccounts()

Check warning on line 1285 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.ParseCredits_HandlesBalanceAndUnlimitedAccounts() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var result = JsonDocument.Parse("{}");
using var balanceLimits = JsonDocument.Parse("""{ "credits": { "hasCredits": true, "unlimited": false, "balance": "10.00" } }""");
Expand Down Expand Up @@ -1341,7 +1324,7 @@
[InlineData(null, null, "-- resets")]
[InlineData(0, null, "0 resets")]
[InlineData(2, "10.00", "2 resets · 10.00")]
public void DockSummary_FormatsAvailableData(int? resetCount, string? balance, string expected)

Check warning on line 1327 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DockSummary_FormatsAvailableData(int?, string?, string) (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var snapshot = new CodexUsageSnapshot(
null,
Expand Down Expand Up @@ -1402,7 +1385,7 @@
}

[Fact]
public void DetailFormatter_ReportsMissingExpiryRows()

Check warning on line 1388 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DetailFormatter_ReportsMissingExpiryRows() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var resets = new RateLimitResetCredits(
2,
Expand All @@ -1428,7 +1411,7 @@
}

[Fact]
public void DashboardSummary_WarnsWhenPrimaryLimitIsLow()

Check warning on line 1414 in CodexUsageDock.Tests/UsageDataTests.cs

View workflow job for this annotation

GitHub Actions / validate

Remove the underscores from member name CodexUsageDock.Tests.UsageDataTests.DashboardSummary_WarnsWhenPrimaryLimitIsLow() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var now = new DateTimeOffset(2026, 7, 12, 14, 0, 0, TimeSpan.Zero);
var snapshot = new CodexUsageSnapshot(
Expand Down Expand Up @@ -1895,7 +1878,7 @@
maximumSampleAge: TimeSpan.FromMinutes(5));

Assert.Contains("Weekly usage trend", trend, StringComparison.Ordinal);
Assert.Contains("Projected at reset: 40% available", trend, StringComparison.Ordinal);
Assert.Contains("Projected at reset: about 40% available", trend, StringComparison.Ordinal);
Assert.DoesNotContain("limit may be reached", trend, StringComparison.Ordinal);
}

Expand All @@ -1905,7 +1888,7 @@
var now = DateTimeOffset.Now;
var trend = CodexUsageDockPage.FormatTrend(
"Weekly usage trend",
[new UsageHistoryEntry(now.AddMinutes(-10), 30), new UsageHistoryEntry(now, 10)],
ContinuousHistory(now.AddMinutes(-30), 30, now, 10),
new RateLimitWindow(90, 10080, now.AddDays(3)),
now,
dataAvailable: true,
Expand All @@ -1927,7 +1910,7 @@
dataAvailable: true,
maximumSampleAge: TimeSpan.FromMinutes(5));

Assert.Contains($"limit may be reached around {estimated.ToLocalTime():ddd d MMM HH:mm}", trend, StringComparison.Ordinal);
Assert.Contains($"limit may be reached around {estimated.ToLocalTime():ddd d MMM}.", trend, StringComparison.Ordinal);
}

[Fact]
Expand Down Expand Up @@ -2094,7 +2077,7 @@
}

[Fact]
public void AdaptiveForecastUsesTheActiveCycleWhenItsResetTimeHasSmallJitter()
public void AdaptiveForecastDoesNotDoubleCountTheActiveCycleAsLearnedHistory()
{
var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero);
var active = new AdaptiveWeeklyUsageCycle(reset, 10080, 60, 6, []);
Expand All @@ -2107,7 +2090,8 @@
enabled: true,
new AdaptiveWeeklyUsageHistory([], active));

Assert.Equal("Forecast: current pace + limited local history.", projection.Status);
Assert.Contains("history is insufficient", projection.Status, StringComparison.Ordinal);
Assert.False(projection.UsesHistory);
}

[Fact]
Expand Down Expand Up @@ -2262,21 +2246,14 @@
{
var reset = new DateTimeOffset(2026, 7, 24, 0, 0, 0, TimeSpan.Zero);
var start = reset.AddDays(-7);
var cycles = Enumerable.Range(1, 3)
.Select(offset => new AdaptiveWeeklyUsageCycle(
reset.AddDays(-7 * offset),
10080,
60,
6,
[new AdaptiveWeeklyUsageBucket(1, 60, 12)]))
.ToArray();
var cycles = WeeklyForecastTests.History(index => index == 1 ? 0.2 : 0.001, reset).CompletedCycles;
var history = new AdaptiveWeeklyUsageHistory(cycles, null);
var latest = new UsageHistoryEntry(start.AddHours(6), 50);

var adaptive = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, true, history);
var currentOnly = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, false, history);

Assert.Equal("Forecast: current pace + local history (3/8 cycles).", adaptive.Status);
Assert.Contains("3 usable weeks", adaptive.Status, StringComparison.Ordinal);
Assert.True(adaptive.Forecast.EndsAt < currentOnly.Forecast.EndsAt);
Assert.Equal("Forecast: current pace only.", currentOnly.Status);
}
Expand Down
Loading