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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality).

## [Unreleased]

## [0.8.5] - 2026-10-03

### Fixed

- Avoid false reset markers and broken history lines when an unused quota's estimated reset time moves with each reading. Keep real reset events, remove duplicate restoration lines, and prevent chart labels from overlapping. Show compact billion-token labels and the forecast basis beside the chart, with a legend that reflects the available data. ([commit e9baaa7](https://github.com/TheBeems/CodexUsageDock/commit/e9baaa73ac545e8d1855cddb774c75c544a458b5))

## [0.8.4] - 2026-10-03

### Changed
Expand Down Expand Up @@ -242,7 +248,8 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality).
- Initial release of the Windows Command Palette extension for viewing local Codex usage. [commit ac72fe5](https://github.com/TheBeems/CodexUsageDock/commit/ac72fe50fcd1af36f41cda896f1d792899573351)
- Automated release installer creation and smoke-test handling. [commit 64a3305](https://github.com/TheBeems/CodexUsageDock/commit/64a33058b7486dea12f026561c545b362eb2d622), [commit 21790a1](https://github.com/TheBeems/CodexUsageDock/commit/21790a1ea60a9bfec14ec578d77356c5576472fb)

[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/v0.8.4...main
[Unreleased]: https://github.com/TheBeems/CodexUsageDock/compare/e9baaa73ac545e8d1855cddb774c75c544a458b5...main
[0.8.5]: https://github.com/TheBeems/CodexUsageDock/compare/v0.8.4...e9baaa73ac545e8d1855cddb774c75c544a458b5
[0.8.4]: https://github.com/TheBeems/CodexUsageDock/releases/tag/v0.8.4
[0.8.3]: https://github.com/TheBeems/CodexUsageDock/compare/46e4a985ce288ff6a3cbc1ada64471716598850b...ef7046cdd5a1478b452c29cfd0c6e2fd94134ab9
[0.8.2]: https://github.com/TheBeems/CodexUsageDock/compare/4fe435e6a03a790cffe5b03384ff4f2436e206d3...46e4a985ce288ff6a3cbc1ada64471716598850b
Expand Down
5 changes: 3 additions & 2 deletions CodexUsageDock.Tests/UsageDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
}

[Fact]
public void ClassifyWindows_RecognizesWeeklyWindowWhenItIsPrimary()

Check warning on line 78 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 @@ -95,7 +95,7 @@
}

[Fact]
public void Summary_UsesWeeklyWindowWhenFiveHourWindowIsInactive()

Check warning on line 98 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 @@ -112,7 +112,7 @@
}

[Fact]
public void MissingFiveHourWindow_CanBeDescribedAsInactive()

Check warning on line 115 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 @@ -121,7 +121,7 @@
}

[Fact]
public void AppServerWorkingDirectory_IsNeverInheritedFromSystem32ForBareCommand()

Check warning on line 124 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 @@ -207,7 +207,7 @@
using var service = _environment.CreateService();
using var page = new CodexUsageDockPage(service, _environment.CreateSettings());

Assert.Equal("0.8.4", CodexUsageDockMetadata.Version);
Assert.Equal("0.8.5", CodexUsageDockMetadata.Version);
Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title);
}

Expand Down Expand Up @@ -241,7 +241,8 @@
Assert.DoesNotContain("singleWindow", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("Time elapsed", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal);
Assert.DoesNotContain("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal);
Assert.Contains("weeklyForecastStatus", main.TemplateJson, StringComparison.Ordinal);
Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent()));
}

Expand Down Expand Up @@ -1219,7 +1220,7 @@
}

[Fact]
public void ParseResetCredits_UsesAvailableCountAndExpiryDetails()

Check warning on line 1223 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 @@ -1242,7 +1243,7 @@
}

[Fact]
public void ParseResetCredits_PreservesCountWhenDetailsAreNull()

Check warning on line 1246 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 @@ -1334,7 +1335,7 @@
}

