From 1b83d5527439511c89ab0c251cee0b74740678b4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:00:33 +0200 Subject: [PATCH 1/3] Use account token totals and prepare 0.8.11 --- CHANGELOG.md | 6 + .../AccountTokenChartTests.cs | 150 ++++++++++++++++++ CodexUsageDock.Tests/CodexUsageTableTests.cs | 6 +- CodexUsageDock.Tests/UsageAlertTests.cs | 3 +- CodexUsageDock.Tests/UsageDashboardTests.cs | 15 +- CodexUsageDock.Tests/UsageDataTests.cs | 50 +++--- CodexUsageDock.Tests/UsageFreshnessTests.cs | 2 +- .../WeeklyChartReadabilityTests.cs | 8 +- .../WeeklyResetIntegrationTests.cs | 10 +- CodexUsageDock/CodexUsageDock.csproj | 2 +- CodexUsageDock/CodexUsageService.cs | 2 +- CodexUsageDock/Pages/CodexUsageDockPage.cs | 46 ++++-- .../Pages/CodexUsageDockSettingsPage.cs | 2 +- CodexUsageDock/Pages/UsageDashboardCard.cs | 4 + CodexUsageDock/UsagePresentation.cs | 3 +- CodexUsageDock/WeeklyUsageTrendChart.cs | 44 ++--- DEVELOPMENT.md | 4 +- PRIVACY.md | 6 +- README.md | 4 +- USER_GUIDE.md | 6 +- 20 files changed, 284 insertions(+), 89 deletions(-) create mode 100644 CodexUsageDock.Tests/AccountTokenChartTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1b24b..78c5d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +## [0.8.11] - 2026-10-20 + +### Changed + +- Use account-wide daily token totals from the Codex account service in the main chart, preserve server calendar dates, and show the source, last read time, and unavailable states without substituting local totals. + ## [0.8.10] - 2026-10-17 ### Changed diff --git a/CodexUsageDock.Tests/AccountTokenChartTests.cs b/CodexUsageDock.Tests/AccountTokenChartTests.cs new file mode 100644 index 0000000..cdc9a47 --- /dev/null +++ b/CodexUsageDock.Tests/AccountTokenChartTests.cs @@ -0,0 +1,150 @@ +using System.Globalization; +using System.Text.Json; +using System.Xml.Linq; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class AccountTokenChartTests : IDisposable +{ + private const string Account = "synthetic-chart-account"; + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly DateOnly TokenDate = new(2026, 9, 8); + private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + private static CodexUsageSnapshot Quota() => CodexUsageSnapshot.Loading with + { + Secondary = new(20, 10080, Now.AddDays(6)), + UpdatedAt = Now, + Source = UsageDataSource.AppServer, + AccountKey = Account, + DefaultBucketId = "codex", + }; + + private static AccountUsageSnapshot Tokens(AccountUsageStatus status = AccountUsageStatus.Available) => + new(Account, Now, [new(TokenDate, 890_123_456)], status); + + private static JsonDocument Dashboard(AccountUsageSnapshot accountUsage, CodexUsageSnapshot? quota = null, + bool enabled = true) => JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson( + quota ?? Quota(), Now, false, [], [new(Now.AddMinutes(-1), 81), new(Now, 80)], TimeSpan.FromMinutes(1), + accountUsage: accountUsage, showAccountActivity: enabled)); + + private static XDocument ParseChart(string imageUrl) => + XDocument.Parse(Uri.UnescapeDataString(imageUrl["data:image/svg+xml;utf8,".Length..])); + + private static IEnumerable TokenBars(XDocument chart) => + chart.Descendants(Svg + "rect").Where(rect => rect.Attribute("data-series")?.Value == "daily-tokens"); + + [Fact] + public async Task AccountTotalsReachTheMainChartIndependentlyOfLocalTokensAndQuotaRefresh() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var quota = Quota(); + using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota, + localTokenUsageReader: (_, _, _, _) => Task.FromResult(new LocalTokenUsageSnapshot( + [new(TokenDate, 12_345)], Now, LocalTokenUsageStatus.Complete)), + clock: () => Now, accountUsageReader: _ => pending.Task); + + await service.RefreshAsync(); + await service.TokenRefreshTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.False(service.AccountUsageRefreshTask.IsCompleted); + var before = service.GetPresentation(); + using (var loading = Dashboard(before.AccountUsage, before.Usage)) + { + Assert.Empty(TokenBars(ParseChart(loading.RootElement.GetProperty("weeklyTrendChartUrl").GetString()!))); + Assert.Contains("unavailable", loading.RootElement.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + } + + var updates = 0; + service.Updated += (_, _) => Interlocked.Increment(ref updates); + var accountUsage = Tokens(); + pending.SetResult(accountUsage); + await service.AccountUsageRefreshTask.WaitAsync(TimeSpan.FromSeconds(10)); + + var view = service.GetPresentation(); + Assert.Same(accountUsage, view.AccountUsage); + Assert.Equal(12_345, Assert.Single(view.TokenUsage.Days).TotalTokens); + Assert.True(updates > 0); + using var dashboard = Dashboard(view.AccountUsage, view.Usage); + var data = dashboard.RootElement; + var bar = Assert.Single(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!))); + Assert.Equal("890123456", bar.Attribute("data-tokens")?.Value); + Assert.Equal("2026-09-08", bar.Attribute("data-date")?.Value); + Assert.Contains("Codex account service", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + Assert.Contains("Last read:", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("local tokens", data.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ServerDatesAndWholeDayTotalsSurviveTimeZoneAndQuotaBoundaryChanges() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("UTC+14 test", TimeSpan.FromHours(14), "UTC+14 test", "UTC+14 test"); + var tokens = new AccountUsageSnapshot(Account, Now, + [new(new(2026, 9, 8), 0), new(new(2026, 9, 9), 765_432)], AccountUsageStatus.Partial); + var chart = Assert.IsType(WeeklyUsageTrendChartRenderer.Create( + [new(Now.AddDays(-3), 95), new(Now, 80)], Quota().Secondary!, Now, TimeSpan.FromDays(4), + null, tokens, CultureInfo.InvariantCulture, timeZone)); + + var svg = ParseChart(chart.ImageUrl); + var bar = Assert.Single(TokenBars(svg)); + Assert.Equal("2026-09-09", bar.Attribute("data-date")?.Value); + Assert.Equal("765432", bar.Attribute("data-tokens")?.Value); + Assert.Contains("Mon 7 Sep: not reported", chart.AltText, StringComparison.Ordinal); + Assert.Contains("Tue 8 Sep: 0", chart.AltText, StringComparison.Ordinal); + Assert.Contains("Account token data is partial", chart.AltText, StringComparison.Ordinal); + Assert.Contains("time zone is unspecified", chart.AltText, StringComparison.Ordinal); + Assert.Contains("not clipped to the quota window", chart.AltText, StringComparison.Ordinal); + Assert.Contains(svg.Descendants(Svg + "g"), group => + group.Attribute("data-axis-label")?.Value == "Account tokens/day (right axis)"); + } + + [Theory] + [InlineData(false, "unavailable")] + [InlineData(true, "unsupported")] + public void UnavailableAccountDataOmitsTokenBarsWithoutRemovingTheQuotaTrend(bool unsupported, string label) + { + using var dashboard = Dashboard(Tokens(unsupported ? AccountUsageStatus.Unsupported : AccountUsageStatus.Unavailable)); + var data = dashboard.RootElement; + var chart = ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!); + + Assert.Empty(TokenBars(chart)); + Assert.NotEmpty(chart.Descendants(Svg + "polyline")); + Assert.Contains(label, data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void DisabledAccountActivityDoesNotShowCachedAccountTotals() + { + using var dashboard = Dashboard(Tokens(), enabled: false); + var data = dashboard.RootElement; + + Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!))); + Assert.Contains("disabled in settings", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("another-account")] + public void UnknownOrDifferentQuotaAccountCannotDisplayAccountTotals(string? quotaAccount) + { + using var dashboard = Dashboard(Tokens(), Quota() with { AccountKey = quotaAccount }); + var data = dashboard.RootElement; + + Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!))); + Assert.Contains("unavailable", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void SummaryWithoutDailyBucketsDoesNotInventZeroDays() + { + using var dashboard = Dashboard(new(Account, Now, [], AccountUsageStatus.Partial, LifetimeTokens: 123_456)); + var data = dashboard.RootElement; + + Assert.Empty(TokenBars(ParseChart(data.GetProperty("weeklyTrendChartUrl").GetString()!))); + Assert.Contains("not reported", data.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.Contains("partial account data", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + } +} diff --git a/CodexUsageDock.Tests/CodexUsageTableTests.cs b/CodexUsageDock.Tests/CodexUsageTableTests.cs index 1f1f187..16c9c06 100644 --- a/CodexUsageDock.Tests/CodexUsageTableTests.cs +++ b/CodexUsageDock.Tests/CodexUsageTableTests.cs @@ -16,7 +16,7 @@ public void RemappedWeeklyQuotaAppearsOnceAndOtherCategoriesStaySeparate() DefaultBucketId = "codex", Buckets = [new("codex", "codex", weekly, null), new("spark", "Spark", weekly, null)], }; - var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false, AccountUsageSnapshot.Unavailable); var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1)); var groups = UsageQuotaGroups.Create(snapshot); Assert.Equal(2, groups.Count); @@ -36,7 +36,7 @@ public void FullTokenTotalsAndLocalDatesUseUnconstrainedTextRows() var snapshot = CodexUsageSnapshot.Loading with { UpdatedAt = now, Source = UsageDataSource.AppServer }; var tokens = new LocalTokenUsageSnapshot([new(new DateOnly(2026, 9, 13), 51_732_933), new(new DateOnly(2026, 9, 15), 9_999_999_999)], now, LocalTokenUsageStatus.Complete); - var view = new UsagePresentation(snapshot, [new(now, 51)], [new(now, 51)], new([], null), tokens, false); + var view = new UsagePresentation(snapshot, [new(now, 51)], [new(now, 51)], new([], null), tokens, false, AccountUsageSnapshot.Unavailable); var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1), timeZone); Assert.Contains("Observed: Wed 16 Sep 2026 00:01", body, StringComparison.Ordinal); Assert.Contains("Times are local (Test local)", body, StringComparison.Ordinal); @@ -53,7 +53,7 @@ public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() var quota = new CodexUsageSnapshot(new(100, 300, now.AddHours(-1)), null, null, null, new(0, null), now.AddHours(-2), UsageDataSource.LastConfirmed, null, [new("extra", "Extra | quota", new(0, 60, now.AddHours(1)), null)], DefaultBucketId: "codex"); - var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false, AccountUsageSnapshot.Unavailable); var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1)); Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); diff --git a/CodexUsageDock.Tests/UsageAlertTests.cs b/CodexUsageDock.Tests/UsageAlertTests.cs index c7b6c44..3169793 100644 --- a/CodexUsageDock.Tests/UsageAlertTests.cs +++ b/CodexUsageDock.Tests/UsageAlertTests.cs @@ -394,6 +394,7 @@ private static UsagePresentation Presentation( weeklyHistory ?? Array.Empty(), new AdaptiveWeeklyUsageHistory([], null), LocalTokenUsageSnapshot.Unavailable, - isLoading); + isLoading, + AccountUsageSnapshot.Unavailable); } } diff --git a/CodexUsageDock.Tests/UsageDashboardTests.cs b/CodexUsageDock.Tests/UsageDashboardTests.cs index c016e1a..33d0765 100644 --- a/CodexUsageDock.Tests/UsageDashboardTests.cs +++ b/CodexUsageDock.Tests/UsageDashboardTests.cs @@ -22,14 +22,15 @@ private static CodexUsageSnapshot Snapshot() => CodexUsageSnapshot.Loading with Source = UsageDataSource.AppServer, Error = null, DefaultBucketId = "codex", + AccountKey = "synthetic-account", }; private static JsonDocument Render(CodexUsageSnapshot snapshot, UsageHistoryEntry[]? history = null, bool showFiveHourLimit = true) => JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [], history ?? [], TimeSpan.FromMinutes(1), - tokenUsage: new LocalTokenUsageSnapshot([new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)], - Now, LocalTokenUsageStatus.Complete), showFiveHourLimit: showFiveHourLimit)); + accountUsage: new AccountUsageSnapshot("synthetic-account", Now, + [new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)], AccountUsageStatus.Available), showFiveHourLimit: showFiveHourLimit)); private static XDocument Chart(JsonElement data) => XDocument.Parse( Uri.UnescapeDataString(data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); @@ -79,7 +80,7 @@ public void HidingFiveHourLimitPreservesOtherDurationsAndOmitsEmptyAdditionalGro } [Fact] - public void ForecastBasisAppearsBelowChartWithoutRestoringTokenCaption() + public void ForecastBasisAndAccountTokenSourceAppearBelowChart() { var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray(); using var data = Render(Snapshot() with { Secondary = new(40, 10080, Now.AddDays(4)) }, history); @@ -89,15 +90,19 @@ public void ForecastBasisAppearsBelowChartWithoutRestoringTokenCaption() && items.EnumerateArray().Any(child => child.TryGetProperty("url", out var url) && url.GetString() == "${weeklyTrendChartUrl}")); var chartItems = chart.GetProperty("items"); - Assert.Equal(2, chartItems.GetArrayLength()); + Assert.Equal(3, chartItems.GetArrayLength()); Assert.Equal("${weeklyForecastStatus}", chartItems[1].GetProperty("text").GetString()); Assert.Equal("${hasForecastBasis}", chartItems[1].GetProperty("$when").GetString()); Assert.True(chartItems[1].GetProperty("wrap").GetBoolean()); + Assert.Equal("${weeklyTokenSource}", chartItems[2].GetProperty("text").GetString()); + Assert.True(chartItems[2].GetProperty("wrap").GetBoolean()); + Assert.False(chartItems[2].TryGetProperty("$when", out _)); + Assert.Contains("Codex account service", data.RootElement.GetProperty("weeklyTokenSource").GetString(), StringComparison.Ordinal); Assert.True(data.RootElement.GetProperty("hasForecastBasis").GetBoolean()); Assert.DoesNotContain("weeklyTrendLegend", UsageDashboardCard.TemplateJson, StringComparison.Ordinal); var details = CodexUsageDockPage.FormatForecastDetails(data.RootElement); Assert.Contains("Quota forecast:", details, StringComparison.Ordinal); - Assert.Contains("Local token bars: available session logs.", details, StringComparison.Ordinal); + Assert.Contains("Codex account service", details, StringComparison.Ordinal); Assert.Contains("Amber markers: reset or restored allowance.", details, StringComparison.Ordinal); } diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 5b76f0d..89034cd 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -26,17 +26,17 @@ private static XDocument ParseSvg(string imageUrl) return XDocument.Parse(Uri.UnescapeDataString(imageUrl[Prefix.Length..])); } - private static LocalTokenUsageSnapshot TokenUsage(DateOnly date, long totalTokens) => + private static AccountUsageSnapshot TokenUsage(DateOnly date, long totalTokens) => TokenUsage((date, totalTokens)); - private static LocalTokenUsageSnapshot TokenUsage( + private static AccountUsageSnapshot TokenUsage( DateOnly firstDate, long firstTotal, DateOnly secondDate, long secondTotal) => TokenUsage((firstDate, firstTotal), (secondDate, secondTotal)); - private static LocalTokenUsageSnapshot TokenUsage( + private static AccountUsageSnapshot TokenUsage( DateOnly firstDate, long firstTotal, DateOnly secondDate, @@ -45,11 +45,12 @@ private static LocalTokenUsageSnapshot TokenUsage( long thirdTotal) => TokenUsage((firstDate, firstTotal), (secondDate, secondTotal), (thirdDate, thirdTotal)); - private static LocalTokenUsageSnapshot TokenUsage(params (DateOnly Date, long TotalTokens)[] days) => + private static AccountUsageSnapshot TokenUsage(params (DateOnly Date, long TotalTokens)[] days) => new( + "synthetic-account", + new DateTimeOffset(days.Max(day => day.Date).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), days.Select(day => new DailyTokenUsage(day.Date, day.TotalTokens)).ToArray(), - DateTimeOffset.UtcNow, - LocalTokenUsageStatus.Complete); + AccountUsageStatus.Available); [Fact] public void SettingsDefaultToShowingAllDockUsageInformation() @@ -207,7 +208,7 @@ public void DetailsPageUsesTheProjectReleaseVersion() using var service = _environment.CreateService(); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); - Assert.Equal("0.8.10", CodexUsageDockMetadata.Version); + Assert.Equal("0.8.11", CodexUsageDockMetadata.Version); Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title); } @@ -318,7 +319,7 @@ public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() null, now, UsageDataSource.AppServer, - null); + null, AccountKey: "synthetic-account"); var data = CodexUsageDockPage.FormatMainDataJson( snapshot, now, @@ -334,7 +335,7 @@ public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() new UsageHistoryEntry(now, 80), ], refreshInterval: TimeSpan.FromMinutes(1), - tokenUsage: TokenUsage( + accountUsage: TokenUsage( new DateOnly(2026, 7, 16), 250_000, new DateOnly(2026, 7, 17), 500_000)); @@ -533,13 +534,13 @@ public void WeeklyTrendChartRendersObservedForecastAndDailyTokens() .Where(group => group.Attribute("data-axis")?.Value == "tokens") .Select(group => group.Attribute("data-axis-label")!.Value)); Assert.Contains("Solid line connects sampled values across measurement gaps", result.AltText, StringComparison.Ordinal); - Assert.Contains("locally observed total tokens", result.AltText, StringComparison.Ordinal); + Assert.Contains("account-wide total tokens", result.AltText, StringComparison.Ordinal); Assert.DoesNotContain("NaN", result.ImageUrl, StringComparison.Ordinal); Assert.DoesNotContain("Infinity", result.ImageUrl, StringComparison.Ordinal); } [Fact] - public void WeeklyTrendMarksPartialLocalTokenCoverage() + public void WeeklyTrendMarksPartialAccountTokenCoverage() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -549,11 +550,12 @@ public void WeeklyTrendMarksPartialLocalTokenCoverage() Secondary = new RateLimitWindow(20, 10080, reset), UpdatedAt = now, Source = UsageDataSource.AppServer, + AccountKey = "synthetic-account", }; - var tokenUsage = new LocalTokenUsageSnapshot( + var tokenUsage = new AccountUsageSnapshot( + "synthetic-account", now, [new DailyTokenUsage(new DateOnly(2026, 7, 11), 250_000)], - now, - LocalTokenUsageStatus.Partial); + AccountUsageStatus.Partial); var data = CodexUsageDockPage.FormatMainDataJson( snapshot, now, @@ -565,19 +567,19 @@ [new DailyTokenUsage(new DateOnly(2026, 7, 11), 250_000)], new UsageHistoryEntry(now, 80), ], refreshInterval: TimeSpan.FromMinutes(1), - tokenUsage: tokenUsage); + accountUsage: tokenUsage); using var document = JsonDocument.Parse(data); var root = document.RootElement; var chart = ParseSvg(root.GetProperty("weeklyTrendChartUrl").GetString()!); - Assert.Contains("incomplete session logs", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); - Assert.Contains("Local token data is partial", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.Contains("partial account data", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + Assert.Contains("Account token data is partial", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); Assert.Single(chart.Descendants(Svg + "rect"), rect => rect.Attribute("data-series")?.Value == "daily-tokens"); } [Fact] - public void WeeklyTrendOmitsOnlyTokenBarsWhenLocalTokenDataIsUnavailable() + public void WeeklyTrendOmitsOnlyTokenBarsWhenAccountTokenDataIsUnavailable() { var windowStart = new DateTimeOffset(2026, 7, 10, 9, 0, 0, TimeSpan.Zero); var reset = windowStart.AddDays(7); @@ -591,7 +593,7 @@ public void WeeklyTrendOmitsOnlyTokenBarsWhenLocalTokenDataIsUnavailable() now, TimeSpan.FromDays(3), forecast: null, - tokenUsage: LocalTokenUsageSnapshot.Unavailable); + accountUsage: AccountUsageSnapshot.Unavailable); var result = Assert.IsType(chart); var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); @@ -599,7 +601,7 @@ public void WeeklyTrendOmitsOnlyTokenBarsWhenLocalTokenDataIsUnavailable() Assert.Contains(root.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null); Assert.DoesNotContain(root.Descendants(Svg + "rect"), rect => rect.Attribute("data-series")?.Value == "daily-tokens"); Assert.DoesNotContain(root.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens"); - Assert.Contains("Local token data is unavailable", result.AltText, StringComparison.Ordinal); + Assert.Contains("Account token data is unavailable", result.AltText, StringComparison.Ordinal); } [Theory] @@ -653,7 +655,7 @@ public void WeeklyTrendChartBreaksAtQuotaIncreaseAndRetainsCalendarDayTokens() now, TimeSpan.FromMinutes(15), forecast: null, - tokenUsage: TokenUsage(new DateOnly(2026, 7, 16), 125_000), + accountUsage: TokenUsage(new DateOnly(2026, 7, 16), 125_000), timeZone: TimeZoneInfo.Utc); var result = Assert.IsType(chart); @@ -894,7 +896,7 @@ public void WeeklyTrendChartAttributesUseToLocalCalendarDays() now, TimeSpan.FromDays(1), forecast: null, - tokenUsage: TokenUsage(new DateOnly(2026, 7, 17), 640_000), + accountUsage: TokenUsage(new DateOnly(2026, 7, 17), 640_000), culture: culture, timeZone: TimeZoneInfo.Utc); @@ -935,7 +937,7 @@ public void WeeklyTrendChartUsesEqualWidthCalendarDayColumns() now, TimeSpan.FromMinutes(15), forecast: null, - tokenUsage: TokenUsage( + accountUsage: TokenUsage( new DateOnly(2026, 7, 16), 200_000, new DateOnly(2026, 7, 17), 400_000), culture: CultureInfo.GetCultureInfo("nl-NL"), @@ -979,7 +981,7 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheTokenBarBaseline() now, TimeSpan.FromDays(2), forecast: null, - tokenUsage: TokenUsage(new DateOnly(2026, 7, 13), 300_000)); + accountUsage: TokenUsage(new DateOnly(2026, 7, 13), 300_000)); var result = Assert.IsType(chart); var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); diff --git a/CodexUsageDock.Tests/UsageFreshnessTests.cs b/CodexUsageDock.Tests/UsageFreshnessTests.cs index f8601fb..e0a5988 100644 --- a/CodexUsageDock.Tests/UsageFreshnessTests.cs +++ b/CodexUsageDock.Tests/UsageFreshnessTests.cs @@ -135,7 +135,7 @@ public void AdditionalBucketsUseSeparateTruthfulDurationRows() DefaultBucketId = "default", }; - var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var view = new UsagePresentation(snapshot, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false, AccountUsageSnapshot.Unavailable); var details = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); Assert.DoesNotContain("Other quota windows", CodexUsageDockPage.FormatDetailsBody(snapshot, Now), StringComparison.Ordinal); diff --git a/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs index 45c1a81..e40c359 100644 --- a/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs +++ b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs @@ -183,7 +183,7 @@ public void DenseCalendarLabelsKeepEveryDateWithoutOverlapping() public void LegendExplainsObservedForecastAndIndependentTokenSeries() { var svg = Parse(CreateScenario()); - Assert.Equal(["Quota remaining (%)", "Forecast", "Local tokens/day (right axis)"], + Assert.Equal(["Quota remaining (%)", "Forecast", "Account tokens/day (right axis)"], LegendLabels(svg).Select(group => group.Attribute("data-axis-label")!.Value)); var observed = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-legend-series")?.Value == "remaining"); Assert.Null(observed.Attribute("stroke-dasharray")); @@ -278,19 +278,19 @@ private static WeeklyUsageTrendChart CreateScenario() new(currentStart, 100, currentStart.AddDays(7), 10080), new(Now, 56, currentStart.AddDays(7), 10080), ]; - var tokens = new LocalTokenUsageSnapshot( + var tokens = new AccountUsageSnapshot("synthetic-account", Now, [ 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); + ], AccountUsageStatus.Available); 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) => + UsageTrendForecast? forecast = null, AccountUsageSnapshot? tokens = null) => Assert.IsType(WeeklyUsageTrendChartRenderer.Create(history, new RateLimitWindow(44, 10080, reset), Now, TimeSpan.FromMinutes(15), forecast, tokens, CultureInfo.InvariantCulture, TimeZoneInfo.Utc)); diff --git a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs index 9ea91eb..0f8ed12 100644 --- a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs +++ b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs @@ -101,22 +101,26 @@ public async Task TokenReadIncludesRetainedDaysBeforeTheCurrentQuotaWindow() readStart = start; readEnd = end; return Task.FromResult(new LocalTokenUsageSnapshot([new(previousDate, 1234)], now, LocalTokenUsageStatus.Complete)); - }, clock: () => now); + }, clock: () => now, + accountUsageReader: _ => Task.FromResult(new AccountUsageSnapshot("test-account", now, + [new(previousDate, 2345)], AccountUsageStatus.Available))); service.RecordHistory(Snapshot("test-account", Reset.AddDays(-2), 20, Reset), Reset.AddDays(-2)); await service.RefreshAsync(); await service.TokenRefreshTask; + await service.AccountUsageRefreshTask; Assert.Equal(now.AddDays(-7), readStart); Assert.Equal(now, readEnd); var presentation = service.GetPresentation(); using var dashboard = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, now, false, presentation.PrimaryHistory, presentation.WeeklyHistory, TimeSpan.FromMinutes(1), - tokenUsage: presentation.TokenUsage)); + accountUsage: presentation.AccountUsage)); var chart = XDocument.Parse(Uri.UnescapeDataString( dashboard.RootElement.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); Assert.Single(chart.Descendants(Svg + "rect"), bar => bar.Attribute("data-series")?.Value == "daily-tokens" && bar.Attribute("data-date")?.Value == previousDate.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) - && bar.Attribute("data-tokens")?.Value == "1234"); + && bar.Attribute("data-tokens")?.Value == "2345"); + Assert.Equal(1234, Assert.Single(presentation.TokenUsage.Days).TotalTokens); } private static CodexUsageSnapshot Snapshot(string account, DateTimeOffset at, double remaining, DateTimeOffset reset) => diff --git a/CodexUsageDock/CodexUsageDock.csproj b/CodexUsageDock/CodexUsageDock.csproj index 09ef771..5564a56 100644 --- a/CodexUsageDock/CodexUsageDock.csproj +++ b/CodexUsageDock/CodexUsageDock.csproj @@ -3,7 +3,7 @@ WinExe CodexUsageDock app.manifest - 0.8.10 + 0.8.11 10.0.26100.68-preview net10.0-windows10.0.26100.0 diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 162271a..2b5e5ac 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -95,7 +95,7 @@ internal UsagePresentation GetPresentation() { return new(Current, _primaryHistory.ToArray(), _weeklyHistory.ToArray(), _historyContext is null ? new([], null) : _adaptiveWeeklyUsageStore.Snapshot, - CurrentTokenUsage, _isLoading); + CurrentTokenUsage, _isLoading, CurrentAccountUsage); } } } diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index bcd32a1..9191831 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -27,7 +27,7 @@ public CodexUsageDockPage(CodexUsageService usage, CodexUsageDockSettingsPage se Details = null; Commands = [ - new CommandContextItem(new RefreshUsageCommand(_usage)) + new CommandContextItem(new RefreshUsageCommand(_usage, refreshAccountActivity: true)) { Title = "Refresh now", }, @@ -56,8 +56,9 @@ internal static string FormatMainDataJson( TimeSpan refreshInterval, bool adaptiveWeeklyForecastEnabled = true, AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, - LocalTokenUsageSnapshot? tokenUsage = null, - bool showFiveHourLimit = true) + AccountUsageSnapshot? accountUsage = null, + bool showFiveHourLimit = true, + bool showAccountActivity = true) { var freshness = GetFreshnessState(snapshot, now, refreshInterval); var dataAvailable = IsDataAvailable(snapshot, freshness); @@ -93,10 +94,13 @@ internal static string FormatMainDataJson( data["hasForecastBasis"] = dataAvailable && weeklyTrend?.Forecast is not null; // Host warning yellow is hard to read in light themes; use its contrasting foreground and bold text. data["forecastColor"] = "Default"; + var accountTokens = showAccountActivity && snapshot.AccountKey is { } accountKey + && string.Equals(accountKey, accountUsage?.AccountKey, StringComparison.Ordinal) + ? accountUsage : AccountUsageSnapshot.Unavailable; AddWeeklyTrendData( data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable, snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed, - now, TrendMaximumGap(refreshInterval), tokenUsage); + now, TrendMaximumGap(refreshInterval), accountTokens, showAccountActivity); foreach (var window in data["quotaGroups"]![0]!["windows"]!.AsArray().OfType() .Where(window => window["hasWeeklyForecast"]!.GetValue())) { @@ -426,11 +430,26 @@ private static void AddWeeklyTrendData( bool canShowObservedHistory, DateTimeOffset now, TimeSpan maximumGap, - LocalTokenUsageSnapshot? tokenUsage) + AccountUsageSnapshot? accountUsage, + bool showAccountActivity) { data["weeklyTrendAvailable"] = false; data["weeklyForecastStatus"] = trend?.ForecastStatus ?? "Forecast unavailable."; - data["weeklyTrendLegend"] = "Solid: remaining quota (%) · dashed: forecast · amber and breaks: resets/restored allowance. Bars: local tokens per day on an independent right-hand scale."; + var tokenSource = !showAccountActivity + ? "Account tokens disabled in settings; no token bars." + : accountUsage?.Status switch + { + AccountUsageStatus.Available => "Tokens: Codex account service · account-wide totals.", + AccountUsageStatus.Partial => "Tokens: Codex account service · partial account data.", + AccountUsageStatus.Unsupported => "Account tokens unsupported by this Codex CLI; no token bars.", + _ => "Account token data unavailable; no token bars.", + }; + if (showAccountActivity && accountUsage?.Status is AccountUsageStatus.Available or AccountUsageStatus.Partial) + { + tokenSource += $" Server calendar days; time zone unspecified. Last read: {FormatLocalTime(accountUsage.UpdatedAt, "ddd d MMM HH:mm zzz")}."; + } + data["weeklyTokenSource"] = tokenSource; + data["weeklyTrendLegend"] = tokenSource + " Amber markers: reset or restored allowance."; data["weeklyRestorationAvailable"] = false; if (window is not { } validWindow || !UsageFreshness.IsValidWindow(validWindow, now) @@ -449,7 +468,7 @@ private static void AddWeeklyTrendData( now, maximumGap, dataAvailable ? trend?.Forecast : null, - tokenUsage); + accountUsage); if (chart is null) { return; @@ -458,12 +477,6 @@ private static void AddWeeklyTrendData( data["weeklyTrendAvailable"] = true; data["weeklyTrendChartUrl"] = chart.ImageUrl; data["weeklyTrendChartAlt"] = chart.AltText; - data["weeklyTrendLegend"] = tokenUsage?.Status switch - { - LocalTokenUsageStatus.Complete => "Local token bars: available session logs. Amber markers: reset or restored allowance.", - LocalTokenUsageStatus.Partial => "Local token bars: incomplete session logs. Amber markers: reset or restored allowance.", - _ => "Local token data unavailable. Amber markers: reset or restored allowance.", - }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); if (restorations.Length > 0) @@ -724,7 +737,7 @@ internal static string FormatForecastDetails(JsonElement data) ## Chart guide - **Line:** remaining allowance (%) over the past seven days, retained across resets. The timing of consumption within gaps is unknown. Resets and restored allowance break the line and are marked in amber. - - **Bars:** local tokens per day, using the independent right axis. Tokens measure activity, not quota consumption. + - **Bars:** account-wide tokens per day from the Codex account service, using the independent right axis. Server calendar dates are kept unchanged; their time zone is unspecified. Missing days are not zero usage. Account totals are not clipped to the quota window and measure activity, not quota consumption. - **Dashed line:** a conditional estimate. Usable learned history can start a forecast from a fresh measurement after a reset or gap. Recent pace joins after 30 minutes of continuous measurements. - **Time remaining:** the separate time bar counts down to the scheduled reset, independently of quota consumption. Similar allowance and time-bar lengths do not guarantee a sustainable pace. """).ToString(); @@ -752,8 +765,9 @@ private void UpdatePresentation() _usage.RefreshInterval, _settings.UseAdaptiveWeeklyForecast, presentation.AdaptiveWeeklyHistory, - presentation.TokenUsage, - _settings.ShowFiveHourLimit); + presentation.AccountUsage, + _settings.ShowFiveHourLimit, + _settings.ShowAccountActivity); using var mainData = JsonDocument.Parse(_mainContent.DataJson); var data = mainData.RootElement; _information.Update(FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index b8407f8..c0a86de 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -83,7 +83,7 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(ShowAccountActivityKey, true) { Label = "Show account activity", - Description = "Read account-wide daily tokens from the Codex service after quotas load. Older CLI versions may not support this.", + Description = "Read account-wide daily tokens for the main chart and account activity page. Older Codex CLI versions may not support this.", }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index 87b4de8..41846b8 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -73,6 +73,10 @@ internal static class UsageDashboardCard { "type": "TextBlock", "text": "${weeklyForecastStatus}", "wrap": true, "isSubtle": true, "size": "small", "spacing": "small", "$when": "${hasForecastBasis}" + }, + { + "type": "TextBlock", "text": "${weeklyTokenSource}", "wrap": true, + "isSubtle": true, "size": "small", "spacing": "small" } ] }, diff --git a/CodexUsageDock/UsagePresentation.cs b/CodexUsageDock/UsagePresentation.cs index 4158d43..d4a3b51 100644 --- a/CodexUsageDock/UsagePresentation.cs +++ b/CodexUsageDock/UsagePresentation.cs @@ -6,4 +6,5 @@ internal sealed record UsagePresentation( IReadOnlyList WeeklyHistory, AdaptiveWeeklyUsageHistory AdaptiveWeeklyHistory, LocalTokenUsageSnapshot TokenUsage, - bool IsLoading); + bool IsLoading, + AccountUsageSnapshot AccountUsage); diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index d900e77..6ef59b8 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -29,7 +29,7 @@ internal static class WeeklyUsageTrendChartRenderer DateTimeOffset now, TimeSpan maximumGap, UsageTrendForecast? forecast, - LocalTokenUsageSnapshot? tokenUsage = null, + AccountUsageSnapshot? accountUsage = null, CultureInfo? culture = null, TimeZoneInfo? timeZone = null) { @@ -57,7 +57,7 @@ internal static class WeeklyUsageTrendChartRenderer var resets = DetectResets(samples, windowStart); var restorations = WeeklyAllowanceRestoration.Detect(samples, window, effectiveNow, plotStart); var dailyUse = CreateCalendarDays(plotStart, window.ResetsAt, effectiveNow, displayTimeZone); - ApplyDailyTokens(dailyUse, tokenUsage); + ApplyDailyTokens(dailyUse, accountUsage); var tokenScaleMaximum = GetTokenScaleMaximum(dailyUse); var calendarScale = new CalendarDayScale(dailyUse); var renderedSegments = DownsampleSegments(SplitAtDiscontinuities(samples, @@ -97,7 +97,7 @@ internal static class WeeklyUsageTrendChartRenderer window.ResetsAt, effectiveNow, dailyUse, - tokenUsage, + accountUsage, tokenScaleMaximum, displayCulture, displayTimeZone); @@ -124,7 +124,7 @@ private static void AddLegend(XElement document, bool hasForecast, bool hasToken if (hasTokenBars) { - const string label = "Local tokens/day (right axis)"; + const string label = "Account tokens/day (right axis)"; var labelWidth = MeasureChartLabel(NormalizeChartLabel(label)); var x = Width - Right - labelWidth - 24; document.Add(new XElement(Svg + "rect", new XAttribute("x", Format(x)), @@ -338,18 +338,22 @@ private static DateTimeOffset AtLocalMidnight(DateOnly date, TimeZoneInfo timeZo private static void ApplyDailyTokens( IReadOnlyList days, - LocalTokenUsageSnapshot? tokenUsage) + AccountUsageSnapshot? accountUsage) { - if (tokenUsage is null || tokenUsage.Status == LocalTokenUsageStatus.Unavailable) + if (accountUsage?.Status is not (AccountUsageStatus.Available or AccountUsageStatus.Partial)) { return; } - var totals = tokenUsage.Days.ToDictionary(day => day.Date, day => day.TotalTokens); + var totals = accountUsage.Days.ToDictionary(day => day.Date, day => day.TotalTokens); foreach (var day in days) { - day.HasTokenData = true; - day.TotalTokens = Math.Max(0, totals.GetValueOrDefault(day.Date)); + // Account buckets already identify server calendar days; do not shift or clip their totals. + if (totals.TryGetValue(day.Date, out var total)) + { + day.HasTokenData = true; + day.TotalTokens = Math.Max(0, total); + } } } @@ -878,13 +882,13 @@ private static string FormatAltText( DateTimeOffset windowEnd, DateTimeOffset now, IReadOnlyList dailyUse, - LocalTokenUsageSnapshot? tokenUsage, + AccountUsageSnapshot? accountUsage, long tokenScaleMaximum, CultureInfo culture, TimeZoneInfo timeZone) { var period = $"{TimeZoneInfo.ConvertTime(windowStart, timeZone).ToString("ddd d MMM HH:mm", culture)} to {TimeZoneInfo.ConvertTime(windowEnd, timeZone).ToString("ddd d MMM HH:mm", culture)}"; - var daily = tokenUsage is null ? string.Empty : FormatDailyTokenAltText(dailyUse, tokenUsage, tokenScaleMaximum, culture); + var daily = FormatDailyTokenAltText(dailyUse, accountUsage, tokenScaleMaximum, culture); var forecastText = forecast switch { { ReachesLimitBeforeReset: true } => $" Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, culture, timeZone, includeDate: true)}; actual usage may differ.", @@ -897,7 +901,7 @@ private static string FormatAltText( var resetText = resets.Length > 0 ? $" {resets.Length} quota reset or reset-schedule change{(resets.Length == 1 ? " was" : "s were")} detected. {string.Join(" ", resets.Select(reset => FormatResetDetection(reset, culture, timeZone)))} Markers show detection times; the precise event time between observations is unknown." : " No quota reset or reset-schedule changes were detected in the retained observations."; - return $"Weekly quota trend from {period}. Recorded observations from the preceding seven days are retained across quota resets. Remaining quota changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observation{(sampleCount == 1 ? string.Empty : "s")}. The left vertical scale is remaining quota from 0% to 100%.{(tokenUsage is null ? string.Empty : " The independent right scale is locally observed total tokens per calendar day.")} Horizontal labels are local calendar dates. Now separates recorded observations from future dates, and Scheduled reset marks the scheduled end of the current quota window. Amber Reset labels mark detected historical cycle changes. Solid line connects sampled values across measurement gaps; allowance increases break the line, as do quota cycle changes; dashed line is a conditional forecast.{restorationText}{resetText}{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Recorded observations from the preceding seven days are retained across quota resets. Remaining quota changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observation{(sampleCount == 1 ? string.Empty : "s")}. The left vertical scale is remaining quota from 0% to 100%. The independent right scale is account-wide total tokens per server calendar day from the Codex account service. Horizontal labels are local calendar dates for quota observations; token buckets keep the server's dates, whose time zone is unspecified. Now separates recorded observations from future dates, and Scheduled reset marks the scheduled end of the current quota window. Amber Reset labels mark detected historical cycle changes. Solid line connects sampled values across measurement gaps; allowance increases break the line, as do quota cycle changes; dashed line is a conditional forecast.{restorationText}{resetText}{forecastText} {daily}"; } private static string FormatResetDetection(QuotaCycleChange reset, CultureInfo culture, TimeZoneInfo timeZone) => @@ -905,25 +909,25 @@ private static string FormatResetDetection(QuotaCycleChange reset, CultureInfo c private static string FormatDailyTokenAltText( IReadOnlyList days, - LocalTokenUsageSnapshot? tokenUsage, + AccountUsageSnapshot? accountUsage, long tokenScaleMaximum, CultureInfo culture) { - if (tokenUsage is null || tokenUsage.Status == LocalTokenUsageStatus.Unavailable) + if (accountUsage?.Status is not (AccountUsageStatus.Available or AccountUsageStatus.Partial)) { - return "Local token data is unavailable, so no token bars are shown."; + return "Account token data is unavailable, so no token bars are shown. Local session totals are not substituted."; } - var status = tokenUsage.Status == LocalTokenUsageStatus.Partial - ? "Local token data is partial." - : "Local token data is complete."; + var status = accountUsage.Status == AccountUsageStatus.Partial + ? "Account token data is partial." + : "Account token data is available."; var totals = string.Join( ", ", - days.Select(day => $"{day.Date.ToString("ddd d MMM", culture)}: {day.TotalTokens.ToString("N0", culture)}")); + days.Select(day => $"{day.Date.ToString("ddd d MMM", culture)}: {(day.HasTokenData ? day.TotalTokens.ToString("N0", culture) : "not reported")}")); var scale = tokenScaleMaximum > 0 ? $" Token bars use a right-axis maximum of {tokenScaleMaximum.ToString("N0", culture)}." : string.Empty; - return $"{status}{scale} Calendar-day token totals are {totals}. The first and last days can be partial, and the current day is measured through now."; + return $"{status}{scale} Server calendar-day token totals are {totals}. Missing days are not zero usage. Reported totals cover the server's calendar day and are not clipped to the quota window; they can change at the next account refresh."; } private static string FormatCompactTokens(double tokens) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3b9117c..0b85739 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -56,7 +56,7 @@ Run a testhost only on a compatible Windows architecture. ARM64 Windows can run Always specify both `Platform` and its matching RID. Omitting `-r win-x64` or `-r win-arm64` can make MSBuild combine the host architecture with a conflicting `PlatformTarget`. -Tests must use `TestEnvironment` or explicitly inject both history stores and a temporary settings path. Only the production parameterless service constructor selects the current user's storage. Never instantiate it in a unit test. Each test owns and deletes its unique temporary directory; synthetic quota events must never reach real user history. Token tests await `TokenRefreshTask` separately because `RefreshAsync` completes when limits are published. Forecast arithmetic fixtures should use continuous measurements; gap and stale-data scenarios are tested separately. +Tests must use `TestEnvironment` or explicitly inject both history stores and a temporary settings path. Only the production parameterless service constructor selects the current user's storage. Never instantiate it in a unit test. Each test owns and deletes its unique temporary directory; synthetic quota events must never reach real user history. Local-token tests await `TokenRefreshTask` and account-token chart tests await `AccountUsageRefreshTask` separately because `RefreshAsync` completes when limits are published. Forecast arithmetic fixtures should use continuous measurements; gap and stale-data scenarios are tested separately. ### Dock notifications @@ -64,6 +64,8 @@ Tests must use `TestEnvironment` or explicitly inject both history stores and a ### Chart labels +`AccountTokenChartTests` covers the main chart's account source, independent asynchronous updates, account isolation, disabled/unsupported reads, unchanged server dates, whole-day totals, and missing-versus-zero values. Local token logs remain a separate text-view source and must never become an implicit chart fallback. + The chart uses baked Segoe UI outlines because the Windows SVG image renderer does not support SVG text. `CodexUsageDock/ChartLabelGlyphs.cs` is generated source; regenerate it on Windows with `scripts/generate-chart-labels.ps1` and review the visual result. Include every character used by a new label in the generator alphabet, including punctuation; the readability regression checks the rendered labels for replacement characters. Runtime builds use the checked-in outlines and need no font loading or drawing dependency. Marker labels sit just above the plot and use an upper header row when they would overlap. The next scheduled reset uses an amber label, a heavier dashed line, and a downward triangle at the plot's upper edge. diff --git a/PRIVACY.md b/PRIVACY.md index 04aa132..14b894f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,7 +6,7 @@ Codex Usage Dock is a local Windows extension for PowerToys Command Palette. It ## Data accessed -The extension accesses the locally installed Codex app-server to request account rate-limit information. If that request is unavailable, it may read local Codex session metadata as a fallback. It also reads aggregate `token_count` records from active and archived local Codex session logs to total locally observed tokens by calendar day. It does not retain or display prompts, responses, tool output, thread names, or session source paths for this analysis. This data is used only to render the usage indicators on the user's device. +The extension accesses the locally installed Codex app-server to request account rate-limit information. If that request is unavailable, it may read local Codex session metadata as a fallback. The main chart uses aggregate daily token counts from the Codex account service, retaining its server calendar dates. Its source and last read time are shown; unavailable account data is never replaced with local totals. The extension also reads aggregate `token_count` records from active and archived local Codex session logs for the separately labelled local totals in the text view. It does not retain or display prompts, responses, tool output, thread names, or session source paths for this analysis. This data is used only to render the usage indicators on the user's device. ## Data collection and transmission @@ -18,11 +18,11 @@ Communication initiated by the extension is limited to the local Codex app-serve Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, retention, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. -The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt for the retained seven-day chart horizon after a restart, including observations before the latest quota reset. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. Adaptive forecast learning stores up to 56 days plus the current partial hour of UTC-hour aggregates: observed duration and quota consumption, plus the last measurement needed to resume learning. The separate `adaptive-weekly-forecast.hourly.json` file is account/category scoped. Existing reset-relative forecast files are preserved unchanged and their valid learned summaries may supply historical startup estimates. These files contain no raw account identifiers, session content, prompts, or messages and are never transmitted. Pausing learning excludes measurements collected while paused, including across restarts. Confirmed deletion clears active hourly learning and disables reuse of preserved legacy profiles and old raw measurements; original history files are retained. An unreadable hourly file is not overwritten during refresh; confirmed deletion can reset it. +The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Account daily totals, local token totals, and per-file read positions are kept only in memory. Local totals are rebuilt for the retained seven-day observation horizon after a restart, including observations before the latest quota reset. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. Adaptive forecast learning stores up to 56 days plus the current partial hour of UTC-hour aggregates: observed duration and quota consumption, plus the last measurement needed to resume learning. The separate `adaptive-weekly-forecast.hourly.json` file is account/category scoped. Existing reset-relative forecast files are preserved unchanged and their valid learned summaries may supply historical startup estimates. These files contain no raw account identifiers, session content, prompts, or messages and are never transmitted. Pausing learning excludes measurements collected while paused, including across restarts. Confirmed deletion clears active hourly learning and disables reuse of preserved legacy profiles and old raw measurements; original history files are retained. An unreadable hourly file is not overwritten during refresh; confirmed deletion can reset it. Account-scoped history uses a one-way hash of the account identity supplied by Codex, combined with the default quota category, as an opaque local directory name. Raw account identifiers and email addresses are not stored or shown in diagnostics. Legacy history without account attribution is not imported into a verified account; unverified observations remain in memory and do not train saved forecasts. The last confirmed usage snapshot is retained only in memory during an outage, with its original timestamp. Diagnostics exposes field availability and bounded status messages, not raw service errors, credentials, or personal paths. -Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. +Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state, hides account token bars in the main chart, and stops new optional reads. Optional retained history stores at most 27,000 aggregate quota observations with UTC timestamps and reset times, separated by hashed account/category context. The selected retention is 7, 30, or 90 days; collection starts only after opting in. Explicit CSV/JSON exports create local files without account IDs or conversation content. Users control deletion of retained observations and exported copies separately. Neither retained observations nor exports contain prompts or task contents. diff --git a/README.md b/README.md index c834e2d..a8b74e0 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Dock-band and dashboard percentages show **remaining allowance**. Codex and addi Values refresh every minute by default. Open **Usage information** from the dashboard's **More** menu for the data source, measurement time, reset-credit expirations, credits balance, and explanatory text. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. -The weekly forecast summary appears with its remaining allowance and next reset, and the graph appears before additional categories. The line shows remaining allowance and keeps the past seven days visible across resets, with amber reset/restoration markers. Moving reset estimates while allowance remains unused do not create extra reset events. A forecast that reaches zero ends at a point on the baseline. Its local date and rounded time appear once above the chart, with an accessible description on the point. With usable learned history, a fresh measurement can start a historical forecast immediately after a reset or gap; recent pace takes over after 30 continuous minutes with a meaningful decrease. Without usable history, those 30 minutes and a meaningful decrease are required. When a fresh forecast is available, its quota basis and historical coverage appear in a compact caption below the chart. These details, local token-log availability, and the daily budget are also available under **Usage information**. Startup estimates use age-weighted observed hours across multiple dates, without requiring a complete week. Daily token bars and their independent right-hand axis appear by default when available; they reflect local session activity and are **not an exact measure of quota consumption**. +The weekly forecast summary appears with its remaining allowance and next reset, and the graph appears before additional categories. The line shows remaining allowance and keeps the past seven days visible across resets, with amber reset/restoration markers. Moving reset estimates while allowance remains unused do not create extra reset events. A forecast that reaches zero ends at a point on the baseline. Its local date and rounded time appear once above the chart, with an accessible description on the point. With usable learned history, a fresh measurement can start a historical forecast immediately after a reset or gap; recent pace takes over after 30 continuous minutes with a meaningful decrease. Without usable history, those 30 minutes and a meaningful decrease are required. When a fresh forecast is available, its quota basis and historical coverage appear in a compact caption below the chart. These details, the daily token source, and the daily budget are also available under **Usage information**. Startup estimates use age-weighted observed hours across multiple dates, without requiring a complete week. Daily token bars and their independent right-hand axis use **account-wide totals from the Codex account service**, matching the server calendar dates shown in the Codex profile. The chart identifies this source and its last read time. Missing, disabled, or unsupported account data is shown explicitly, without substituting local session totals or treating missing days as zero. Tokens are **not an exact measure of quota consumption**. See the [user guide](USER_GUIDE.md#charts-and-forecasts) for chart legends, adaptive forecasts, and data handling. @@ -85,7 +85,7 @@ Open Command Palette and select **Codex Usage settings**. | Refresh interval | Choose 1, 5, or 15 minutes. | | Adaptive weekly forecast | Enable or pause learning from local usage history. Pausing keeps learned history; deletion requires confirmation. | | **Enable usage alerts** | Receive optional low-allowance, projected-limit, and expiring-reset notifications. Off by default. | -| **Show account activity** | Enable optional account-wide usage reads on compatible CLI versions. | +| **Show account activity** | Enable account-wide daily tokens in the main chart and account activity page on compatible CLI versions. | | **Retain usage observations** | Enable optional history retention for 7, 30, or 90 days. | Switching between combined and separate items does not convert existing pins. Add the desired bands through Dock customization; inactive bands stay hidden. Settings persist after restart, and saving failures are shown on the settings page. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index febcccb..1a3a758 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -28,7 +28,9 @@ A forecast measurement segment starts again after a reset-cycle change, an allow Projections are conditional estimates, not calibrated probabilities. Estimated exhaustion at least a day away shows a date; nearer estimates are rounded up to a quarter hour. The chart retains the calculated points. The budget under Usage information shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. The allowance and time-remaining bars show separate remaining proportions; neither they nor the budget guarantee future consumption. The dashboard therefore omits reassuring pace labels such as “On track”. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. -The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The Usage information page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. +The main chart reads account-wide daily token totals from the Codex account service, using the same server calendar dates as the Codex profile. Its legend identifies the source and last read time; the right-axis legend says **Account tokens/day**. Server dates are kept unchanged because the service does not specify their time zone. A reported day retains its full total even when the quota window starts or ends partway through that date. Missing days are not treated as zero. Limits update first, and account token bars update independently, at most every five minutes automatically. **Refresh now** on the main page also requests a new account read. Disabled, unsupported, or unavailable account data is identified explicitly; the chart never substitutes local session totals. These token totals measure activity, not allowance consumption. + +The extension still reads aggregate `token_count` records from active and archived local Codex session logs for the separately labelled local totals in **Codex usage in text**. The Usage information page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. ### Text alternative @@ -54,7 +56,7 @@ The extension saves these choices in `CodexUsageDock/settings.json` under the cu ## Account activity -**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these reads and hide account token bars in the main chart. Account identity must match before and after the request. Missing days and fields are not zero usage. The main chart uses the same unchanged server dates for its token bars; the server does not specify their time zone. No account activity is written to disk by this feature. ## History and exports From 1b099508edbbf12f25f91e943505a15d94181136 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:02:04 +0200 Subject: [PATCH 2/3] Link 0.8.11 changelog to implementation PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c5d45..4177ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Changed -- Use account-wide daily token totals from the Codex account service in the main chart, preserve server calendar dates, and show the source, last read time, and unavailable states without substituting local totals. +- Use account-wide daily token totals from the Codex account service in the main chart, preserve server calendar dates, and show the source, last read time, and unavailable states without substituting local totals. ([PR #41](https://github.com/TheBeems/CodexUsageDock/pull/41)) ## [0.8.10] - 2026-10-17 From 6792f47833046d1558f67f22dc3f3dc0bc691e8d Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:10:34 +0200 Subject: [PATCH 3/3] Prevent chart-scale overflow for large account totals --- CHANGELOG.md | 4 ++++ .../AccountTokenChartTests.cs | 21 +++++++++++++++++++ CodexUsageDock/WeeklyUsageTrendChart.cs | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4177ee1..6a05d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). - Use account-wide daily token totals from the Codex account service in the main chart, preserve server calendar dates, and show the source, last read time, and unavailable states without substituting local totals. ([PR #41](https://github.com/TheBeems/CodexUsageDock/pull/41)) +### Fixed + +- Keep the chart available when the account service reports very large token totals, without overflowing the scale calculation. ([PR #41](https://github.com/TheBeems/CodexUsageDock/pull/41)) + ## [0.8.10] - 2026-10-17 ### Changed diff --git a/CodexUsageDock.Tests/AccountTokenChartTests.cs b/CodexUsageDock.Tests/AccountTokenChartTests.cs index cdc9a47..ee9865b 100644 --- a/CodexUsageDock.Tests/AccountTokenChartTests.cs +++ b/CodexUsageDock.Tests/AccountTokenChartTests.cs @@ -147,4 +147,25 @@ public void SummaryWithoutDailyBucketsDoesNotInventZeroDays() Assert.Contains("not reported", data.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); Assert.Contains("partial account data", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); } + + [Theory] + [InlineData(5_000_000_000_000_000_001)] + [InlineData(long.MaxValue)] + public void LargeAccountTotalsDoNotOverflowTheChartScale(long tokens) + { + using var payload = JsonDocument.Parse(JsonSerializer.Serialize(new + { + dailyUsageBuckets = new[] { new { startDate = "2026-09-08", tokens } }, + })); + var accountUsage = AccountUsageParser.Parse(payload.RootElement, Account, Now); + using var dashboard = Dashboard(accountUsage); + var data = dashboard.RootElement; + var imageUrl = data.GetProperty("weeklyTrendChartUrl").GetString()!; + var bar = Assert.Single(TokenBars(ParseChart(imageUrl))); + + Assert.True(data.GetProperty("weeklyTrendAvailable").GetBoolean()); + Assert.Equal(tokens.ToString(CultureInfo.InvariantCulture), bar.Attribute("data-tokens")?.Value); + Assert.DoesNotContain("NaN", imageUrl, StringComparison.Ordinal); + Assert.DoesNotContain("Infinity", imageUrl, StringComparison.Ordinal); + } } diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 6ef59b8..ee96040 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -368,7 +368,7 @@ private static long GetTokenScaleMaximum(IReadOnlyList days) var magnitude = (long)Math.Pow(10, Math.Floor(Math.Log10(maximum))); var normalized = maximum / (double)magnitude; var nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; - return checked(nice * magnitude); + return magnitude > long.MaxValue / nice ? long.MaxValue : Math.Max(maximum, nice * magnitude); } private static void AddCalendarDayGrid(