[Fact]
public void ParseCredits_HandlesBalanceAndUnlimitedAccounts()

Check warning on line 1338 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 @@ -1376,7 +1377,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 1380 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 @@ -1437,7 +1438,7 @@
}

[Fact]
public void DetailFormatter_ReportsMissingExpiryRows()

Check warning on line 1441 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 @@ -1463,7 +1464,7 @@
}

[Fact]
public void DashboardSummary_WarnsWhenPrimaryLimitIsLow()

Check warning on line 1467 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
193 changes: 193 additions & 0 deletions CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
using System.Globalization;
using System.Xml.Linq;
using Xunit;
using Xunit.Abstractions;

namespace CodexUsageDock.Tests;

public sealed class WeeklyChartReadabilityTests(ITestOutputHelper output)
{
private static readonly XNamespace Svg = "http://www.w3.org/2000/svg";
private static readonly DateTimeOffset Now = new(2026, 10, 3, 7, 0, 0, TimeSpan.Zero);

[Fact]
public void MovingUnusedResetEstimatesKeepOneObservedLineWithoutResetMarkers()
{
var idle = Enumerable.Range(0, 200).Select(index =>
{
var at = Now.AddHours(-10).AddMinutes(index * 3);
return new UsageHistoryEntry(at, 100, at.AddDays(7), 10080);
}).ToArray();
var reset = idle[^1].ResetsAt!.Value;
var history = idle.Append(new UsageHistoryEntry(Now, 95, reset, 10080)).ToArray();
var chart = Create(history, reset);
var svg = Parse(chart);

Assert.Single(ObservedLines(svg));
Assert.Empty(ResetMarkers(svg));
Assert.Contains("No quota reset or reset-schedule changes", chart.AltText, StringComparison.Ordinal);
}

[Fact]
public void UnusedEstimateCanSettleBetweenObservationsWhenConsumptionStarts()
{
var previousAt = Now.AddMinutes(-5);
var startedAt = Now.AddMinutes(-2);
UsageHistoryEntry previous = new(previousAt, 100, previousAt.AddDays(7), 10080);
UsageHistoryEntry current = new(Now, 99, startedAt.AddDays(7), 10080);

Assert.False(WeeklyAllowanceRestoration.IsCycleChange(previous, current));
Assert.Empty(ResetMarkers(Parse(Create([previous, current], current.ResetsAt!.Value))));
}

[Theory]
[InlineData(100)]
[InlineData(80)]
public void FixedCycleChangesArePreservedEvenWithoutAnAllowanceIncrease(double remaining)
{
UsageHistoryEntry previous = new(Now.AddMinutes(-5), remaining, Now.AddDays(3), 10080);
UsageHistoryEntry current = new(Now, remaining, Now.AddDays(6), 10080);
Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current));
}

[Fact]
public void ScheduledRolloverAtFullAllowanceIsStillACycleChange()
{
UsageHistoryEntry previous = new(Now.AddMinutes(-5), 100, Now.AddMinutes(-1), 10080);
UsageHistoryEntry current = new(Now, 100, Now.AddDays(7), 10080);
Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current));
}

[Fact]
public void MissingWindowMetadataCannotBeAssumedToBeAnUnusedEstimate()
{
var previousAt = Now.AddMinutes(-5);
UsageHistoryEntry previous = new(previousAt, 100, previousAt.AddDays(7));
UsageHistoryEntry current = new(Now, 100, Now.AddDays(7));
Assert.True(WeeklyAllowanceRestoration.IsCycleChange(previous, current));
}

[Fact]
public void RetainedHistoryShowsOnlyRealResetEventsAndNoDuplicateRestorationLines()
{
var chart = CreateScenario();
var svg = Parse(chart);
output.WriteLine("CHART_PREVIEW_SVG=" + svg.ToString(SaveOptions.DisableFormatting));

Assert.Equal(2, ResetMarkers(svg).Length);
Assert.Equal(2, svg.Descendants(Svg + "g").Count(group => group.Attribute("data-axis")?.Value == "reset-detected"));
Assert.DoesNotContain(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "allowance-restored");
Assert.Equal("56%", Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "current")
.Attribute("data-axis-label")?.Value);
Assert.Contains("across quota resets", chart.AltText, StringComparison.Ordinal);
}

[Fact]
public void ResetAndNowLabelsFitInsideTheHeaderWithoutOverlapping()
{
var svg = Parse(CreateScenario());
var labels = svg.Descendants(Svg + "g")
.Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected")
.Select(LabelBounds).ToArray();
Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "NOW");
Assert.All(labels, bounds => Assert.InRange(bounds.Top + bounds.Height, 0, PlotTop(svg)));
AssertNonOverlapping(labels);
}

[Fact]
public void DenseCalendarLabelsKeepEveryDateWithoutOverlapping()
{
var svg = Parse(CreateScenario());
var dayLabels = svg.Descendants(Svg + "g").Where(group => group.Attribute("data-axis")?.Value == "horizontal").ToArray();
Assert.Equal(14, dayLabels.Length);
Assert.Equal("Sat 26", dayLabels[0].Attribute("data-axis-label")?.Value);
Assert.Equal("Fri 9", dayLabels[^1].Attribute("data-axis-label")?.Value);
Assert.All(dayLabels, group => Assert.Equal(2, group.Elements(Svg + "g").Count()));
var visibleLabels = dayLabels.SelectMany(group => group.Elements(Svg + "path").Any()
? new[] { group } : group.Elements(Svg + "g")).Select(LabelBounds).ToArray();
AssertNonOverlapping(visibleLabels);
}

[Fact]
public void BillionTokenScaleUsesACompactLabelAtTheTopGridline()
{
var svg = Parse(CreateScenario());
var topTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens"
&& group.Attribute("data-axis-label")?.Value == "1B");
Assert.Equal(PlotTop(svg) - ChartLabelGlyphs.Height / 2, LabelBounds(topTokenLabel).Top);
}

private static WeeklyUsageTrendChart CreateScenario()
{
var firstReset = new DateTimeOffset(2026, 9, 26, 16, 0, 0, TimeSpan.Zero);
var currentStart = new DateTimeOffset(2026, 10, 2, 21, 10, 0, TimeSpan.Zero);
var idle = Enumerable.Range(0, 124).Select(index =>
{
var at = firstReset.AddMinutes(index * 20);
return new UsageHistoryEntry(at, 100, at.AddDays(7), 10080);
}).ToArray();
var oldReset = idle[^1].ResetsAt!.Value;
UsageHistoryEntry[] history =
[
new(firstReset.AddHours(-2), 1, firstReset.AddHours(3), 10080),
.. idle,
new(idle[^1].RecordedAt.AddMinutes(10), 95, oldReset, 10080),
new(currentStart.AddDays(-2), 70, oldReset, 10080),
new(currentStart.AddDays(-1), 60, oldReset, 10080),
new(currentStart.AddMinutes(-15), 36, oldReset, 10080),
new(currentStart, 100, currentStart.AddDays(7), 10080),
new(Now, 56, currentStart.AddDays(7), 10080),
];
var tokens = new LocalTokenUsageSnapshot(
[
new(new DateOnly(2026, 9, 26), 850_000_000),
new(new DateOnly(2026, 9, 27), 350_000_000),
new(new DateOnly(2026, 10, 1), 250_000_000),
new(new DateOnly(2026, 10, 2), 600_000_000),
new(new DateOnly(2026, 10, 3), 200_000_000),
], Now, LocalTokenUsageStatus.Complete);
return Create(history, currentStart.AddDays(7), new(new DateTimeOffset(2026, 10, 5, 0, 0, 0, TimeSpan.Zero), 0, true), tokens);
}

private static WeeklyUsageTrendChart Create(UsageHistoryEntry[] history, DateTimeOffset reset,
UsageTrendForecast? forecast = null, LocalTokenUsageSnapshot? tokens = null) =>
Assert.IsType<WeeklyUsageTrendChart>(WeeklyUsageTrendChartRenderer.Create(history,
new RateLimitWindow(44, 10080, reset), Now, TimeSpan.FromMinutes(15), forecast, tokens,
CultureInfo.InvariantCulture, TimeZoneInfo.Utc));

private static XDocument Parse(WeeklyUsageTrendChart chart) =>
XDocument.Parse(Uri.UnescapeDataString(chart.ImageUrl["data:image/svg+xml;utf8,".Length..]));

private static XElement[] ObservedLines(XDocument svg) => svg.Descendants(Svg + "polyline")
.Where(line => line.Attribute("stroke-dasharray") is null).ToArray();

private static XElement[] ResetMarkers(XDocument svg) => svg.Descendants(Svg + "line")
.Where(line => line.Attribute("data-marker")?.Value == "quota-reset").ToArray();

private static double PlotTop(XDocument svg) => double.Parse(svg.Descendants(Svg + "line")
.Single(line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "100")
.Attribute("y1")!.Value, CultureInfo.InvariantCulture);

private static (double Left, double Top, double Width, double Height) LabelBounds(XElement group)
{
var translate = group.Descendants(Svg + "path").First().Attribute("transform")!.Value["translate(".Length..^1].Split(' ');
var rendered = group.Attribute("data-rendered-label")!.Value;
return (double.Parse(translate[0], CultureInfo.InvariantCulture), double.Parse(translate[1], CultureInfo.InvariantCulture),
rendered.Sum(character => ChartLabelGlyphs.Values[character].Width), ChartLabelGlyphs.Height);
}

private static void AssertNonOverlapping((double Left, double Top, double Width, double Height)[] labels)
{
for (var first = 0; first < labels.Length; first++)
{
for (var second = first + 1; second < labels.Length; second++)
{
var a = labels[first];
var b = labels[second];
Assert.False(a.Left < b.Left + b.Width && a.Left + a.Width > b.Left
&& a.Top < b.Top + b.Height && a.Top + a.Height > b.Top,
$"Labels overlap: {a} and {b}.");
}
}
}
}
2 changes: 1 addition & 1 deletion CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public void ResetAndRestartPreserveObservedHistoryAndBootstrapFromLearnedUsage(b
var observed = Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null);
Assert.Equal(2, observed.Attribute("points")!.Value.Split(' ').Length);
Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null);
Assert.Contains("amber: reset/restoration", data.GetProperty("chartLegend").GetString(), StringComparison.Ordinal);
Assert.Contains("amber: reset/restoration", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal);

var otherAccount = afterReset with { AccountKey = "other-account" };
restarted.RecordHistory(otherAccount, now);
Expand Down
2 changes: 1 addition & 1 deletion CodexUsageDock/CodexUsageDock.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<OutputType>WinExe</OutputType>
<RootNamespace>CodexUsageDock</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.8.4</Version>
<Version>0.8.5</Version>

<WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
Expand Down
16 changes: 5 additions & 11 deletions CodexUsageDock/Pages/CodexUsageDockPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,6 @@ internal static string FormatMainDataJson(
data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable,
snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed,
now, TrendMaximumGap(refreshInterval), tokenUsage);
data["chartLegend"] = tokenUsage?.Status switch
{
LocalTokenUsageStatus.Complete => "Line: remaining % · amber: reset/restoration · bars: local tokens/day (right axis)",
LocalTokenUsageStatus.Partial => "Line: remaining % · amber: reset/restoration · bars: partial local tokens/day (right axis)",
_ => "Line: remaining % · amber: reset/restoration · local tokens unavailable",
};
return data.ToJsonString();
}

Expand Down Expand Up @@ -438,13 +432,13 @@ private static void AddWeeklyTrendData(
data["weeklyTrendChartUrl"] = chart.ImageUrl;
data["weeklyTrendChartAlt"] = chart.AltText;
var forecastLegend = dataAvailable && trend?.Forecast is not null
? "dashed: forecast"
: "forecast pending sufficient fresh measurements";
? "dashed: estimate"
: "forecast pending";
data["weeklyTrendLegend"] = tokenUsage?.Status switch
{
LocalTokenUsageStatus.Complete => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · bars: local tokens per day · amber: detected resets/restorations",
LocalTokenUsageStatus.Partial => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected resets/restorations",
_ => $"Solid: remaining quota (%) · breaks: resets/restorations · {forecastLegend} · local token data unavailable · amber: detected resets/restorations",
LocalTokenUsageStatus.Complete => $"Line: remaining % · amber: reset/restoration · bars: local tokens/day (right axis) · {forecastLegend}",
LocalTokenUsageStatus.Partial => $"Line: remaining % · amber: reset/restoration · bars: partial local tokens per day (right axis) · {forecastLegend}",
_ => $"Line: remaining % · amber: reset/restoration · local token data unavailable · {forecastLegend}",
};

var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now);
Expand Down
6 changes: 5 additions & 1 deletion CodexUsageDock/Pages/UsageDashboardCard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ internal static class UsageDashboardCard
"size": "stretch", "spacing": "small"
},
{
"type": "TextBlock", "text": "${chartLegend} · dashed: estimate",
"type": "TextBlock", "text": "${weeklyTrendLegend}",
"isSubtle": true, "size": "small", "wrap": true, "spacing": "small"
},
{
"type": "TextBlock", "text": "${weeklyForecastStatus}",
"isSubtle": true, "size": "small", "wrap": true, "spacing": "small"
}
]
Expand Down
20 changes: 19 additions & 1 deletion CodexUsageDock/WeeklyAllowanceRestoration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,25 @@ internal static bool IsIncrease(UsageHistoryEntry previous, UsageHistoryEntry cu
internal static bool IsCycleChange(UsageHistoryEntry previous, UsageHistoryEntry current) =>
previous.ResetsAt.HasValue
&& current.ResetsAt.HasValue
&& (current.ResetsAt.Value - previous.ResetsAt.Value).Duration() > ResetCycleTolerance;
&& (current.ResetsAt.Value - previous.ResetsAt.Value).Duration() > ResetCycleTolerance
&& !IsUnusedResetEstimate(previous, current);

private static bool IsUnusedResetEstimate(UsageHistoryEntry previous, UsageHistoryEntry current)
{
if (previous.RemainingPercent != 100
|| previous.WindowMinutes is not > 0
|| current.WindowMinutes != previous.WindowMinutes)
{
return false;
}

// Before first consumption, the server can report a deadline one full window from each reading.
// The deadline may settle between readings when usage starts; neither change proves a reset.
var duration = TimeSpan.FromMinutes(previous.WindowMinutes.Value);
return (previous.ResetsAt!.Value - previous.RecordedAt - duration).Duration() <= ResetCycleTolerance
&& current.ResetsAt!.Value - previous.RecordedAt - duration >= -ResetCycleTolerance
&& current.ResetsAt.Value - current.RecordedAt - duration <= ResetCycleTolerance;
Comment on lines +88 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fixed cycle changes after a full-allowance sample

When a 100% observation is recorded near the start of a real cycle, its fixed reset is also one full window after RecordedAt; if a genuine early rollover or schedule change later moves the reset forward, these one-sided bounds can still classify the pair as an unused moving estimate. IsCycleChange then returns false, so the chart connects both cycles without a reset marker and downstream forecasting can treat them as continuous. Constrain this exception to evidence that the deadline is actually moving with closely spaced idle readings rather than allowing any later reset up to current.RecordedAt + duration.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

}

internal static bool IsInCycle(UsageHistoryEntry sample, DateTimeOffset resetsAt, int windowMinutes) =>
(!sample.WindowMinutes.HasValue || sample.WindowMinutes.Value == windowMinutes)
Expand Down
Loading