From f56e4ed6c2465a9f89f273412439e4735b79d2a4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:52:03 +0200 Subject: [PATCH 1/5] Simplify quota dashboard and unify Codex and Spark bars --- CHANGELOG.md | 2 + CodexUsageDock.Tests/UsageDashboardTests.cs | 231 +++++++++++ CodexUsageDock.Tests/UsageDataTests.cs | 82 ++-- CodexUsageDock.Tests/UsageFreshnessTests.cs | 4 +- CodexUsageDock/ChartLabelGlyphs.cs | 50 +++ CodexUsageDock/Pages/CodexUsageDockPage.cs | 337 +++++++++------- CodexUsageDock/Pages/UsageDashboardCard.cs | 421 ++++---------------- CodexUsageDock/UsageFreshness.cs | 1 + CodexUsageDock/WeeklyUsageTrendChart.cs | 175 +++----- DEVELOPMENT.md | 6 +- README.md | 6 +- USER_GUIDE.md | 10 +- scripts/generate-chart-labels.ps1 | 60 +++ 13 files changed, 740 insertions(+), 645 deletions(-) create mode 100644 CodexUsageDock.Tests/UsageDashboardTests.cs create mode 100644 CodexUsageDock/ChartLabelGlyphs.cs create mode 100644 scripts/generate-chart-labels.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4d8fe..95cba17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Changed +- Simplify the dashboard with shared Used/time bars for Codex and Spark quota windows, collapsed Details, concise forecast status, and a larger used-quota chart with smooth labels. Show local token bars only with Details and keep missing or stale values explicit. + - Clarify contributor guidance on installation authorization, isolated test storage, architecture verification, and changelog links, and consolidate repeated instructions. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/0661e46a12ae9ae906a1ddbcb8fc70874fa2071d)) ## [0.8.0] - 2026-09-15 diff --git a/CodexUsageDock.Tests/UsageDashboardTests.cs b/CodexUsageDock.Tests/UsageDashboardTests.cs new file mode 100644 index 0000000..8de3489 --- /dev/null +++ b/CodexUsageDock.Tests/UsageDashboardTests.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageDashboardTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 15, 12, 0, 0, TimeSpan.Zero); + private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + private static CodexUsageSnapshot Snapshot() => CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, Now.AddHours(4)), + Secondary = new RateLimitWindow(33, 10080, Now.AddDays(4)), + UpdatedAt = Now, + Source = UsageDataSource.AppServer, + Error = null, + DefaultBucketId = "codex", + }; + + private static JsonDocument Render(CodexUsageSnapshot snapshot, bool details = false, UsageHistoryEntry[]? history = null) => + 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), + showDetails: details)); + + private static XDocument Chart(JsonElement data) => XDocument.Parse( + Uri.UnescapeDataString(data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); + + [Fact] + public void CodexAndSparkShareUsedAndTimeBarsWithoutCombiningCategories() + { + var snapshot = Snapshot(); + snapshot = snapshot with + { + Buckets = + [ + new RateLimitBucket("codex", "Codex", snapshot.Primary, snapshot.Secondary), + new RateLimitBucket("spark", "Codex Spark", + new RateLimitWindow(25, 300, Now.AddHours(4)), + new RateLimitWindow(80, 10080, Now.AddDays(6))), + ], + }; + using var data = Render(snapshot); + var groups = data.RootElement.GetProperty("quotaGroups"); + Assert.Equal(2, groups.GetArrayLength()); + Assert.Equal("Codex Spark", groups[1].GetProperty("name").GetString()); + var codex = groups[0].GetProperty("windows"); + var spark = groups[1].GetProperty("windows"); + Assert.Equal(2, codex.GetArrayLength()); + Assert.Equal(2, spark.GetArrayLength()); + Assert.Equal("25%", codex[0].GetProperty("usedPercent").GetString()); + Assert.Equal("20%", spark[0].GetProperty("elapsedPercent").GetString()); + Assert.Equal(codex[0].GetProperty("usedBarUrl").GetString(), spark[0].GetProperty("usedBarUrl").GetString()); + Assert.Equal(codex[0].GetProperty("elapsedBarUrl").GetString(), spark[0].GetProperty("elapsedBarUrl").GetString()); + Assert.Equal("33%", codex[1].GetProperty("usedPercent").GetString()); + Assert.Equal("80%", spark[1].GetProperty("usedPercent").GetString()); + Assert.DoesNotContain("available", codex[0].GetProperty("usedBarAlt").GetString()!, StringComparison.Ordinal); + } + + [Fact] + public void ArbitraryDefaultDurationsAppearOnceWithTheirActualDuration() + { + var snapshot = Snapshot(); + snapshot = snapshot with + { + Buckets = [new RateLimitBucket("codex", "Codex", + new RateLimitWindow(10, 90, Now.AddMinutes(45)), snapshot.Secondary)], + }; + using var data = Render(snapshot); + var groups = data.RootElement.GetProperty("quotaGroups"); + Assert.Equal(1, groups.GetArrayLength()); + var windows = groups[0].GetProperty("windows"); + Assert.Equal(3, windows.GetArrayLength()); + Assert.Equal("90-minute", windows[2].GetProperty("title").GetString()); + Assert.Equal("50%", windows[2].GetProperty("elapsedPercent").GetString()); + } + + [Fact] + public void WeeklyPrimaryDoesNotInventAnUnreportedWeeklyWindow() + { + using var data = Render(Snapshot() with + { + Buckets = [new RateLimitBucket("spark", "Spark", + new RateLimitWindow(25, 10080, Now.AddDays(6)), null)], + }); + var spark = data.RootElement.GetProperty("quotaGroups")[1]; + Assert.False(spark.GetProperty("hasInactiveWindows").GetBoolean()); + var window = Assert.Single(spark.GetProperty("windows").EnumerateArray()); + Assert.Equal("Weekly", window.GetProperty("title").GetString()); + Assert.Equal("14%", window.GetProperty("elapsedPercent").GetString()); + } + + [Fact] + public void MissingExpiredAndReportedZeroRemainDistinct() + { + using var data = Render(Snapshot() with + { + Primary = null, + Secondary = new RateLimitWindow(0, 10080, Now.AddDays(7)), + Buckets = [new RateLimitBucket("spark", "Spark", + new RateLimitWindow(0, 300, Now.AddMinutes(-1)), null)], + }); + var groups = data.RootElement.GetProperty("quotaGroups"); + Assert.Contains("Not reported", groups[0].GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + var zero = Assert.Single(groups[0].GetProperty("windows").EnumerateArray()); + Assert.Equal("0%", zero.GetProperty("usedPercent").GetString()); + Assert.Equal("0%", zero.GetProperty("elapsedPercent").GetString()); + Assert.Empty(groups[1].GetProperty("windows").EnumerateArray()); + Assert.Contains("Awaiting refresh", groups[1].GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData(double.NaN, 300)] + [InlineData(double.PositiveInfinity, 300)] + [InlineData(101, 300)] + [InlineData(50, 0)] + [InlineData(50, int.MaxValue)] + public void MalformedWindowDoesNotProduceAZeroBarOrThrow(double used, int minutes) + { + using var data = Render(Snapshot() with { Primary = new RateLimitWindow(used, minutes, Now.AddHours(4)) }); + Assert.Single(data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void OldObservationsCannotLookLikeCurrentTimeComparisons(bool lastConfirmed) + { + using var data = Render(Snapshot() with + { + Source = lastConfirmed ? UsageDataSource.LastConfirmed : UsageDataSource.AppServer, + UpdatedAt = Now.AddHours(-1), + }); + Assert.True(data.RootElement.GetProperty("hasNotice").GetBoolean()); + foreach (var window in data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()) + { + Assert.Equal("—", window.GetProperty("elapsedPercent").GetString()); + Assert.False(window.GetProperty("elapsedAvailable").GetBoolean()); + Assert.Equal(string.Empty, window.GetProperty("elapsedBarUrl").GetString()); + Assert.Contains("last observation", window.GetProperty("usedBarAlt").GetString(), StringComparison.Ordinal); + } + Assert.Contains("paused", data.RootElement.GetProperty("weeklyForecastSummary").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void OldHistoryDoesNotInflateTheContinuousForecastProgress() + { + using var data = Render(Snapshot(), history: + [ + new(Now.AddDays(-2), 100), new(Now.AddDays(-2).AddMinutes(5), 90), + new(Now.AddMinutes(-19), 75), new(Now.AddMinutes(-10), 70), new(Now, 67), + ]); + Assert.Equal("Forecast · 19/30 min of continuous data", + data.RootElement.GetProperty("weeklyForecastSummary").GetString()); + Assert.DoesNotContain("On track", data.RootElement.GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public void DefaultChartRisesWithUsageAndDetailsOnlyAddsTheTokenOverlay() + { + UsageHistoryEntry[] history = [new(Now.AddMinutes(-5), 80), new(Now, 67)]; + using var compact = Render(Snapshot(), history: history); + using var expanded = Render(Snapshot(), details: true, history: history); + var chart = Chart(compact.RootElement); + var detailChart = Chart(expanded.RootElement); + Assert.DoesNotContain(chart.Descendants(Svg + "rect"), node => node.Attribute("data-series")?.Value == "daily-tokens"); + Assert.Single(detailChart.Descendants(Svg + "rect"), node => node.Attribute("data-series")?.Value == "daily-tokens"); + var line = Assert.Single(chart.Descendants(Svg + "polyline")); + var points = line.Attribute("points")!.Value.Split(' '); + var firstY = double.Parse(points[0].Split(',')[1], CultureInfo.InvariantCulture); + var lastY = double.Parse(points[^1].Split(',')[1], CultureInfo.InvariantCulture); + Assert.True(lastY < firstY, "Increasing usage must move up the used-quota axis."); + Assert.Equal(line.Attribute("points")!.Value, Assert.Single(detailChart.Descendants(Svg + "polyline")).Attribute("points")!.Value); + Assert.Contains("20% to 33%", compact.RootElement.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); + Assert.NotEmpty(chart.Descendants(Svg + "path")); + } + + [Fact] + public void DetailsToggleSurvivesRefreshAndClosesAgain() + { + using var service = _environment.CreateService(); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); + var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); + Assert.Null(page.Details); + form.SubmitForm("""{"action":"details"}"""); + var details = Assert.IsType
(page.Details); + Assert.Contains("Forecast and budget", details.Body, StringComparison.Ordinal); + page.Refresh(); + Assert.Same(details, page.Details); + using (var data = JsonDocument.Parse(form.DataJson)) + Assert.Equal("Hide details", data.RootElement.GetProperty("detailsButtonTitle").GetString()); + form.SubmitForm("""{"action":"details"}"""); + Assert.Null(page.Details); + } + + [Theory] + [InlineData("")] + [InlineData("{")] + [InlineData("[]")] + [InlineData("""{"action":true}""")] + [InlineData("""{"action":"reset"}""")] + public void UnrecognizedFormInputCannotToggleDetails(string payload) + { + using var service = _environment.CreateService(); + using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); + Assert.IsAssignableFrom(Assert.Single(page.GetContent())).SubmitForm(payload); + Assert.Null(page.Details); + } + + [Theory] + [InlineData(19, "Default")] + [InlineData(0.5, "Warning")] + public void OnlyAnUpcomingResetCreditExpiryNeedsEmphasis(double days, string color) + { + using var data = Render(Snapshot() with + { + ResetCredits = new(2, [new("Full reset", "available", Now.AddDays(days))]), + }); + Assert.True(data.RootElement.GetProperty("hasResetCredits").GetBoolean()); + Assert.StartsWith("2 reset credits", data.RootElement.GetProperty("resetCreditsSummary").GetString(), StringComparison.Ordinal); + Assert.Equal(color, data.RootElement.GetProperty("resetCreditsColor").GetString()); + } +} diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 6297630..7389a6d 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -13,6 +13,10 @@ public sealed class UsageDataTests : IDisposable public void Dispose() => _environment.Dispose(); private static readonly TimeSpan AsyncTestTimeout = TimeSpan.FromSeconds(5); + private static JsonElement DashboardWindow(JsonElement root, string title) => + root.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray() + .Single(window => window.GetProperty("title").GetString() == title); + private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; private static XDocument ParseSvg(string imageUrl) @@ -220,23 +224,25 @@ public void DetailsPageContextMenuIncludesRefreshThenSettings() } [Fact] - public void DetailsPageUsesNativeMediumDetailsPane() + public void DashboardKeepsDetailsCollapsedUntilRequested() { using var service = _environment.CreateService(); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); + Assert.Null(page.Details); + var main = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); + main.SubmitForm("""{"action":"details"}"""); var details = Assert.IsType
(page.Details); - var main = Assert.IsType(Assert.Single(page.GetContent())); using var template = JsonDocument.Parse(main.TemplateJson); Assert.Equal("Usage details", details.Title); Assert.Equal(ContentSize.Medium, details.Size); Assert.Equal("AdaptiveCard", template.RootElement.GetProperty("type").GetString()); Assert.Contains("\"type\": \"Image\"", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("Allowance used", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("Window elapsed", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("Used", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("Time elapsed", main.TemplateJson, StringComparison.Ordinal); Assert.Contains("weeklyTrendAvailable", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal); + Assert.DoesNotContain("weeklyTrendLegend", main.TemplateJson, StringComparison.Ordinal); Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent())); } @@ -266,23 +272,21 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), using var mainData = JsonDocument.Parse(main); var root = mainData.RootElement; - Assert.Equal("Status: Plenty of allowance available", root.GetProperty("statusTitle").GetString()); - Assert.Equal("80%", root.GetProperty("fiveHourRemaining").GetString()); - Assert.Equal("98%", root.GetProperty("weeklyRemaining").GetString()); - Assert.Equal("20%", root.GetProperty("fiveHourUsedPercent").GetString()); - Assert.Equal("20%", root.GetProperty("fiveHourElapsedPercent").GetString()); - Assert.Equal("2%", root.GetProperty("weeklyUsedPercent").GetString()); - Assert.Equal("14%", root.GetProperty("weeklyElapsedPercent").GetString()); - Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("fiveHourUsedBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("fiveHourElapsedBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyUsedBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyElapsedBarUrl").GetString(), StringComparison.Ordinal); + Assert.False(root.GetProperty("hasNotice").GetBoolean()); + Assert.Equal("20%", DashboardWindow(root, "5-hour").GetProperty("usedPercent").GetString()); + Assert.Equal("20%", DashboardWindow(root, "5-hour").GetProperty("elapsedPercent").GetString()); + Assert.Equal("2%", DashboardWindow(root, "Weekly").GetProperty("usedPercent").GetString()); + Assert.Equal("14%", DashboardWindow(root, "Weekly").GetProperty("elapsedPercent").GetString()); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("usedBarUrl").GetString(), StringComparison.Ordinal); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("usedBarUrl").GetString(), StringComparison.Ordinal); + Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("elapsedBarUrl").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 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.DoesNotContain("On track", main, StringComparison.Ordinal); + Assert.Equal("Default", DashboardWindow(root, "Weekly").GetProperty("usedColor").GetString()); Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal); Assert.Contains("Projected at reset", root.GetProperty("weeklyProjection").GetString(), StringComparison.Ordinal); Assert.DoesNotContain("Resets and credits", main, StringComparison.Ordinal); @@ -330,7 +334,7 @@ public void WeeklyTrendUsesFullWindowHistoryBeforeTheLatestQuotaIncrease() refreshInterval: TimeSpan.FromMinutes(1), tokenUsage: TokenUsage( new DateOnly(2026, 7, 16), 250_000, - new DateOnly(2026, 7, 17), 500_000)); + new DateOnly(2026, 7, 17), 500_000), showDetails: true); using var document = JsonDocument.Parse(data); var root = document.RootElement; @@ -390,7 +394,8 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() _ => result.Task, () => throw new InvalidOperationException("Fallback should not run.")); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); - var main = Assert.IsType(Assert.Single(page.GetContent())); + var main = Assert.IsAssignableFrom(Assert.Single(page.GetContent())); + main.SubmitForm("""{"action":"details"}"""); var details = Assert.IsType
(page.Details); var refresh = service.RefreshAsync(); @@ -416,10 +421,10 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() using (var refreshedData = JsonDocument.Parse(main.DataJson)) { Assert.False(refreshedData.RootElement.GetProperty("isLoading").GetBoolean()); - Assert.Equal("75%", refreshedData.RootElement.GetProperty("fiveHourRemaining").GetString()); + Assert.Equal("25%", DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("usedPercent").GetString()); Assert.StartsWith( "data:image/svg+xml;utf8,", - refreshedData.RootElement.GetProperty("fiveHourUsedBarUrl").GetString(), + DashboardWindow(refreshedData.RootElement, "5-hour").GetProperty("usedBarUrl").GetString(), StringComparison.Ordinal); Assert.Equal( "Projection will appear after another measurement.", @@ -473,17 +478,17 @@ public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvide [Fact] public void UsageProgressBarCreatesSvgDataUri() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.FiveHour); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.Used); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.Contains("%3Csvg", bar, StringComparison.OrdinalIgnoreCase); - Assert.Contains("%2339B8E3", bar, StringComparison.OrdinalIgnoreCase); + Assert.Contains("%235C9EFA", bar, StringComparison.OrdinalIgnoreCase); } [Fact] public void UsageProgressBarHandlesNonFinitePercentage() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Weekly); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Used); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.DoesNotContain("NaN", bar, StringComparison.Ordinal); @@ -517,7 +522,7 @@ public void WeeklyTrendChartRendersObservedForecastAndDailyTokens() Assert.NotNull(root); var lines = root!.Descendants(Svg + "polyline").ToArray(); - Assert.Equal($"0 0 {UsageDashboardCard.BarWidth} {WeeklyUsageTrendChartRenderer.Height}", root.Attribute("viewBox")?.Value); + Assert.Equal($"0 0 {WeeklyUsageTrendChartRenderer.Width} {WeeklyUsageTrendChartRenderer.Height}", root.Attribute("viewBox")?.Value); Assert.Contains(lines, line => line.Attribute("stroke-dasharray") is null); Assert.Contains(lines, line => line.Attribute("stroke-dasharray")?.Value == "5 4"); Assert.Equal(3, root.Descendants(Svg + "rect").Count(rect => rect.Attribute("data-series")?.Value == "daily-tokens")); @@ -557,7 +562,7 @@ [new DailyTokenUsage(new DateOnly(2026, 7, 11), 250_000)], new UsageHistoryEntry(now, 80), ], refreshInterval: TimeSpan.FromMinutes(1), - tokenUsage: tokenUsage); + tokenUsage: tokenUsage, showDetails: true); using var document = JsonDocument.Parse(data); var root = document.RootElement; @@ -833,8 +838,8 @@ public void WeeklyTrendChartRendersHostSafeLocalizedAxisLabels() .Select(group => group.Attribute("data-rendered-label")!.Value)); Assert.Empty(root.Descendants(Svg + "text")); Assert.All(root.Descendants(Svg + "g").Where(group => group.Attribute("data-axis") is not null), group => - Assert.NotEmpty(group.Descendants(Svg + "rect"))); - Assert.Contains("left vertical scale is remaining allowance", result.AltText, StringComparison.Ordinal); + Assert.NotEmpty(group.Descendants(Svg + "path"))); + Assert.Contains("left vertical scale is used quota", result.AltText, StringComparison.Ordinal); } [Fact] @@ -976,11 +981,11 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheTokenBarBaseline() var root = Assert.IsType(ParseSvg(result.ImageUrl).Root); var zeroGridline = Assert.Single( root.Descendants(Svg + "line"), - line => line.Attribute("data-grid")?.Value == "remaining-percent" + line => line.Attribute("data-grid")?.Value == "used-percent" && line.Attribute("data-value")?.Value == "0"); var zeroY = double.Parse(zeroGridline.Attribute("y1")!.Value, CultureInfo.InvariantCulture); var bottomGridY = root.Descendants(Svg + "line") - .Where(line => line.Attribute("data-grid")?.Value == "remaining-percent") + .Where(line => line.Attribute("data-grid")?.Value == "used-percent") .Max(line => double.Parse(line.Attribute("y1")!.Value, CultureInfo.InvariantCulture)); var dailyTokenBar = Assert.Single( root.Descendants(Svg + "rect"), @@ -991,12 +996,13 @@ public void WeeklyTrendChartUsesTheZeroGridlineAsTheTokenBarBaseline() root.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "vertical" && group.Attribute("data-axis-label")?.Value == "0%"); - var labelTop = zeroLabel.Descendants(Svg + "rect") - .Min(rect => double.Parse(rect.Attribute("y")!.Value, CultureInfo.InvariantCulture)); + var labelTop = zeroLabel.Descendants(Svg + "path") + .Select(path => path.Attribute("transform")!.Value.TrimEnd(')').Split(' ')[1]) + .Min(value => double.Parse(value, CultureInfo.InvariantCulture)); Assert.Equal(bottomGridY, zeroY, precision: 3); Assert.Equal(zeroY, barBottom, precision: 3); - Assert.Equal(zeroY - 5, labelTop, precision: 3); + Assert.Equal(zeroY - ChartLabelGlyphs.Height / 2, labelTop, precision: 3); } [Fact] @@ -1094,10 +1100,10 @@ public void UsagePaceWarnsWhenAllowanceRunsFarAheadOfElapsedTime() refreshInterval: TimeSpan.FromMinutes(1)); using var document = JsonDocument.Parse(data); - Assert.Equal("70%", document.RootElement.GetProperty("fiveHourUsedPercent").GetString()); - Assert.Equal("20%", document.RootElement.GetProperty("fiveHourElapsedPercent").GetString()); - Assert.Equal("Limit may be reached before reset", document.RootElement.GetProperty("fiveHourPaceStatus").GetString()); - Assert.Equal("Attention", document.RootElement.GetProperty("fiveHourPaceColor").GetString()); + Assert.Equal("70%", DashboardWindow(document.RootElement, "5-hour").GetProperty("usedPercent").GetString()); + Assert.Equal("20%", DashboardWindow(document.RootElement, "5-hour").GetProperty("elapsedPercent").GetString()); + Assert.DoesNotContain("Limit may be reached before reset", data, StringComparison.Ordinal); + Assert.Equal("Warning", DashboardWindow(document.RootElement, "5-hour").GetProperty("usedColor").GetString()); } [Theory] diff --git a/CodexUsageDock.Tests/UsageFreshnessTests.cs b/CodexUsageDock.Tests/UsageFreshnessTests.cs index 688d789..04152d8 100644 --- a/CodexUsageDock.Tests/UsageFreshnessTests.cs +++ b/CodexUsageDock.Tests/UsageFreshnessTests.cs @@ -79,7 +79,7 @@ public void LastConfirmedSuppressesForecastButPreservesObservedChart() refreshInterval: TimeSpan.FromMinutes(1))); var root = document.RootElement; - Assert.Contains("Last confirmed usage", root.GetProperty("statusTitle").GetString(), StringComparison.Ordinal); + Assert.Contains("Last confirmed", root.GetProperty("notice").GetString(), StringComparison.Ordinal); Assert.Contains("Projection unavailable", root.GetProperty("weeklyProjection").GetString(), StringComparison.Ordinal); Assert.Equal("Forecast unavailable until usage data is refreshed.", root.GetProperty("weeklyForecastStatus").GetString()); Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); @@ -106,7 +106,7 @@ public void ExpiredWindowIsReportedAsUnknownInsteadOfZero() Assert.Contains("allowance unknown", summary, StringComparison.Ordinal); Assert.DoesNotContain("0%", summary, StringComparison.Ordinal); - Assert.False(document.RootElement.GetProperty("fiveHourAvailable").GetBoolean()); + Assert.Empty(document.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); } [Fact] diff --git a/CodexUsageDock/ChartLabelGlyphs.cs b/CodexUsageDock/ChartLabelGlyphs.cs new file mode 100644 index 0000000..e7b0bea --- /dev/null +++ b/CodexUsageDock/ChartLabelGlyphs.cs @@ -0,0 +1,50 @@ +// Generated by scripts/generate-chart-labels.ps1 from Segoe UI. Do not edit glyphs by hand. +namespace CodexUsageDock; + +internal static class ChartLabelGlyphs +{ + internal const double Height = 16; + internal static readonly Dictionary Values = new() + { + ['0'] = (10.209, "M8.209 10.069 C8.209 11.124 8.114 12.059 7.923 12.873 C7.733 13.688 7.458 14.375 7.097 14.934 C6.737 15.494 6.294 15.918 5.77 16.208 C5.246 16.499 4.649 16.644 3.981 16.644 C3.349 16.644 2.785 16.504 2.29 16.226 C1.794 15.948 1.377 15.542 1.037 15.009 C0.697 14.476 0.439 13.821 0.264 13.044 C0.088 12.268 0 11.379 0 10.377 C0 9.275 0.092 8.303 0.277 7.459 C0.461 6.615 0.732 5.908 1.09 5.336 C1.447 4.765 1.891 4.334 2.421 4.044 C2.952 3.754 3.56 3.609 4.245 3.609 C6.888 3.609 8.209 5.763 8.209 10.069 ZM6.732 10.21 C6.732 6.624 5.88 4.831 4.175 4.831 C2.376 4.831 1.477 6.656 1.477 10.307 C1.477 13.711 2.358 15.413 4.122 15.413 C5.862 15.413 6.732 13.679 6.732 10.21 Z"), + ['1'] = (6.359, "M4.359 16.424 L2.918 16.424 L2.918 5.534 C2.807 5.646 2.644 5.773 2.43 5.917 C2.216 6.06 1.976 6.202 1.709 6.343 C1.443 6.483 1.162 6.615 0.866 6.738 C0.57 6.861 0.281 6.958 0 7.028 L0 5.569 C0.316 5.481 0.652 5.363 1.006 5.213 C1.361 5.064 1.709 4.897 2.052 4.712 C2.395 4.528 2.719 4.334 3.023 4.132 C3.328 3.93 3.592 3.732 3.814 3.539 L4.359 3.539 Z"), + ['2'] = (9.778, "M5.827 7.125 C5.827 6.738 5.767 6.401 5.647 6.114 C5.527 5.827 5.363 5.588 5.155 5.398 C4.947 5.208 4.702 5.065 4.421 4.972 C4.14 4.878 3.835 4.831 3.507 4.831 C3.226 4.831 2.95 4.869 2.681 4.945 C2.411 5.021 2.149 5.127 1.894 5.262 C1.639 5.396 1.393 5.558 1.156 5.745 C0.918 5.933 0.697 6.141 0.492 6.369 L0.492 4.822 C0.896 4.43 1.349 4.129 1.85 3.921 C2.351 3.713 2.962 3.609 3.683 3.609 C4.198 3.609 4.676 3.684 5.115 3.833 C5.555 3.983 5.936 4.201 6.258 4.488 C6.58 4.775 6.833 5.13 7.018 5.552 C7.203 5.974 7.295 6.457 7.295 7.002 C7.295 7.5 7.238 7.951 7.124 8.355 C7.009 8.76 6.836 9.141 6.605 9.498 C6.374 9.855 6.082 10.198 5.73 10.526 C5.379 10.854 4.966 11.188 4.491 11.528 C3.899 11.95 3.412 12.311 3.028 12.609 C2.644 12.908 2.339 13.188 2.114 13.449 C1.888 13.709 1.73 13.97 1.639 14.231 C1.548 14.492 1.503 14.792 1.503 15.132 L7.778 15.132 L7.778 16.424 L0 16.424 L0 15.8 C0 15.261 0.059 14.786 0.176 14.376 C0.293 13.966 0.486 13.576 0.756 13.207 C1.025 12.838 1.378 12.469 1.815 12.1 C2.251 11.73 2.786 11.314 3.419 10.852 C3.876 10.523 4.258 10.21 4.566 9.911 C4.874 9.612 5.121 9.313 5.309 9.015 C5.496 8.716 5.629 8.413 5.708 8.105 C5.788 7.797 5.827 7.471 5.827 7.125 Z"), + ['3'] = (9.295, "M7.295 12.855 C7.295 13.424 7.188 13.941 6.974 14.407 C6.76 14.873 6.458 15.271 6.069 15.602 C5.679 15.933 5.212 16.189 4.667 16.371 C4.122 16.553 3.521 16.644 2.865 16.644 C1.664 16.644 0.709 16.415 0 15.958 L0 14.411 C0.85 15.079 1.822 15.413 2.918 15.413 C3.357 15.413 3.754 15.357 4.109 15.246 C4.463 15.135 4.768 14.974 5.023 14.763 C5.278 14.552 5.474 14.297 5.612 13.998 C5.75 13.699 5.818 13.362 5.818 12.987 C5.818 11.341 4.646 10.518 2.303 10.518 L1.257 10.518 L1.257 9.296 L2.25 9.296 C4.324 9.296 5.361 8.522 5.361 6.976 C5.361 5.546 4.57 4.831 2.988 4.831 C2.098 4.831 1.263 5.13 0.483 5.728 L0.483 4.33 C1.286 3.85 2.241 3.609 3.349 3.609 C3.876 3.609 4.354 3.683 4.781 3.829 C5.209 3.976 5.575 4.181 5.88 4.444 C6.185 4.708 6.42 5.024 6.587 5.394 C6.754 5.763 6.838 6.17 6.838 6.615 C6.838 8.273 6 9.34 4.324 9.814 L4.324 9.85 C4.752 9.896 5.147 10 5.511 10.162 C5.874 10.323 6.188 10.532 6.451 10.79 C6.715 11.048 6.921 11.351 7.071 11.7 C7.22 12.048 7.295 12.434 7.295 12.855 Z"), + ['4'] = (10.965, "M7.348 3.82 L7.348 12.144 L8.965 12.144 L8.965 13.462 L7.348 13.462 L7.348 16.424 L5.924 16.424 L5.924 13.462 L0 13.462 L0 12.214 C0.551 11.593 1.107 10.929 1.67 10.223 C2.232 9.517 2.77 8.801 3.283 8.074 C3.795 7.348 4.27 6.623 4.707 5.899 C5.143 5.175 5.511 4.482 5.81 3.82 ZM1.626 12.144 L5.924 12.144 L5.924 5.974 C5.484 6.741 5.068 7.427 4.676 8.03 C4.283 8.634 3.91 9.182 3.555 9.674 C3.201 10.166 2.862 10.611 2.54 11.01 C2.218 11.408 1.913 11.786 1.626 12.144 Z"), + ['5'] = (9.075, "M7.075 12.574 C7.075 13.189 6.97 13.746 6.759 14.244 C6.548 14.742 6.248 15.17 5.858 15.527 C5.468 15.885 4.998 16.16 4.447 16.354 C3.896 16.547 3.284 16.644 2.61 16.644 C1.438 16.644 0.568 16.468 0 16.116 L0 14.587 C0.855 15.138 1.731 15.413 2.628 15.413 C3.085 15.413 3.497 15.346 3.863 15.211 C4.229 15.076 4.541 14.889 4.799 14.648 C5.057 14.408 5.254 14.118 5.392 13.778 C5.53 13.438 5.599 13.066 5.599 12.662 C5.599 11.842 5.323 11.199 4.772 10.733 C4.222 10.267 3.422 10.034 2.373 10.034 C2.203 10.034 2.024 10.037 1.837 10.043 C1.649 10.049 1.462 10.056 1.274 10.065 C1.087 10.074 0.904 10.084 0.725 10.096 C0.546 10.107 0.384 10.122 0.237 10.14 L0.668 3.82 L6.495 3.82 L6.495 5.121 L1.916 5.121 L1.661 8.821 C1.849 8.81 2.048 8.799 2.259 8.791 C2.47 8.782 2.657 8.777 2.821 8.777 C3.489 8.777 4.087 8.865 4.614 9.041 C5.142 9.217 5.588 9.467 5.955 9.792 C6.321 10.118 6.599 10.516 6.79 10.988 C6.98 11.459 7.075 11.988 7.075 12.574 Z"), + ['6'] = (10.007, "M8.007 12.425 C8.007 13.04 7.907 13.605 7.708 14.121 C7.509 14.637 7.233 15.082 6.882 15.457 C6.53 15.832 6.111 16.124 5.625 16.332 C5.139 16.54 4.611 16.644 4.043 16.644 C3.41 16.644 2.843 16.512 2.342 16.248 C1.841 15.984 1.417 15.605 1.068 15.11 C0.719 14.615 0.454 14.01 0.272 13.295 C0.091 12.58 0 11.774 0 10.878 C0 9.782 0.122 8.788 0.365 7.894 C0.608 7 0.954 6.236 1.402 5.6 C1.85 4.964 2.391 4.474 3.023 4.128 C3.656 3.782 4.362 3.609 5.142 3.609 C6.009 3.609 6.706 3.729 7.233 3.97 L7.233 5.332 C6.583 4.998 5.897 4.831 5.177 4.831 C4.614 4.831 4.107 4.957 3.656 5.209 C3.205 5.461 2.818 5.817 2.496 6.277 C2.174 6.737 1.926 7.292 1.753 7.942 C1.581 8.593 1.494 9.316 1.494 10.113 L1.529 10.113 C2.109 9.064 3.067 8.54 4.403 8.54 C4.954 8.54 5.451 8.634 5.893 8.821 C6.335 9.009 6.713 9.274 7.027 9.617 C7.34 9.959 7.582 10.368 7.752 10.843 C7.922 11.317 8.007 11.845 8.007 12.425 ZM6.53 12.609 C6.53 12.17 6.475 11.774 6.363 11.423 C6.252 11.071 6.092 10.772 5.884 10.526 C5.676 10.28 5.42 10.091 5.115 9.959 C4.811 9.828 4.465 9.762 4.078 9.762 C3.721 9.762 3.39 9.829 3.085 9.964 C2.78 10.099 2.517 10.282 2.294 10.513 C2.071 10.745 1.897 11.014 1.771 11.322 C1.645 11.629 1.582 11.956 1.582 12.302 C1.582 12.735 1.644 13.141 1.767 13.519 C1.89 13.897 2.063 14.227 2.285 14.508 C2.508 14.789 2.773 15.01 3.081 15.171 C3.388 15.333 3.729 15.413 4.104 15.413 C4.468 15.413 4.799 15.344 5.098 15.207 C5.396 15.069 5.651 14.877 5.862 14.631 C6.073 14.385 6.237 14.09 6.354 13.748 C6.472 13.405 6.53 13.025 6.53 12.609 Z"), + ['7'] = (10.016, "M8.016 4.655 C7.822 4.989 7.589 5.405 7.317 5.903 C7.044 6.401 6.756 6.961 6.451 7.582 C6.146 8.203 5.836 8.871 5.52 9.586 C5.203 10.301 4.907 11.039 4.632 11.801 C4.356 12.563 4.115 13.334 3.907 14.117 C3.699 14.899 3.548 15.668 3.454 16.424 L1.907 16.424 C2.013 15.674 2.175 14.908 2.395 14.125 C2.615 13.343 2.864 12.576 3.142 11.823 C3.42 11.07 3.715 10.345 4.025 9.647 C4.336 8.95 4.638 8.309 4.931 7.723 C5.224 7.137 5.492 6.62 5.735 6.171 C5.978 5.723 6.173 5.373 6.319 5.121 L0 5.121 L0 3.82 L8.016 3.82 Z"), + ['8'] = (10.174, "M0 12.908 C0 12.574 0.053 12.239 0.158 11.902 C0.264 11.565 0.415 11.25 0.611 10.957 C0.807 10.664 1.05 10.397 1.34 10.157 C1.63 9.917 1.96 9.727 2.329 9.586 C1.784 9.281 1.351 8.89 1.028 8.413 C0.706 7.935 0.545 7.406 0.545 6.826 C0.545 6.363 0.634 5.936 0.813 5.543 C0.992 5.15 1.239 4.811 1.556 4.523 C1.872 4.236 2.247 4.012 2.681 3.851 C3.114 3.69 3.583 3.609 4.087 3.609 C4.597 3.609 5.068 3.691 5.502 3.855 C5.936 4.02 6.311 4.245 6.627 4.532 C6.943 4.819 7.189 5.159 7.365 5.552 C7.541 5.944 7.629 6.369 7.629 6.826 C7.629 7.406 7.468 7.935 7.146 8.413 C6.823 8.89 6.396 9.281 5.862 9.586 C6.226 9.727 6.551 9.917 6.838 10.157 C7.125 10.397 7.367 10.664 7.563 10.957 C7.759 11.25 7.91 11.565 8.016 11.902 C8.121 12.239 8.174 12.574 8.174 12.908 C8.174 13.459 8.074 13.963 7.875 14.42 C7.676 14.877 7.396 15.268 7.036 15.593 C6.675 15.918 6.245 16.172 5.744 16.354 C5.243 16.535 4.69 16.626 4.087 16.626 C3.489 16.626 2.941 16.535 2.443 16.354 C1.945 16.172 1.515 15.918 1.151 15.593 C0.788 15.268 0.505 14.877 0.303 14.42 C0.101 13.963 0 13.459 0 12.908 ZM1.564 12.785 C1.564 13.178 1.625 13.532 1.745 13.849 C1.865 14.165 2.035 14.435 2.254 14.657 C2.474 14.88 2.741 15.051 3.054 15.171 C3.368 15.292 3.715 15.352 4.096 15.352 C4.465 15.352 4.805 15.29 5.115 15.167 C5.426 15.044 5.694 14.871 5.919 14.648 C6.145 14.426 6.321 14.156 6.447 13.84 C6.573 13.523 6.636 13.172 6.636 12.785 C6.636 12.434 6.576 12.104 6.456 11.796 C6.335 11.489 6.164 11.218 5.941 10.983 C5.719 10.749 5.452 10.564 5.142 10.43 C4.831 10.295 4.482 10.228 4.096 10.228 C3.732 10.228 3.397 10.291 3.089 10.417 C2.782 10.542 2.515 10.718 2.29 10.944 C2.064 11.169 1.887 11.439 1.758 11.752 C1.629 12.066 1.564 12.41 1.564 12.785 ZM2.013 6.94 C2.013 7.239 2.067 7.516 2.175 7.771 C2.284 8.026 2.433 8.249 2.624 8.439 C2.814 8.629 3.035 8.779 3.287 8.887 C3.539 8.996 3.809 9.05 4.096 9.05 C4.383 9.05 4.654 8.994 4.909 8.883 C5.164 8.771 5.385 8.621 5.572 8.43 C5.76 8.24 5.909 8.017 6.021 7.762 C6.132 7.507 6.188 7.233 6.188 6.94 C6.188 6.636 6.133 6.354 6.025 6.097 C5.917 5.839 5.769 5.616 5.581 5.429 C5.394 5.241 5.172 5.093 4.917 4.985 C4.663 4.876 4.389 4.822 4.096 4.822 C3.785 4.822 3.502 4.878 3.248 4.989 C2.993 5.101 2.773 5.251 2.588 5.442 C2.404 5.632 2.262 5.856 2.162 6.114 C2.063 6.372 2.013 6.647 2.013 6.94 Z"), + ['9'] = (9.998, "M7.998 9.366 C7.998 10.521 7.884 11.547 7.655 12.447 C7.427 13.346 7.093 14.108 6.653 14.732 C6.214 15.356 5.678 15.831 5.045 16.156 C4.412 16.481 3.691 16.644 2.883 16.644 C2.051 16.644 1.318 16.497 0.686 16.204 L0.686 14.824 C1.383 15.217 2.127 15.413 2.918 15.413 C3.486 15.413 3.995 15.296 4.443 15.062 C4.891 14.827 5.271 14.486 5.581 14.038 C5.892 13.589 6.129 13.037 6.293 12.381 C6.457 11.725 6.539 10.972 6.539 10.122 L6.504 10.122 C5.965 11.206 5.027 11.748 3.691 11.748 C3.152 11.748 2.657 11.653 2.206 11.462 C1.755 11.272 1.365 11.002 1.037 10.654 C0.709 10.305 0.454 9.891 0.272 9.41 C0.091 8.93 0 8.399 0 7.819 C0 7.198 0.101 6.63 0.303 6.114 C0.505 5.599 0.787 5.155 1.147 4.783 C1.507 4.411 1.935 4.122 2.43 3.917 C2.925 3.712 3.466 3.609 4.052 3.609 C4.685 3.609 5.246 3.737 5.735 3.992 C6.224 4.247 6.636 4.619 6.97 5.108 C7.304 5.597 7.559 6.199 7.734 6.914 C7.91 7.629 7.998 8.446 7.998 9.366 ZM6.442 8.074 C6.442 7.588 6.379 7.146 6.253 6.747 C6.127 6.349 5.953 6.007 5.73 5.723 C5.508 5.439 5.243 5.219 4.935 5.064 C4.627 4.909 4.292 4.831 3.929 4.831 C3.583 4.831 3.261 4.9 2.962 5.038 C2.663 5.175 2.404 5.369 2.184 5.618 C1.964 5.867 1.792 6.163 1.666 6.505 C1.54 6.848 1.477 7.222 1.477 7.626 C1.477 8.071 1.537 8.47 1.657 8.821 C1.777 9.173 1.948 9.47 2.171 9.713 C2.394 9.957 2.662 10.143 2.975 10.271 C3.289 10.4 3.636 10.465 4.017 10.465 C4.351 10.465 4.666 10.402 4.961 10.276 C5.257 10.15 5.515 9.98 5.735 9.766 C5.955 9.552 6.127 9.3 6.253 9.01 C6.379 8.72 6.442 8.408 6.442 8.074 Z"), + ['A'] = (13.215, "M11.215 16.424 L9.58 16.424 L8.244 12.891 L2.9 12.891 L1.644 16.424 L0 16.424 L4.834 3.82 L6.363 3.82 ZM7.761 11.563 L5.783 6.193 C5.719 6.018 5.654 5.736 5.59 5.35 L5.555 5.35 C5.496 5.707 5.429 5.988 5.353 6.193 L3.393 11.563 Z"), + ['B'] = (9.761, "M0 16.424 L0 3.82 L3.586 3.82 C4.676 3.82 5.54 4.087 6.179 4.62 C6.817 5.153 7.137 5.848 7.137 6.703 C7.137 7.418 6.943 8.039 6.557 8.566 C6.17 9.094 5.637 9.469 4.957 9.691 L4.957 9.727 C5.807 9.826 6.486 10.147 6.996 10.689 C7.506 11.231 7.761 11.936 7.761 12.803 C7.761 13.881 7.374 14.754 6.601 15.422 C5.827 16.09 4.852 16.424 3.674 16.424 ZM1.477 5.156 L1.477 9.226 L2.988 9.226 C3.797 9.226 4.433 9.031 4.896 8.641 C5.358 8.251 5.59 7.702 5.59 6.993 C5.59 5.769 4.784 5.156 3.173 5.156 ZM1.477 10.553 L1.477 15.088 L3.48 15.088 C4.348 15.088 5.02 14.883 5.498 14.473 C5.975 14.063 6.214 13.5 6.214 12.785 C6.214 11.297 5.2 10.553 3.173 10.553 Z"), + ['C'] = (11.404, "M9.404 15.896 C8.473 16.389 7.312 16.635 5.924 16.635 C4.131 16.635 2.695 16.058 1.617 14.903 C0.539 13.749 0 12.234 0 10.359 C0 8.344 0.606 6.715 1.819 5.473 C3.032 4.23 4.57 3.609 6.434 3.609 C7.629 3.609 8.619 3.782 9.404 4.128 L9.404 5.701 C8.502 5.197 7.506 4.945 6.416 4.945 C4.969 4.945 3.795 5.429 2.896 6.396 C1.997 7.362 1.547 8.654 1.547 10.271 C1.547 11.807 1.967 13.03 2.808 13.941 C3.649 14.852 4.752 15.308 6.117 15.308 C7.383 15.308 8.479 15.026 9.404 14.464 Z"), + ['D'] = (12.143, "M0 16.424 L0 3.82 L3.48 3.82 C7.922 3.82 10.143 5.868 10.143 9.964 C10.143 11.909 9.526 13.472 8.292 14.653 C7.059 15.833 5.408 16.424 3.34 16.424 ZM1.477 5.156 L1.477 15.088 L3.357 15.088 C5.01 15.088 6.296 14.646 7.216 13.761 C8.136 12.876 8.596 11.622 8.596 9.999 C8.596 6.771 6.879 5.156 3.445 5.156 Z"), + ['E'] = (8.68, "M6.68 16.424 L0 16.424 L0 3.82 L6.398 3.82 L6.398 5.156 L1.477 5.156 L1.477 9.349 L6.029 9.349 L6.029 10.676 L1.477 10.676 L1.477 15.088 L6.68 15.088 Z"), + ['F'] = (8.398, "M6.398 5.156 L1.477 5.156 L1.477 9.516 L6.029 9.516 L6.029 10.843 L1.477 10.843 L1.477 16.424 L0 16.424 L0 3.82 L6.398 3.82 Z"), + ['G'] = (12.248, "M10.248 15.563 C8.982 16.277 7.576 16.635 6.029 16.635 C4.23 16.635 2.776 16.055 1.666 14.895 C0.555 13.734 0 12.199 0 10.289 C0 8.338 0.617 6.737 1.85 5.486 C3.083 4.235 4.646 3.609 6.539 3.609 C7.91 3.609 9.062 3.832 9.993 4.277 L9.993 5.912 C8.974 5.268 7.767 4.945 6.372 4.945 C4.96 4.945 3.803 5.432 2.9 6.404 C1.998 7.377 1.547 8.637 1.547 10.184 C1.547 11.777 1.966 13.03 2.804 13.941 C3.642 14.852 4.778 15.308 6.214 15.308 C7.198 15.308 8.051 15.111 8.771 14.719 L8.771 11.186 L6.012 11.186 L6.012 9.85 L10.248 9.85 Z"), + ['H'] = (11.475, "M9.475 16.424 L7.998 16.424 L7.998 10.676 L1.477 10.676 L1.477 16.424 L0 16.424 L0 3.82 L1.477 3.82 L1.477 9.349 L7.998 9.349 L7.998 3.82 L9.475 3.82 Z"), + ['I'] = (3.477, "M1.477 16.424 L0 16.424 L0 3.82 L1.477 3.82 Z"), + ['J'] = (6.702, "M4.702 11.906 C4.702 13.4 4.386 14.562 3.753 15.391 C3.12 16.22 2.273 16.635 1.213 16.635 C0.721 16.635 0.316 16.564 0 16.424 L0 14.965 C0.316 15.193 0.727 15.308 1.23 15.308 C2.561 15.308 3.226 14.18 3.226 11.924 L3.226 3.82 L4.702 3.82 Z"), + ['K'] = (10.771, "M8.771 16.424 L6.715 16.424 L1.846 10.658 C1.664 10.441 1.553 10.295 1.512 10.219 L1.477 10.219 L1.477 16.424 L0 16.424 L0 3.82 L1.477 3.82 L1.477 9.744 L1.512 9.744 C1.594 9.615 1.705 9.472 1.846 9.313 L6.557 3.82 L8.394 3.82 L2.988 9.867 Z"), + ['L'] = (8.539, "M6.539 16.424 L0 16.424 L0 3.82 L1.477 3.82 L1.477 15.088 L6.539 15.088 Z"), + ['M'] = (14.85, "M12.85 16.424 L11.382 16.424 L11.382 7.969 C11.382 7.301 11.423 6.483 11.505 5.517 L11.47 5.517 C11.329 6.085 11.203 6.492 11.092 6.738 L6.785 16.424 L6.064 16.424 L1.767 6.809 C1.644 6.527 1.518 6.097 1.389 5.517 L1.354 5.517 C1.4 6.021 1.424 6.844 1.424 7.986 L1.424 16.424 L0 16.424 L0 3.82 L1.951 3.82 L5.818 12.609 C6.117 13.283 6.311 13.787 6.398 14.121 L6.451 14.121 C6.703 13.43 6.905 12.914 7.058 12.574 L11.004 3.82 L12.85 3.82 Z"), + ['N'] = (12.16, "M10.16 16.424 L8.35 16.424 L1.863 6.378 C1.699 6.126 1.564 5.862 1.459 5.587 L1.406 5.587 C1.453 5.856 1.477 6.434 1.477 7.318 L1.477 16.424 L0 16.424 L0 3.82 L1.916 3.82 L8.227 13.708 C8.49 14.118 8.66 14.399 8.736 14.552 L8.771 14.552 C8.713 14.188 8.684 13.57 8.684 12.697 L8.684 3.82 L10.16 3.82 Z"), + ['O'] = (13.918, "M5.906 16.635 C4.119 16.635 2.688 16.046 1.613 14.868 C0.538 13.69 0 12.158 0 10.271 C0 8.244 0.548 6.627 1.644 5.42 C2.739 4.213 4.23 3.609 6.117 3.609 C7.857 3.609 9.259 4.195 10.323 5.367 C11.386 6.539 11.918 8.071 11.918 9.964 C11.918 12.021 11.373 13.646 10.283 14.842 C9.193 16.037 7.734 16.635 5.906 16.635 ZM6.012 4.945 C4.688 4.945 3.612 5.423 2.786 6.378 C1.96 7.333 1.547 8.587 1.547 10.14 C1.547 11.692 1.95 12.942 2.755 13.888 C3.561 14.834 4.611 15.308 5.906 15.308 C7.289 15.308 8.379 14.856 9.176 13.954 C9.973 13.052 10.371 11.789 10.371 10.166 C10.371 8.502 9.984 7.216 9.211 6.308 C8.438 5.399 7.371 4.945 6.012 4.945 Z"), + ['P'] = (9.708, "M1.477 11.66 L1.477 16.424 L0 16.424 L0 3.82 L3.463 3.82 C4.811 3.82 5.855 4.148 6.596 4.805 C7.337 5.461 7.708 6.387 7.708 7.582 C7.708 8.777 7.296 9.756 6.473 10.518 C5.65 11.279 4.538 11.66 3.138 11.66 ZM1.477 5.156 L1.477 10.324 L3.023 10.324 C4.043 10.324 4.821 10.091 5.357 9.625 C5.893 9.16 6.161 8.502 6.161 7.652 C6.161 5.988 5.177 5.156 3.208 5.156 Z"), + ['Q'] = (14.999, "M5.906 16.635 C4.125 16.635 2.692 16.046 1.608 14.868 C0.536 13.69 0 12.158 0 10.271 C0 8.238 0.548 6.621 1.644 5.42 C2.745 4.213 4.236 3.609 6.117 3.609 C7.852 3.609 9.252 4.195 10.318 5.367 C11.385 6.539 11.918 8.071 11.918 9.964 C11.918 12.021 11.373 13.646 10.283 14.842 C10.025 15.129 9.75 15.375 9.457 15.58 L12.999 18.12 L10.318 18.12 L7.945 16.345 C7.324 16.538 6.645 16.635 5.906 16.635 ZM6.012 4.945 C4.688 4.945 3.612 5.423 2.786 6.378 C1.96 7.333 1.547 8.587 1.547 10.14 C1.547 11.687 1.948 12.938 2.751 13.893 C3.56 14.836 4.611 15.308 5.906 15.308 C7.289 15.308 8.379 14.856 9.176 13.954 C9.973 13.052 10.371 11.789 10.371 10.166 C10.371 8.496 9.984 7.21 9.211 6.308 C8.438 5.399 7.371 4.945 6.012 4.945 Z"), + ['R'] = (11.035, "M9.035 16.424 L7.277 16.424 L5.168 12.891 C4.975 12.563 4.787 12.283 4.605 12.051 C4.424 11.82 4.238 11.631 4.047 11.484 C3.857 11.338 3.652 11.231 3.432 11.164 C3.212 11.096 2.965 11.063 2.689 11.063 L1.477 11.063 L1.477 16.424 L0 16.424 L0 3.82 L3.762 3.82 C4.312 3.82 4.821 3.889 5.287 4.027 C5.752 4.165 6.157 4.374 6.5 4.655 C6.842 4.937 7.11 5.287 7.304 5.706 C7.497 6.125 7.594 6.615 7.594 7.178 C7.594 7.617 7.528 8.02 7.396 8.386 C7.264 8.752 7.077 9.079 6.833 9.366 C6.59 9.653 6.297 9.898 5.955 10.1 C5.612 10.302 5.227 10.459 4.799 10.57 L4.799 10.605 C5.01 10.699 5.193 10.806 5.348 10.926 C5.503 11.046 5.651 11.188 5.792 11.353 C5.933 11.517 6.072 11.703 6.209 11.911 C6.347 12.119 6.501 12.36 6.671 12.636 ZM1.477 5.156 L1.477 9.727 L3.48 9.727 C3.85 9.727 4.191 9.671 4.504 9.56 C4.818 9.448 5.089 9.289 5.317 9.081 C5.546 8.873 5.725 8.618 5.854 8.316 C5.982 8.014 6.047 7.676 6.047 7.301 C6.047 6.627 5.829 6.101 5.392 5.723 C4.956 5.345 4.324 5.156 3.498 5.156 Z"), + ['S'] = (9.638, "M0 15.914 L0 14.174 C0.199 14.35 0.438 14.508 0.716 14.648 C0.995 14.789 1.288 14.908 1.595 15.004 C1.903 15.101 2.212 15.176 2.522 15.229 C2.833 15.281 3.12 15.308 3.384 15.308 C4.292 15.308 4.97 15.139 5.418 14.802 C5.867 14.465 6.091 13.98 6.091 13.348 C6.091 13.008 6.016 12.712 5.867 12.46 C5.717 12.208 5.511 11.978 5.247 11.77 C4.983 11.562 4.671 11.363 4.311 11.172 C3.951 10.982 3.563 10.781 3.146 10.57 C2.707 10.348 2.297 10.122 1.916 9.894 C1.535 9.665 1.204 9.413 0.923 9.138 C0.642 8.862 0.42 8.55 0.259 8.202 C0.098 7.853 0.018 7.444 0.018 6.976 C0.018 6.401 0.144 5.902 0.396 5.477 C0.647 5.052 0.979 4.702 1.389 4.427 C1.799 4.151 2.266 3.946 2.791 3.812 C3.315 3.677 3.85 3.609 4.395 3.609 C5.637 3.609 6.542 3.759 7.11 4.058 L7.11 5.719 C6.366 5.203 5.411 4.945 4.245 4.945 C3.923 4.945 3.601 4.979 3.278 5.046 C2.956 5.114 2.669 5.224 2.417 5.376 C2.165 5.528 1.96 5.725 1.802 5.965 C1.644 6.205 1.564 6.498 1.564 6.844 C1.564 7.166 1.625 7.444 1.745 7.679 C1.865 7.913 2.042 8.127 2.276 8.32 C2.511 8.514 2.796 8.701 3.133 8.883 C3.47 9.064 3.858 9.264 4.298 9.48 C4.749 9.703 5.177 9.938 5.581 10.184 C5.985 10.43 6.34 10.702 6.645 11.001 C6.949 11.3 7.191 11.631 7.37 11.994 C7.548 12.357 7.638 12.773 7.638 13.242 C7.638 13.863 7.516 14.389 7.273 14.82 C7.03 15.25 6.702 15.601 6.289 15.87 C5.875 16.14 5.399 16.334 4.86 16.455 C4.321 16.575 3.753 16.635 3.155 16.635 C2.956 16.635 2.71 16.619 2.417 16.586 C2.124 16.554 1.825 16.507 1.521 16.446 C1.216 16.384 0.927 16.308 0.655 16.217 C0.382 16.126 0.164 16.025 0 15.914 Z"), + ['T'] = (10.745, "M8.745 5.156 L5.106 5.156 L5.106 16.424 L3.63 16.424 L3.63 5.156 L0 5.156 L0 3.82 L8.745 3.82 Z"), + ['U'] = (11.378, "M9.378 11.326 C9.378 14.865 7.781 16.635 4.588 16.635 C1.529 16.635 0 14.933 0 11.528 L0 3.82 L1.477 3.82 L1.477 11.432 C1.477 14.016 2.566 15.308 4.746 15.308 C6.85 15.308 7.901 14.06 7.901 11.563 L7.901 3.82 L9.378 3.82 Z"), + ['V'] = (12.863, "M10.863 3.82 L6.196 16.424 L4.57 16.424 L0 3.82 L1.644 3.82 L5.133 13.813 C5.244 14.136 5.329 14.508 5.388 14.93 L5.423 14.93 C5.47 14.578 5.566 14.2 5.713 13.796 L9.272 3.82 Z"), + ['W'] = (18.365, "M16.365 3.82 L12.806 16.424 L11.074 16.424 L8.481 7.213 C8.37 6.82 8.303 6.393 8.279 5.93 L8.244 5.93 C8.209 6.363 8.133 6.785 8.016 7.195 L5.405 16.424 L3.691 16.424 L0 3.82 L1.626 3.82 L4.307 13.488 C4.418 13.893 4.488 14.314 4.518 14.754 L4.562 14.754 C4.591 14.443 4.682 14.021 4.834 13.488 L7.62 3.82 L9.035 3.82 L11.707 13.559 C11.801 13.893 11.871 14.285 11.918 14.736 L11.953 14.736 C11.977 14.432 12.056 14.027 12.19 13.523 L14.766 3.82 Z"), + ['X'] = (12.125, "M10.125 16.424 L8.323 16.424 L5.423 11.599 C5.335 11.452 5.238 11.244 5.133 10.975 L5.098 10.975 C5.039 11.109 4.939 11.317 4.799 11.599 L1.811 16.424 L0 16.424 L4.219 10.087 L0.334 3.82 L2.145 3.82 L4.72 8.25 C4.89 8.543 5.039 8.836 5.168 9.129 L5.203 9.129 C5.391 8.742 5.555 8.438 5.695 8.215 L8.376 3.82 L10.072 3.82 L6.108 10.069 Z"), + ['Y'] = (11.686, "M9.686 3.82 L5.528 11.766 L5.528 16.424 L4.052 16.424 L4.052 11.801 L0 3.82 L1.679 3.82 L4.5 9.516 C4.535 9.586 4.638 9.844 4.808 10.289 L4.834 10.289 C4.893 10.09 5.004 9.832 5.168 9.516 L8.121 3.82 Z"), + ['Z'] = (11.589, "M9.589 4.172 L2.206 15.088 L9.413 15.088 L9.413 16.424 L0 16.424 L0 16.011 L7.321 5.156 L0.589 5.156 L0.589 3.82 L9.589 3.82 Z"), + [' '] = (5, ""), + ['%'] = (15.359, "M2.848 10.14 C1.98 10.14 1.289 9.854 0.773 9.283 C0.258 8.711 0 7.948 0 6.993 C0 5.95 0.272 5.128 0.817 4.528 C1.362 3.927 2.098 3.627 3.023 3.627 C3.92 3.627 4.616 3.905 5.111 4.462 C5.606 5.019 5.854 5.801 5.854 6.809 C5.854 7.816 5.577 8.624 5.023 9.23 C4.469 9.836 3.744 10.14 2.848 10.14 ZM2.971 4.594 C2.414 4.594 1.975 4.8 1.652 5.213 C1.33 5.626 1.169 6.193 1.169 6.914 C1.169 7.623 1.327 8.175 1.644 8.571 C1.96 8.966 2.391 9.164 2.936 9.164 C3.486 9.164 3.917 8.959 4.228 8.549 C4.538 8.139 4.693 7.57 4.693 6.844 C4.693 6.135 4.54 5.583 4.232 5.187 C3.924 4.792 3.504 4.594 2.971 4.594 ZM11.285 3.82 L3.199 16.512 L1.942 16.512 L10.02 3.82 ZM10.354 16.617 C9.486 16.617 8.795 16.33 8.279 15.756 C7.764 15.182 7.506 14.42 7.506 13.471 C7.506 12.428 7.781 11.603 8.332 10.997 C8.883 10.39 9.615 10.087 10.529 10.087 C11.42 10.087 12.114 10.368 12.612 10.931 C13.11 11.493 13.359 12.281 13.359 13.295 C13.359 14.297 13.083 15.101 12.529 15.708 C11.975 16.314 11.25 16.617 10.354 16.617 ZM10.477 11.08 C9.914 11.08 9.473 11.285 9.154 11.695 C8.834 12.105 8.675 12.674 8.675 13.4 C8.675 14.098 8.833 14.644 9.149 15.04 C9.466 15.435 9.896 15.633 10.441 15.633 C10.992 15.633 11.423 15.426 11.733 15.013 C12.044 14.6 12.199 14.033 12.199 13.313 C12.199 12.598 12.045 12.047 11.738 11.66 C11.43 11.273 11.01 11.08 10.477 11.08 Z"), + ['.'] = (3.969, "M0.976 16.617 C0.706 16.617 0.476 16.521 0.286 16.327 C0.095 16.134 0 15.902 0 15.633 C0 15.363 0.095 15.13 0.286 14.934 C0.476 14.738 0.706 14.64 0.976 14.64 C1.251 14.64 1.485 14.738 1.679 14.934 C1.872 15.13 1.969 15.363 1.969 15.633 C1.969 15.902 1.872 16.134 1.679 16.327 C1.485 16.521 1.251 16.617 0.976 16.617 Z"), + ['?'] = (8.029, "M1.793 12.926 C1.74 12.773 1.693 12.576 1.652 12.333 C1.611 12.089 1.591 11.848 1.591 11.607 C1.591 11.232 1.666 10.894 1.815 10.592 C1.964 10.291 2.15 10.008 2.373 9.744 C2.596 9.48 2.839 9.227 3.103 8.984 C3.366 8.741 3.609 8.495 3.832 8.246 C4.055 7.997 4.241 7.736 4.39 7.463 C4.54 7.191 4.614 6.891 4.614 6.563 C4.614 6.281 4.56 6.032 4.452 5.815 C4.343 5.599 4.195 5.417 4.008 5.271 C3.82 5.124 3.604 5.014 3.357 4.941 C3.111 4.868 2.851 4.831 2.575 4.831 C1.585 4.831 0.727 5.221 0 6 L0 4.453 C0.896 3.891 1.825 3.609 2.786 3.609 C3.231 3.609 3.65 3.668 4.043 3.785 C4.436 3.902 4.78 4.077 5.076 4.308 C5.372 4.54 5.604 4.828 5.774 5.174 C5.944 5.52 6.029 5.921 6.029 6.378 C6.029 6.812 5.953 7.198 5.801 7.538 C5.648 7.878 5.457 8.19 5.225 8.474 C4.994 8.758 4.743 9.023 4.474 9.27 C4.204 9.516 3.954 9.76 3.722 10.003 C3.491 10.247 3.299 10.5 3.146 10.764 C2.994 11.027 2.918 11.317 2.918 11.634 C2.918 11.903 2.95 12.148 3.015 12.368 C3.079 12.587 3.144 12.773 3.208 12.926 ZM2.584 16.582 C2.326 16.582 2.098 16.488 1.898 16.301 C1.705 16.113 1.608 15.885 1.608 15.615 C1.608 15.34 1.705 15.111 1.898 14.93 C2.092 14.736 2.32 14.64 2.584 14.64 C2.848 14.64 3.076 14.736 3.27 14.93 C3.463 15.111 3.56 15.34 3.56 15.615 C3.56 15.885 3.463 16.113 3.27 16.301 C3.07 16.488 2.842 16.582 2.584 16.582 Z"), + }; +} diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 5bda93d..8110cdf 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -12,10 +13,8 @@ internal sealed partial class CodexUsageDockPage : ContentPage, IDisposable private bool _disposed; private readonly CodexUsageService _usage; private readonly CodexUsageDockSettingsPage _settings; - private readonly FormContent _mainContent = new() - { - TemplateJson = UsageDashboardCard.TemplateJson, - }; + private readonly UsageDashboardForm _mainContent; + private bool _showDetails; private readonly Details _details = new() { Title = "Usage details", @@ -25,12 +24,13 @@ internal sealed partial class CodexUsageDockPage : ContentPage, IDisposable public CodexUsageDockPage(CodexUsageService usage, CodexUsageDockSettingsPage settings) { _usage = usage; + _mainContent = new UsageDashboardForm(ToggleDetails) { TemplateJson = UsageDashboardCard.TemplateJson }; _settings = settings; _usage.Updated += OnUpdated; Id = "nl.mathijs.codexusage.details"; Title = $"Codex Usage - {Version}"; Name = "Open"; - Details = _details; + Details = null; Commands = [ new CommandContextItem(new RefreshUsageCommand(_usage)) @@ -61,59 +61,169 @@ internal static string FormatMainDataJson( TimeSpan refreshInterval, bool adaptiveWeeklyForecastEnabled = true, AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, - LocalTokenUsageSnapshot? tokenUsage = null) + LocalTokenUsageSnapshot? tokenUsage = null, + bool showDetails = false) { var freshness = GetFreshnessState(snapshot, now, refreshInterval); var dataAvailable = IsDataAvailable(snapshot, freshness); var maximumSampleAge = TrendFreshness(refreshInterval); - var (statusTitle, statusDescription) = FormatSummaryParts(snapshot, now, refreshInterval); + var notice = FormatDashboardNotice(snapshot, now, freshness); + var nextExpiry = snapshot.ResetCredits?.Credits?.Where(credit => credit.ExpiresAt > now) + .MinBy(credit => credit.ExpiresAt)?.ExpiresAt; + var urgentReset = nextExpiry is { } expiry && expiry - now <= TimeSpan.FromDays(1); + var resetCount = snapshot.ResetCredits?.AvailableCount ?? 0; var data = new JsonObject { ["isLoading"] = isLoading, - ["statusTitle"] = statusTitle, - ["statusDescription"] = statusDescription, + ["notice"] = notice, + ["hasNotice"] = notice.Length > 0, + ["hasResetCredits"] = resetCount > 0, + ["resetCreditsSummary"] = resetCount > 0 + ? $"{resetCount} reset credit{(resetCount == 1 ? string.Empty : "s")}{(urgentReset ? $" · expires {FormatRelativeTime(nextExpiry!.Value, now)}" : string.Empty)}" + : string.Empty, + ["resetCreditsColor"] = urgentReset ? "Warning" : "Default", + ["detailsButtonTitle"] = showDetails ? "Hide details" : "Details", + ["quotaGroups"] = CreateQuotaGroups(snapshot, now, dataAvailable), + ["weeklyAvailable"] = UsageFreshness.IsValidWindow(snapshot.Secondary, now), }; - _ = AddWindowData( - data, - "fiveHour", - "5-hour window", - snapshot.Primary, - primaryHistory, - now, - dataAvailable, - maximumSampleAge, - UsageBarPalette.FiveHour, - snapshot.Secondary is not null ? "Currently inactive" : "Not available"); - var weeklyTrend = AddWindowData( - data, - "weekly", - "Weekly window", - snapshot.Secondary, - weeklyHistory, - now, - dataAvailable, - maximumSampleAge, - UsageBarPalette.Weekly, - "Not available", - adaptiveWeeklyForecastEnabled ? adaptiveWeeklyHistory : null, - adaptiveWeeklyForecastEnabled); + var primaryTrend = AnalyzeWindow(snapshot.Primary, primaryHistory, now, dataAvailable, maximumSampleAge); + var weeklyTrend = AnalyzeWindow(snapshot.Secondary, weeklyHistory, now, dataAvailable, + maximumSampleAge, adaptiveWeeklyForecastEnabled, adaptiveWeeklyHistory); + data["fiveHourProjection"] = primaryTrend?.Message ?? string.Empty; + data["weeklyProjection"] = weeklyTrend?.Message ?? string.Empty; + data["weeklyBudget"] = snapshot.Secondary is { } weekly + ? FormatWeeklyBudget(weekly, now, dataAvailable) : string.Empty; + data["weeklyForecastSummary"] = FormatForecastSummary(weeklyTrend, now, dataAvailable, maximumSampleAge); AddWeeklyTrendData( - data, - snapshot.Secondary, - weeklyHistory, - weeklyTrend, - dataAvailable, - snapshot.Source is UsageDataSource.AppServer - or UsageDataSource.LocalSession - or UsageDataSource.LastConfirmed, - now, - TrendMaximumGap(refreshInterval), - tokenUsage); - + data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable, + snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed, + now, TrendMaximumGap(refreshInterval), showDetails ? tokenUsage : null); return data.ToJsonString(); } + private static string FormatDashboardNotice(CodexUsageSnapshot snapshot, DateTimeOffset now, UsageFreshnessState freshness) + { + var status = snapshot.Source == UsageDataSource.Initializing ? string.Empty : freshness switch + { + UsageFreshnessState.LastConfirmed => $"Last confirmed · {FormatAge(snapshot.UpdatedAt, now)} ago", + UsageFreshnessState.Stale => $"Outdated · {FormatAge(snapshot.UpdatedAt, now)} ago", + UsageFreshnessState.Future => "Timestamp needs confirmation", + UsageFreshnessState.Unknown => "Usage unavailable", + _ => snapshot.Source == UsageDataSource.LocalSession ? "Local fallback data" : string.Empty, + }; + return snapshot.OrdinaryUsageAllowed == false + ? $"Usage blocked{(status.Length > 0 ? $" · {status}" : string.Empty)}" + : status; + } + + private static JsonArray CreateQuotaGroups(CodexUsageSnapshot snapshot, DateTimeOffset now, bool fresh) + { + var groups = new JsonArray(); + var defaultWindows = new List<(string Title, RateLimitWindow? Window)> + { + ("5-hour", snapshot.Primary), + ("Weekly", snapshot.Secondary), + }; + foreach (var bucket in snapshot.Buckets ?? []) + { + if (!string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal)) continue; + foreach (var window in new[] { bucket.Primary, bucket.Secondary }) + { + if (window is not null && window != snapshot.Primary && window != snapshot.Secondary) + defaultWindows.Add((FormatWindowDuration(window.WindowMinutes), window)); + } + } + groups.Add((JsonNode)CreateQuotaGroup("Codex", defaultWindows, now, fresh)); + foreach (var bucket in snapshot.Buckets ?? []) + { + if (string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal)) continue; + var name = UsageText.SanitizeExternal(bucket.Name) ?? UsageText.SanitizeExternal(bucket.Id) ?? "Additional quota"; + // Additional categories can use arbitrary durations in either slot. + var windows = new[] { bucket.Primary, bucket.Secondary } + .Where(window => window is not null) + .Select(window => ("Quota", window)) + .ToArray(); + groups.Add((JsonNode)CreateQuotaGroup(name, + windows.Length > 0 ? windows : [("Quota", null)], now, fresh)); + } + return groups; + } + + private static JsonObject CreateQuotaGroup( + string name, IEnumerable<(string Title, RateLimitWindow? Window)> windows, DateTimeOffset now, bool fresh) + { + var active = new JsonArray(); + var inactive = new List(); + foreach (var (fallbackTitle, window) in windows) + { + var title = window is { WindowMinutes: > 0 } ? FormatWindowDuration(window.WindowMinutes) : fallbackTitle; + if (string.Equals(title, "weekly", StringComparison.Ordinal)) title = "Weekly"; + if (!UsageFreshness.IsValidWindow(window, now) || window is null) + { + inactive.Add($"{title} · {(window is null ? "Not reported" : "Awaiting refresh")}"); + continue; + } + + var startsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + var elapsedAvailable = fresh && startsAt <= now; + var elapsed = Math.Clamp((now - startsAt).TotalMinutes / window.WindowMinutes * 100, 0, 100); + active.Add((JsonNode)new JsonObject + { + ["title"] = title, + ["usedPercent"] = $"{window.UsedPercent:0}%", + ["usedColor"] = !fresh ? "Default" : window.UsedPercent >= 90 ? "Attention" + : window.UsedPercent >= 70 ? "Warning" : "Default", + ["usedBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(window.UsedPercent, UsageBarPalette.Used), + ["usedBarAlt"] = $"{name}, {title}: {window.UsedPercent:0}% used{(fresh ? string.Empty : " at last observation")}.", + ["elapsedAvailable"] = elapsedAvailable, + ["elapsedPercent"] = elapsedAvailable ? $"{elapsed:0}%" : "—", + ["elapsedBarUrl"] = elapsedAvailable ? UsageDashboardCard.CreateProgressBarImageUrl(elapsed, UsageBarPalette.Time) : string.Empty, + ["elapsedBarAlt"] = elapsedAvailable ? $"{name}, {title}: {elapsed:0}% of time elapsed." : "Time comparison unavailable.", + ["reset"] = $"Resets {window.ResetsAt.ToLocalTime().ToString("ddd d MMM HH:mm", CultureInfo.InvariantCulture)}", + }); + } + + foreach (var window in active) + { + window!["showTitle"] = active.Count > 1; + } + var heading = active.Count == 1 ? $"{name} · {active[0]!["title"]!.GetValue()}" : name; + return new JsonObject + { + ["name"] = UsageText.EscapeMarkdown(name), + ["heading"] = UsageText.EscapeMarkdown(heading), + ["singleWindow"] = active.Count == 1, + ["windows"] = active, + ["inactiveWindows"] = string.Join(" · ", inactive), + ["hasInactiveWindows"] = inactive.Count > 0, + }; + } + + private static UsageTrendAnalyzer.TrendAnalysis? AnalyzeWindow( + RateLimitWindow? window, IReadOnlyList history, DateTimeOffset now, + bool fresh, TimeSpan maximumSampleAge, bool adaptive = false, AdaptiveWeeklyUsageHistory? learned = null) => + UsageFreshness.IsValidWindow(window, now) && window is not null + ? UsageTrendAnalyzer.Analyze(history, window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes), + window.ResetsAt, now, fresh, maximumSampleAge, adaptive, adaptive ? learned : null) + : null; + + private static string FormatForecastSummary( + UsageTrendAnalyzer.TrendAnalysis? trend, DateTimeOffset now, bool fresh, TimeSpan maximumSampleAge) + { + if (!fresh) return "Forecast paused · refresh needed"; + if (trend?.Forecast is { } forecast) + return forecast.ReachesLimitBeforeReset + ? $"Estimated limit · {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, CultureInfo.InvariantCulture)}" + : $"Forecast · {100 - forecast.RemainingPercent:0}% used at reset"; + if (trend?.History is not { Length: > 0 } history) return "Forecast · collecting measurements"; + if (now - history[^1].RecordedAt > maximumSampleAge) return "Forecast paused · fresh measurement needed"; + var minutes = (history[^1].RecordedAt - history[0].RecordedAt).TotalMinutes; + return minutes < 30 + ? $"Forecast · {Math.Floor(minutes):0}/30 min of continuous data" + : "Forecast · waiting for a usage change"; + } + internal static string FormatDetailsBody( CodexUsageSnapshot snapshot, DateTimeOffset now, @@ -372,68 +482,6 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi return $"## {name}\n\n**{window.RemainingPercent:0}% available** \nResets {FormatRelativeTime(window.ResetsAt, now)} · {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}"; } - private static UsageTrendAnalyzer.TrendAnalysis? AddWindowData( - JsonObject data, - string prefix, - string windowName, - RateLimitWindow? window, - IReadOnlyList history, - DateTimeOffset now, - bool dataAvailable, - TimeSpan maximumSampleAge, - UsageBarPalette palette, - string inactiveMessage, - AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, - bool adaptiveWeeklyForecastEnabled = false) - { - var usableWindow = UsageFreshness.IsValidWindow(window, now); - data[$"{prefix}Available"] = usableWindow; - if (!usableWindow || window is null) - { - data[$"{prefix}State"] = window is null - ? inactiveMessage - : "Window data expired or invalid; waiting for refreshed data."; - return null; - } - - data[$"{prefix}Remaining"] = $"{window.RemainingPercent:0}%"; - data[$"{prefix}Reset"] = $"{FormatRelativeTime(window.ResetsAt, now)}\n{FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}"; - - var usedPercent = Math.Clamp(window.UsedPercent, 0, 100); - var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - var elapsedPercent = window.WindowMinutes > 0 - ? Math.Clamp((now - windowStartsAt).TotalMinutes / window.WindowMinutes * 100, 0, 100) - : 0; - var (paceStatus, paceColor) = dataAvailable - ? FormatPaceStatus(usedPercent, elapsedPercent) - : ("Pace unavailable until refreshed", "Default"); - data[$"{prefix}UsedPercent"] = $"{usedPercent:0}%"; - data[$"{prefix}ElapsedPercent"] = $"{elapsedPercent:0}%"; - data[$"{prefix}UsedBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(usedPercent, palette); - data[$"{prefix}UsedBarAlt"] = $"{windowName}: {usedPercent:0}% of allowance used."; - data[$"{prefix}ElapsedBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(elapsedPercent, UsageBarPalette.Time); - data[$"{prefix}ElapsedBarAlt"] = $"{windowName}: {elapsedPercent:0}% of the window elapsed."; - data[$"{prefix}PaceStatus"] = paceStatus; - data[$"{prefix}PaceColor"] = paceColor; - - - var trend = UsageTrendAnalyzer.Analyze( - history, - windowStartsAt, - window.ResetsAt, - now, - dataAvailable, - maximumSampleAge, - adaptiveWeeklyForecastEnabled, - adaptiveWeeklyHistory); - data[$"{prefix}Projection"] = trend.Message; - if (prefix == "weekly") - { - data["weeklyBudget"] = FormatWeeklyBudget(window, now, dataAvailable); - } - return trend; - } - private static string FormatWeeklyBudget(RateLimitWindow window, DateTimeOffset now, bool dataAvailable) { if (!dataAvailable || !UsageFreshness.IsValidWindow(window, now)) @@ -461,6 +509,8 @@ private static void AddWeeklyTrendData( LocalTokenUsageSnapshot? tokenUsage) { data["weeklyTrendAvailable"] = false; + data["weeklyForecastStatus"] = trend?.ForecastStatus ?? "Forecast unavailable."; + data["weeklyTrendLegend"] = "Solid: used quota (%) · dashed: forecast · breaks: gaps or restored allowance. Token bars appear with Details; their scale is independent."; data["weeklyRestorationAvailable"] = false; if (window is not { } validWindow || !UsageFreshness.IsValidWindow(validWindow, now) @@ -500,9 +550,9 @@ private static void AddWeeklyTrendData( : "forecast pending sufficient fresh measurements"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · bars: local tokens per day · amber: detected restorations", - LocalTokenUsageStatus.Partial => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", - _ => $"Solid: remaining allowance (%) · breaks: gaps or restorations · {forecastLegend} · local token data unavailable · amber: detected restorations", + LocalTokenUsageStatus.Complete => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: local tokens per day · amber: detected restorations", + LocalTokenUsageStatus.Partial => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", + _ => $"Solid: used quota (%) · breaks: gaps or restorations · {forecastLegend} · local token data unavailable · amber: detected restorations", }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); @@ -548,37 +598,6 @@ private static string FormatRestorationDetails( return $"## Detected allowance restorations\n\n{string.Join(Environment.NewLine, rows)}"; } - private static (string Status, string Color) FormatPaceStatus(double usedPercent, double elapsedPercent) - { - if (usedPercent <= 1) - { - return ("No significant usage yet", "Good"); - } - - if (elapsedPercent < 1 && usedPercent <= 5) - { - return ("Window has just started", "Default"); - } - - var paceDifference = usedPercent - elapsedPercent; - if (paceDifference <= -10) - { - return ("Comfortably on track", "Good"); - } - - if (paceDifference <= 5) - { - return ("On track", "Good"); - } - - if (paceDifference <= 20) - { - return ("Usage is running ahead of time", "Warning"); - } - - return ("Limit may be reached before reset", "Attention"); - } - internal static string FormatRelativeTime(DateTimeOffset target, DateTimeOffset now) { var duration = target - now; @@ -797,14 +816,29 @@ private void UpdatePresentation() _usage.RefreshInterval, _settings.UseAdaptiveWeeklyForecast, presentation.AdaptiveWeeklyHistory, - presentation.TokenUsage); + presentation.TokenUsage, + _showDetails); + using var mainData = JsonDocument.Parse(_mainContent.DataJson); + var data = mainData.RootElement; _details.Body = FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) + + $"\n\n## Forecast and budget\n\n### 5-hour\n\n{data.GetProperty("fiveHourProjection").GetString()}\n\n### Weekly\n\n{data.GetProperty("weeklyProjection").GetString()}\n\n{data.GetProperty("weeklyBudget").GetString()}\n\n{data.GetProperty("weeklyForecastStatus").GetString()}\n\n## Chart guide\n\n{data.GetProperty("weeklyTrendLegend").GetString()}\n\nTime elapsed is a comparison with an even budget, not a forecast. Forecasts are conditional estimates. Weekly forecasts need 30 minutes of recent, continuous measurements; a gap or restored allowance starts a new series. Local tokens describe activity, not quota consumption." + (_usage.HistoryStorageError is { } error ? $"\n\n> **Local storage:** {error}" : string.Empty); } RaiseItemsChanged(0); } + private void ToggleDetails() + { + lock (_presentationLock) + { + if (_disposed) return; + _showDetails = !_showDetails; + Details = _showDetails ? _details : null; + } + UpdatePresentation(); + } + private void OnUpdated(object? sender, EventArgs e) => UpdatePresentation(); internal void Refresh() => UpdatePresentation(); @@ -819,3 +853,22 @@ public void Dispose() GC.SuppressFinalize(this); } } + + +internal sealed partial class UsageDashboardForm(Action toggleDetails) : FormContent +{ + public override CommandResult SubmitForm(string payload) + { + if (string.IsNullOrEmpty(payload) || payload.Length > 1024) return CommandResult.KeepOpen(); + try + { + using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 4 }); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("action", out var action) + && action.ValueKind == JsonValueKind.String && action.GetString() == "details") + toggleDetails(); + } + catch (JsonException) { } + return CommandResult.KeepOpen(); + } +} diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index 4984432..e13e0b9 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -5,8 +5,7 @@ namespace CodexUsageDock; internal enum UsageBarPalette { - FiveHour, - Weekly, + Used, Time, } @@ -15,391 +14,137 @@ internal static class UsageDashboardCard internal const int BarHeight = 12; internal const int BarWidth = 536; + internal const string TemplateJson = """ { "type": "AdaptiveCard", "body": [ { - "type": "TextBlock", - "text": "Refreshing Codex usage…", - "weight": "bolder", - "wrap": true, - "$when": "${isLoading}" - }, - { - "type": "Container", - "items": [ - { - "type": "TextBlock", - "text": "${statusTitle}", - "weight": "bolder", - "wrap": true - }, - { - "type": "TextBlock", - "text": "${statusDescription}", - "spacing": "none", - "wrap": true - } - ] - }, - { - "type": "Container", - "separator": true, - "spacing": "medium", - "items": [ - { - "type": "TextBlock", - "text": "5-hour window", - "size": "large", - "weight": "bolder" - }, + "type": "ColumnSet", + "columns": [ { - "type": "TextBlock", - "text": "${fiveHourState}", - "weight": "bolder", - "wrap": true, - "$when": "${fiveHourAvailable == false}" - }, - { - "type": "ColumnSet", - "$when": "${fiveHourAvailable}", - "columns": [ + "type": "Column", "width": "stretch", + "items": [ { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "Available", - "isSubtle": true - }, - { - "type": "TextBlock", - "text": "${fiveHourRemaining}", - "size": "large", - "weight": "bolder", - "spacing": "none" - } - ] + "type": "TextBlock", "text": "${notice}", + "wrap": true, "color": "Warning", "$when": "${hasNotice}" }, { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "Reset", - "isSubtle": true, - "horizontalAlignment": "right" - }, - { - "type": "TextBlock", - "text": "${fiveHourReset}", - "horizontalAlignment": "right", - "spacing": "none", - "wrap": true - } - ] - } - ] - }, - { - "type": "ColumnSet", - "spacing": "medium", - "$when": "${fiveHourAvailable}", - "columns": [ - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "Allowance used", - "isSubtle": true - } - ] + "type": "TextBlock", "text": "${resetCreditsSummary}", + "wrap": true, "color": "${resetCreditsColor}", + "$when": "${hasResetCredits}" }, { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "TextBlock", - "text": "${fiveHourUsedPercent}", - "weight": "bolder", - "horizontalAlignment": "right" - } - ] + "type": "TextBlock", "text": "Refreshing…", + "isSubtle": true, "$when": "${isLoading}" } ] }, { - "type": "Image", - "url": "${fiveHourUsedBarUrl}", - "altText": "${fiveHourUsedBarAlt}", - "size": "stretch", - "spacing": "none", - "$when": "${fiveHourAvailable}" - }, - { - "type": "ColumnSet", - "spacing": "small", - "$when": "${fiveHourAvailable}", - "columns": [ - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "Window elapsed", - "isSubtle": true - } - ] - }, + "type": "Column", "width": "auto", + "items": [ { - "type": "Column", - "width": "auto", - "items": [ + "type": "ActionSet", + "actions": [ { - "type": "TextBlock", - "text": "${fiveHourElapsedPercent}", - "weight": "bolder", - "horizontalAlignment": "right" + "type": "Action.Submit", "title": "${detailsButtonTitle}", + "data": { "action": "details" } } ] } ] - }, - { - "type": "Image", - "url": "${fiveHourElapsedBarUrl}", - "altText": "${fiveHourElapsedBarAlt}", - "size": "stretch", - "spacing": "none", - "$when": "${fiveHourAvailable}" - }, - { - "type": "TextBlock", - "text": "${fiveHourPaceStatus}", - "color": "${fiveHourPaceColor}", - "weight": "bolder", - "wrap": true, - "$when": "${fiveHourAvailable}" - }, - { - "type": "TextBlock", - "text": "${fiveHourProjection}", - "isSubtle": true, - "spacing": "small", - "wrap": true, - "$when": "${fiveHourAvailable}" } ] }, { "type": "Container", - "separator": true, - "spacing": "medium", + "$data": "${quotaGroups}", + "spacing": "medium", "separator": true, "items": [ + { "type": "TextBlock", "text": "${heading}", "weight": "bolder", "wrap": true }, { - "type": "TextBlock", - "text": "Weekly window", - "size": "large", - "weight": "bolder" + "type": "TextBlock", "text": "${inactiveWindows}", + "isSubtle": true, "spacing": "small", "wrap": true, + "$when": "${hasInactiveWindows}" }, { - "type": "TextBlock", - "text": "${weeklyState}", - "weight": "bolder", - "wrap": true, - "$when": "${weeklyAvailable == false}" - }, - { - "type": "ColumnSet", - "$when": "${weeklyAvailable}", + "type": "ColumnSet", "spacing": "small", "columns": [ { - "type": "Column", - "width": "stretch", + "type": "Column", "width": "stretch", "$data": "${windows}", "items": [ { - "type": "TextBlock", - "text": "Available", - "isSubtle": true + "type": "TextBlock", "text": "${title}", "weight": "bolder", "wrap": true, + "$when": "${showTitle}" }, { - "type": "TextBlock", - "text": "${weeklyRemaining}", - "size": "large", - "weight": "bolder", - "spacing": "none" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "Reset", - "isSubtle": true, - "horizontalAlignment": "right" + "type": "ColumnSet", "spacing": "small", + "columns": [ + { + "type": "Column", "width": "stretch", + "items": [ { "type": "TextBlock", "text": "Used", "isSubtle": true } ] + }, + { + "type": "Column", "width": "auto", + "items": [ + { + "type": "TextBlock", "text": "${usedPercent}", + "size": "large", "weight": "bolder", "color": "${usedColor}", + "horizontalAlignment": "right" + } + ] + } + ] }, { - "type": "TextBlock", - "text": "${weeklyReset}", - "horizontalAlignment": "right", - "spacing": "none", - "wrap": true - } - ] - } - ] - }, - { - "type": "ColumnSet", - "spacing": "medium", - "$when": "${weeklyAvailable}", - "columns": [ - { - "type": "Column", - "width": "stretch", - "items": [ + "type": "Image", "url": "${usedBarUrl}", "altText": "${usedBarAlt}", + "size": "stretch", "height": "12px", "spacing": "none" + }, { - "type": "TextBlock", - "text": "Allowance used", - "isSubtle": true - } - ] - }, - { - "type": "Column", - "width": "auto", - "items": [ + "type": "ColumnSet", "spacing": "small", + "columns": [ + { + "type": "Column", "width": "stretch", + "items": [ { "type": "TextBlock", "text": "Time elapsed", "isSubtle": true } ] + }, + { + "type": "Column", "width": "auto", + "items": [ + { "type": "TextBlock", "text": "${elapsedPercent}", "horizontalAlignment": "right" } + ] + } + ] + }, { - "type": "TextBlock", - "text": "${weeklyUsedPercent}", - "weight": "bolder", - "horizontalAlignment": "right" - } - ] - } - ] - }, - { - "type": "Image", - "url": "${weeklyUsedBarUrl}", - "altText": "${weeklyUsedBarAlt}", - "size": "stretch", - "spacing": "none", - "$when": "${weeklyAvailable}" - }, - { - "type": "ColumnSet", - "spacing": "small", - "$when": "${weeklyAvailable}", - "columns": [ - { - "type": "Column", - "width": "stretch", - "items": [ + "type": "Image", "url": "${elapsedBarUrl}", "altText": "${elapsedBarAlt}", + "size": "stretch", "height": "12px", "spacing": "none", "$when": "${elapsedAvailable}" + }, { - "type": "TextBlock", - "text": "Window elapsed", - "isSubtle": true + "type": "TextBlock", "text": "${reset}", + "isSubtle": true, "spacing": "small", "wrap": true } ] }, { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "TextBlock", - "text": "${weeklyElapsedPercent}", - "weight": "bolder", - "horizontalAlignment": "right" - } - ] + "type": "Column", "width": "stretch", "items": [], + "$when": "${singleWindow}" } ] - }, - { - "type": "Image", - "url": "${weeklyElapsedBarUrl}", - "altText": "${weeklyElapsedBarAlt}", - "size": "stretch", - "spacing": "none", - "$when": "${weeklyAvailable}" - }, - { - "type": "TextBlock", - "text": "${weeklyPaceStatus}", - "color": "${weeklyPaceColor}", - "weight": "bolder", - "wrap": true, - "$when": "${weeklyAvailable}" - }, - { - "type": "TextBlock", - "text": "${weeklyProjection}", - "isSubtle": true, - "spacing": "small", - "wrap": true, - "$when": "${weeklyAvailable}" - }, + } + ] + }, + { + "type": "Container", "separator": true, "spacing": "medium", + "$when": "${weeklyAvailable}", + "items": [ + { "type": "TextBlock", "text": "Codex · weekly usage", "weight": "bolder" }, { - "type": "TextBlock", - "text": "${weeklyBudget}", - "isSubtle": true, - "spacing": "small", - "wrap": true, - "$when": "${weeklyAvailable}" + "type": "Image", "url": "${weeklyTrendChartUrl}", "altText": "${weeklyTrendChartAlt}", + "size": "stretch", "spacing": "small", "$when": "${weeklyTrendAvailable}" }, { - "type": "Container", - "separator": true, - "spacing": "medium", - "$when": "${weeklyTrendAvailable}", - "items": [ - { - "type": "TextBlock", - "text": "Weekly trend", - "weight": "bolder" - }, - { - "type": "TextBlock", - "text": "${weeklyTrendLegend}", - "isSubtle": true, - "spacing": "none", - "wrap": true - }, - { - "type": "Image", - "url": "${weeklyTrendChartUrl}", - "altText": "${weeklyTrendChartAlt}", - "size": "stretch", - "spacing": "small" - }, - { - "type": "TextBlock", - "text": "${weeklyRestorationSummary}", - "color": "Warning", - "spacing": "small", - "wrap": true, - "$when": "${weeklyRestorationAvailable}" - }, - { - "type": "TextBlock", - "text": "${weeklyForecastStatus}", - "isSubtle": true, - "spacing": "small", - "wrap": true - } - ] + "type": "TextBlock", "text": "${weeklyForecastSummary}", + "isSubtle": true, "wrap": true, "spacing": "small" } ] } @@ -415,8 +160,6 @@ internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette var progressWidth = (BarWidth - 2d) * normalized / 100; var fillColor = palette switch { - UsageBarPalette.FiveHour => "#39B8E3", - UsageBarPalette.Weekly => "#5C9EFA", UsageBarPalette.Time => "#8A8A8A", _ => "#5C9EFA", }; diff --git a/CodexUsageDock/UsageFreshness.cs b/CodexUsageDock/UsageFreshness.cs index 15ac4d4..5cd8553 100644 --- a/CodexUsageDock/UsageFreshness.cs +++ b/CodexUsageDock/UsageFreshness.cs @@ -61,5 +61,6 @@ internal static bool IsValidWindow(RateLimitWindow? window, DateTimeOffset now) window is { WindowMinutes: > 0 } candidate && double.IsFinite(candidate.UsedPercent) && candidate.UsedPercent is >= 0 and <= 100 + && (long)candidate.WindowMinutes * TimeSpan.TicksPerMinute <= Math.Min(candidate.ResetsAt.Ticks, candidate.ResetsAt.UtcTicks) && candidate.ResetsAt > now; } diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 003bb48..674cb85 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -8,65 +8,18 @@ internal sealed record WeeklyUsageTrendChart(string ImageUrl, string AltText); internal static class WeeklyUsageTrendChartRenderer { - internal const int Height = 136; + internal const int Width = 960; + internal const int Height = 240; internal const int MaximumRenderedPoints = 180; - private const double Left = 38; - private const double Right = 38; - private const double TrendTop = 8; - private const double TrendBottom = 114; + private const double Left = 58; + private const double Right = 58; + private const double TrendTop = 28; + private const double TrendBottom = 207; private const double TrendHeight = TrendBottom - TrendTop; - private const double DayLabelTop = 122; - private const int BitmapGlyphColumns = 3; - private const int BitmapGlyphRows = 5; - private const double BitmapCellSize = 2; - private const double BitmapGlyphGap = 1; - private const string AxisLabelFill = "#C8C8C8"; + private const double DayLabelTop = 219; + private const string AxisLabelFill = "#888888"; private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; - private static readonly Dictionary BitmapGlyphs = new() - { - ['0'] = "111101101101111", - ['1'] = "010110010010111", - ['2'] = "111001111100111", - ['3'] = "111001111001111", - ['4'] = "101101111001001", - ['5'] = "111100111001111", - ['6'] = "111100111101111", - ['7'] = "111001010010010", - ['8'] = "111101111101111", - ['9'] = "111101111001111", - ['A'] = "010101111101101", - ['B'] = "110101110101110", - ['C'] = "111100100100111", - ['D'] = "110101101101110", - ['E'] = "111100110100111", - ['F'] = "111100110100100", - ['G'] = "111100101101111", - ['H'] = "101101111101101", - ['I'] = "111010010010111", - ['J'] = "001001001101111", - ['K'] = "101101110101101", - ['L'] = "100100100100111", - ['M'] = "101111111101101", - ['N'] = "101111111111101", - ['O'] = "111101101101111", - ['P'] = "111101111100100", - ['Q'] = "111101101111001", - ['R'] = "110101110101101", - ['S'] = "111100111001111", - ['T'] = "111010010010010", - ['U'] = "101101101101111", - ['V'] = "101101101101010", - ['W'] = "101101111111101", - ['X'] = "101101010101101", - ['Y'] = "101101010010010", - ['Z'] = "111001010100111", - [' '] = "000000000000000", - ['%'] = "101001010100101", - ['.'] = "000000000000010", - ['?'] = "111001010000010", - }; - internal static WeeklyUsageTrendChart? Create( IReadOnlyList history, RateLimitWindow window, @@ -89,7 +42,7 @@ internal static class WeeklyUsageTrendChartRenderer return null; } - var displayCulture = culture ?? CultureInfo.CurrentCulture; + var displayCulture = culture ?? CultureInfo.InvariantCulture; var displayTimeZone = timeZone ?? TimeZoneInfo.Local; var restorations = WeeklyAllowanceRestoration.Detect(history, window, effectiveNow); var samples = Normalize(history, windowStart, effectiveNow); @@ -143,9 +96,9 @@ internal static class WeeklyUsageTrendChartRenderer private static XElement CreateDocument() => new( Svg + "svg", - new XAttribute("width", UsageDashboardCard.BarWidth), + new XAttribute("width", Width), new XAttribute("height", Height), - new XAttribute("viewBox", $"0 0 {UsageDashboardCard.BarWidth} {Height}"), + new XAttribute("viewBox", $"0 0 {Width} {Height}"), new XAttribute("role", "img")); private static UsageHistoryEntry[] Normalize( @@ -364,25 +317,25 @@ private static void AddTrendGrid(XElement document) { foreach (var percent in new[] { 100, 50, 0 }) { - var y = GetTrendY(percent); + var y = GetTrendY(100 - percent); document.Add( new XElement( Svg + "line", new XAttribute("x1", Left), - new XAttribute("x2", UsageDashboardCard.BarWidth - Right), + new XAttribute("x2", Width - Right), new XAttribute("y1", Format(y)), new XAttribute("y2", Format(y)), new XAttribute("stroke", "#7A7A7A"), new XAttribute("stroke-opacity", "0.42"), new XAttribute("stroke-width", "1"), - new XAttribute("data-grid", "remaining-percent"), + new XAttribute("data-grid", "used-percent"), new XAttribute("data-value", percent))); - AddBitmapLabel( + AddChartLabel( document, $"{percent}%", Left - 5, - Math.Clamp(y - BitmapLabelHeight / 2, 0, Height - BitmapLabelHeight), - BitmapLabelAlignment.End, + Math.Clamp(y - ChartLabelHeight / 2, 0, Height - ChartLabelHeight), + ChartLabelAlignment.End, "vertical"); } } @@ -397,34 +350,34 @@ private static void AddTokenAxis(XElement document, long tokenScaleMaximum) foreach (var (tokens, y) in new[] { // Keep the top token label below the reset label at the chart boundary. - (tokenScaleMaximum, TrendTop + BitmapLabelHeight + 2), + (tokenScaleMaximum, TrendTop + ChartLabelHeight + 2), (tokenScaleMaximum / 2d, TrendTop + TrendHeight / 2), }) { - AddBitmapLabel( + AddChartLabel( document, FormatCompactTokens(tokens), - UsageDashboardCard.BarWidth - Right + 5, - Math.Clamp(y - BitmapLabelHeight / 2, 0, Height - BitmapLabelHeight), - BitmapLabelAlignment.Start, + Width - Right + 5, + Math.Clamp(y - ChartLabelHeight / 2, 0, Height - ChartLabelHeight), + ChartLabelAlignment.Start, "tokens"); } } - private static void AddBitmapLabel( + private static void AddChartLabel( XElement document, string label, double anchorX, double top, - BitmapLabelAlignment alignment, + ChartLabelAlignment alignment, string axis) { - var rendered = NormalizeBitmapLabel(label); - var width = MeasureBitmapLabel(rendered); + var rendered = NormalizeChartLabel(label); + var width = MeasureChartLabel(rendered); var left = alignment switch { - BitmapLabelAlignment.Center => anchorX - width / 2, - BitmapLabelAlignment.End => anchorX - width, + ChartLabelAlignment.Center => anchorX - width / 2, + ChartLabelAlignment.End => anchorX - width, _ => anchorX, }; var group = new XElement( @@ -433,35 +386,25 @@ private static void AddBitmapLabel( new XAttribute("data-axis-label", label), new XAttribute("data-rendered-label", rendered)); - for (var characterIndex = 0; characterIndex < rendered.Length; characterIndex++) + var glyphLeft = left; + foreach (var character in rendered) { - var glyph = BitmapGlyphs[rendered[characterIndex]]; - var glyphLeft = left + characterIndex * (BitmapGlyphColumns * BitmapCellSize + BitmapGlyphGap); - for (var row = 0; row < BitmapGlyphRows; row++) + var glyph = ChartLabelGlyphs.Values[character]; + if (glyph.Path.Length > 0) { - for (var column = 0; column < BitmapGlyphColumns; column++) - { - if (glyph[row * BitmapGlyphColumns + column] != '1') - { - continue; - } - - group.Add( - new XElement( - Svg + "rect", - new XAttribute("x", Format(glyphLeft + column * BitmapCellSize)), - new XAttribute("y", Format(top + row * BitmapCellSize)), - new XAttribute("width", Format(BitmapCellSize)), - new XAttribute("height", Format(BitmapCellSize)), - new XAttribute("fill", AxisLabelFill))); - } + group.Add(new XElement( + Svg + "path", + new XAttribute("d", glyph.Path), + new XAttribute("transform", $"translate({Format(glyphLeft)} {Format(top)})"), + new XAttribute("fill", AxisLabelFill))); } + glyphLeft += glyph.Width; } document.Add(group); } - private static string NormalizeBitmapLabel(string label) + private static string NormalizeChartLabel(string label) { var normalized = new StringBuilder(label.Length); foreach (var character in label.Normalize(NormalizationForm.FormD)) @@ -472,13 +415,13 @@ private static string NormalizeBitmapLabel(string label) } var glyph = char.ToUpperInvariant(character); - normalized.Append(BitmapGlyphs.ContainsKey(glyph) ? glyph : '?'); + normalized.Append(ChartLabelGlyphs.Values.ContainsKey(glyph) ? glyph : '?'); } return normalized.Length > 0 ? normalized.ToString() : "?"; } - private static bool CanRenderBitmapLabel(string label) + private static bool CanRenderChartLabel(string label) { var hasGlyph = false; foreach (var character in label.Normalize(NormalizationForm.FormD)) @@ -489,7 +432,7 @@ private static bool CanRenderBitmapLabel(string label) } hasGlyph = true; - if (!BitmapGlyphs.ContainsKey(char.ToUpperInvariant(character))) + if (!ChartLabelGlyphs.Values.ContainsKey(char.ToUpperInvariant(character))) { return false; } @@ -503,16 +446,16 @@ private static string FormatCalendarDayLabel(DateOnly date, CultureInfo culture) var label = culture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek) .Trim() .TrimEnd('.'); - var weekday = CanRenderBitmapLabel(label) + var weekday = CanRenderChartLabel(label) ? label : CultureInfo.InvariantCulture.DateTimeFormat.GetAbbreviatedDayName(date.DayOfWeek); return $"{weekday} {date.Day}"; } - private static double MeasureBitmapLabel(string label) => - label.Length * BitmapGlyphColumns * BitmapCellSize + Math.Max(0, label.Length - 1) * BitmapGlyphGap; + private static double MeasureChartLabel(string label) => + label.Sum(character => ChartLabelGlyphs.Values[character].Width); - private static double BitmapLabelHeight => BitmapGlyphRows * BitmapCellSize; + private static double ChartLabelHeight => ChartLabelGlyphs.Height; private static void AddResetMarkers( XElement document, @@ -538,7 +481,7 @@ private static void AddResetMarkers( new XAttribute("stroke-width", "1"), new XAttribute("stroke-dasharray", "2 2"), new XAttribute("data-marker", $"reset-{edge}"))); - AddBitmapLabel(document, "RESET", x, 0, BitmapLabelAlignment.Center, "marker"); + AddChartLabel(document, "RESET", x, 0, ChartLabelAlignment.Center, "marker"); } } @@ -566,9 +509,9 @@ private static void AddNowMarker( new XAttribute("stroke-opacity", "0.45"), new XAttribute("stroke-width", "1"), new XAttribute("data-marker", "now"))); - var labelWidth = MeasureBitmapLabel("NOW"); - var labelX = Math.Clamp(x, Left + labelWidth / 2, UsageDashboardCard.BarWidth - Right - labelWidth / 2); - AddBitmapLabel(document, "NOW", labelX, 0, BitmapLabelAlignment.Center, "marker"); + var labelWidth = MeasureChartLabel("NOW"); + var labelX = Math.Clamp(x, Left + labelWidth / 2, Width - Right - labelWidth / 2); + AddChartLabel(document, "NOW", labelX, 0, ChartLabelAlignment.Center, "marker"); } private static void AddRestorationMarkers( @@ -743,12 +686,12 @@ private static void AddDailyTokenBars( new XAttribute("stroke-width", day.IsCurrent ? "1" : "0"))); } - AddBitmapLabel( + AddChartLabel( document, FormatCalendarDayLabel(day.Date, culture), (dayLeft + dayRight) / 2, DayLabelTop, - BitmapLabelAlignment.Center, + ChartLabelAlignment.Center, "horizontal"); } } @@ -769,17 +712,17 @@ private static string FormatAltText( 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 = FormatDailyTokenAltText(dailyUse, tokenUsage, tokenScaleMaximum, culture); + var daily = tokenUsage is null ? string.Empty : FormatDailyTokenAltText(dailyUse, tokenUsage, tokenScaleMaximum, culture); var forecastText = forecast switch { { ReachesLimitBeforeReset: true } => $" Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, culture, timeZone)}; actual usage may differ.", - { } => $" Forecast leaves about {forecast.RemainingPercent:0}% at reset; actual usage may differ.", + { } => $" Forecast reaches about {100 - forecast.RemainingPercent:0}% used at reset; actual usage may differ.", null => " Forecast is unavailable.", }; var restorationText = restorations.Length > 0 ? $" {restorations.Length} allowance restoration{(restorations.Length == 1 ? " was" : "s were")} detected; the latest at {TimeZoneInfo.ConvertTime(restorations[^1].DetectedAt, timeZone).ToString("ddd d MMM HH:mm", culture)} increased remaining allowance from {restorations[^1].PreviousRemainingPercent:0}% to {restorations[^1].CurrentRemainingPercent:0}%. Amber markers show detected restorations." : " No allowance restorations were detected in this window."; - return $"Weekly quota trend from {period}. Remaining allowance changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is remaining allowance from 0% to 100%; the independent right scale is locally observed total tokens per calendar day. Horizontal labels are local calendar dates, and reset markers bound the quota window. Solid line connects continuous measurements; gaps and allowance increases break the line; dashed line is a conditional forecast.{restorationText}{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Used quota changed from {100 - first.RemainingPercent:0}% to {100 - last.RemainingPercent:0}% across {sampleCount} observations. The left vertical scale is used 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, and reset markers bound the quota window. Solid line connects continuous measurements; gaps and allowance increases break the line; dashed line is a conditional forecast.{restorationText}{forecastText} {daily}"; } private static string FormatDailyTokenAltText( @@ -828,11 +771,11 @@ private static string FormatPoints( points.Select(point => $"{Format(calendarScale.GetX(point.RecordedAt))},{Format(GetTrendY(point.RemainingPercent))}")); private static double GetTrendY(double remainingPercent) => - TrendTop + (100 - Math.Clamp(remainingPercent, 0, 100)) / 100 * TrendHeight; + TrendTop + Math.Clamp(remainingPercent, 0, 100) / 100 * TrendHeight; private static string Format(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); - private enum BitmapLabelAlignment + private enum ChartLabelAlignment { Start, Center, @@ -892,7 +835,7 @@ public double GetX(DateTimeOffset instant) } } - return UsageDashboardCard.BarWidth - Right; + return Width - Right; } public double GetDayStartX(int index) => Left + index * DayWidth; @@ -900,7 +843,7 @@ public double GetX(DateTimeOffset instant) public double GetDayEndX(int index) => Left + (index + 1) * DayWidth; private double DayWidth => _days.Count > 0 - ? (UsageDashboardCard.BarWidth - Left - Right) / _days.Count + ? (Width - Left - Right) / _days.Count : 0; } } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c66473d..69038c2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -58,6 +58,10 @@ Always specify both `Platform` and its matching RID. Omitting `-r win-x64` or `- 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. +### Chart labels + +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. Runtime builds use the checked-in outlines and need no font loading or drawing dependency. + ## Forecast evaluation Run `WeeklyForecastTests` for observation-quality gates, idle periods, shifted resets, partial coverage, and chronological synthetic forecasts at six hours, 24 hours, and reset. The rolling benchmark uses only completed preceding weeks and data available at each origin, compares mean absolute error in percentage points with a current-pace baseline, and prints both errors. This repeated-pattern benchmark does not establish accuracy on real users or unexpected workload changes. Calibration on representative real histories is required before adding probabilistic intervals or making general accuracy claims. Existing short-window arithmetic fixtures stay short; weekly projection fixtures must satisfy the new observation duration and coverage requirements. @@ -92,7 +96,7 @@ Complete every row on a clean x64 environment and a separate clean ARM64 environ | Automated preflight | Required | Required | `test-integration.ps1` passes manifest, package, CLSID, COM, and AppExtension checks. | | Start-menu visibility | Required | Required | Codex Usage does not appear as a standalone app in Start; it is activated only by Command Palette through its packaged COM registration. | | Discovery and reload | Required | Required | After **Reload Command Palette Extension**, **Codex Usage** appears once under **Settings > Extensions** and can be enabled. | -| Details page | Required | Required | Opening **Codex Usage** shows five-hour and weekly quota summaries that compare allowance used with elapsed window time and include a projection for each active window. The weekly view spans the quota window: continuous observed allowance is solid, a fresh projection is dashed, bars show locally observed `last_token_usage.total_tokens` per local calendar day on an independent right-hand scale, dated weekday labels include the partial first and last reset-boundary days, reset/current-time markers clarify the timeline, and amber markers identify detected allowance restorations. The token legend must identify partial local-log coverage, and unavailable token data must omit only the bars while leaving allowance data usable. The latest restoration is summarized below the chart and every restoration in the active window appears in the native Details pane; normal scheduled window rollovers must not appear as restorations. Weekly projections require at least 30 continuous minutes and use at most six recent hours. Their basis identifies usable historical weeks and coverage; insufficient history falls back to a conditional recent-pace scenario. The dashed line can change hourly and at six-hour boundaries. Verify that measurement gaps break the solid line, isolated samples remain visible, the remaining daily/hourly budget is shown independently of a forecast, and weekly alerts follow the same adaptive setting as the chart. Resets, credits, account status, and source appear in the native Details pane. Verify pace labels, inactive/projection fallback states, chart gap handling, restoration marker contrast and text wrapping, visible 0–100% allowance and token axes, partial-day placement, semantic status colors, narrow and wide window sizes, light/dark themes, and high contrast; manual refresh must update both areas without clipping or freezing Command Palette. | +| Dashboard and Details | Required | Required | Opening **Codex Usage** starts with Details collapsed. Codex, Spark, and other reported categories share Used/time bars with actual window durations, one local reset date, and large used percentages; missing/expired windows take a compact text row and never become zero. Verify that high usage remains visible, stale/last-confirmed observations keep their age and suppress time comparisons, and local fallback remains labeled. **Details** opens and closes the native pane and survives refresh without losing focus or scroll unnecessarily. It contains reset-credit expirations, credit balance, account/source status, forecast basis, remaining daily/hourly budget, chart explanation, and restoration history. The main chart uses a 0–100% used-quota axis, smooth readable labels, solid observations, conditional dashed forecasts, reset/current-time markers, and amber restorations. Increased usage moves upward; restored allowance moves downward and breaks the line. Gaps also break the line; isolated samples remain visible. The compact forecast status explains the recent continuous-data requirement and becomes an estimate only after its gates pass. Local token bars and their independent right-hand axis appear only while Details is open and must identify partial coverage; they use local calendar days, including partial boundary days. Verify narrow/wide layouts, light/dark/high-contrast themes, label contrast, keyboard access to Details, and refresh without clipping or freezing. | | Dock band | Required | Required | The band can be added, each enabled item opens details, and values update while Command Palette remains responsive. | | Settings | Required | Required | Visibility, reset-time, refresh-interval, and adaptive-forecast choices apply immediately and persist after restarting Command Palette. Verify that disabling pauses learning without replaying measurements collected while paused, retains history, and that deleting learned history requires confirmation. | | Live app-server | Required | Required | With a signed-in standalone Codex CLI, the details page identifies the CLI app-server as the source and refreshes live data. | diff --git a/README.md b/README.md index e7ad968..7748f3a 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ GitHub releases identify source versions and may differ from the version availab | `2 resets · 10.00` | Two earned resets are available; `10.00` is the reported credits balance. | | `Reset - …` / `Expires - …` | The next quota reset or earned-reset expiry, using your local time zone and regional date format. | -Percentages show **remaining allowance**, not usage already consumed. Earned resets and the credits balance are separate values; missing information is not treated as zero. The overall status reflects the most restrictive active quota window. +Dock-band percentages show **remaining allowance**. Open **Codex Usage** for a compact dashboard: Codex and additional categories such as Spark use the same **Used** and **Time elapsed** bars. Dashboard percentages and the weekly line show **usage consumed**, on a 0–100% scale. Missing windows are marked **Not reported** or **Awaiting refresh**, never shown as zero. -Values refresh every minute by default. Details shows the data source and measurement time. During an outage, **Last confirmed** can retain the previous live measurement with its original timestamp. Local fallback data is labeled with its source and age. +Values refresh every minute by default. **Details** opens the data source, measurement time, reset-credit expirations, credits balance, and explanatory text; it starts collapsed. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. -Forecasts estimate future allowance from observed usage. Weekly forecasts require at least 30 minutes of fresh, continuous measurements and show their measurement basis. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. A separate budget shows the average allowance available per day until reset. Daily token bars reflect local session activity and are **not an exact measure of quota consumption**. +The weekly graph shows one short forecast status. Forecasts require at least 30 minutes of fresh, continuous measurements; their basis and the remaining daily budget are under **Details**. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. Daily token bars appear only while **Details** is open; they reflect local session activity and 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. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8b5bf34..a5e2d40 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -6,9 +6,11 @@ For installation and first use, see the [README](README.md#quick-start). This gu ## Charts and forecasts -The weekly chart shows sampled remaining allowance on a 0–100% scale. Its solid line connects continuous measurements and breaks at measurement gaps and allowance increases. Isolated measurements remain visible as points. Amber markers identify detected allowance restorations. The latest restoration is summarized below the chart, with the current window's restoration history in Details. +The dashboard groups Codex and additional quota categories such as Spark into the same compact windows. Each reported window shows **Used**, **Time elapsed**, and a local reset date. The two bars share a 0–100% scale: blue indicates consumption and gray indicates elapsed time. Large percentages also mean used quota. Missing or expired windows take one short line, without fabricated zero values. An unavailable time comparison is shown as **—**. Fresh high usage is highlighted by the percentage; the comparison does not predict whether future usage will fit. -Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Locally observed total-token bars use an independent daily scale. +The weekly chart also shows **used quota** on a 0–100% scale. Its solid line rises as quota is consumed and breaks at measurement gaps and allowance restorations; a restoration reduces the used percentage. Isolated measurements remain visible as points. Amber markers identify detected restorations. The chart has smooth font outlines and a short forecast status; the detailed explanation and restoration history are under **Details**. Dashboard date labels use English names and local time to match the English interface; Dock-band subtitles retain regional formatting. + +Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. **Details** starts collapsed to leave the dashboard the full width. Opening it reveals forecast explanations, the remaining daily/hourly budget, reset-credit expirations, credits, and source information. It also enables locally observed total-token bars on an independent daily scale. Hiding Details removes those bars again; refreshing retains the current Details choice for that page. Weekly projections require at least 30 minutes of fresh, continuous measurements. Recent pace uses at most the last six hours of that segment, so an old idle period cannot dilute the estimate indefinitely. With insufficient learned history, the projection explicitly describes what would happen if that recent pace continued. Its status identifies the measurement duration. The five-hour projection retains its shorter observation requirement. @@ -18,7 +20,7 @@ Six-hour patterns require at least three usable weeks with at least three observ A segment starts again after an allowance increase or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Text, chart, and alert projections pause until enough fresh measurements are available. Without usable history, a meaningful decrease is also required; with usable history, observed idle time can still produce an estimate. Weekly warning notifications follow the same adaptive setting and calculation as the dashboard. -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 separate budget shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Pace indicators compare allowance used with elapsed window time; neither they nor the budget guarantee future consumption. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. +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 Details shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Used/time bars compare consumption with elapsed window time; 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 details 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. @@ -70,7 +72,7 @@ Codex is detected using the environment configuration described in [Requirements ### Quota categories -Details retains separate quota categories returned by newer Codex versions, including durations other than five hours or one week. The familiar Dock entries and weekly forecast describe only the default category. Percentages from different categories are never added or averaged. Credits remain usable even when there are no percentage windows. +The dashboard renders separate quota categories returned by newer Codex versions with the same Used/time bars, including durations other than five hours or one week. The familiar Dock entries and weekly forecast describe only the default category. Percentages from different categories are never added or averaged. Available reset credits have a compact count; an expiry within 24 hours is highlighted. The full credits balance is under Details, and credits remain usable even when there are no percentage windows. ### Freshness and account isolation diff --git a/scripts/generate-chart-labels.ps1 b/scripts/generate-chart-labels.ps1 new file mode 100644 index 0000000..a78eb6a --- /dev/null +++ b/scripts/generate-chart-labels.ps1 @@ -0,0 +1,60 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.Drawing + +# Bake smooth font outlines: the Windows SVG image renderer does not render SVG text. +# This keeps font loading and native drawing out of the extension's refresh path. +$font = [System.Drawing.FontFamily]::new('Segoe UI') +$format = [System.Drawing.StringFormat]::GenericTypographic +$culture = [Globalization.CultureInfo]::InvariantCulture +$source = [Text.StringBuilder]::new() +[void]$source.AppendLine('// Generated by scripts/generate-chart-labels.ps1 from Segoe UI. Do not edit glyphs by hand.') +[void]$source.AppendLine('namespace CodexUsageDock;') +[void]$source.AppendLine('') +[void]$source.AppendLine('internal static class ChartLabelGlyphs') +[void]$source.AppendLine('{') +[void]$source.AppendLine(' internal const double Height = 16;') +[void]$source.AppendLine(' internal static readonly Dictionary Values = new()') +[void]$source.AppendLine(' {') +try { + foreach ($character in '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ %.?'.ToCharArray()) { + $path = [System.Drawing.Drawing2D.GraphicsPath]::new() + try { + $path.AddString([string]$character, $font, [int][Drawing.FontStyle]::Regular, 18, [Drawing.PointF]::Empty, $format) + $bounds = $path.GetBounds() + $points = $path.PathPoints + $types = $path.PathTypes + $commands = [Text.StringBuilder]::new() + for ($index = 0; $index -lt $points.Length; $index++) { + $kind = $types[$index] -band 7 + $command = if ($kind -eq 0) { 'M' } elseif ($kind -eq 1) { 'L' } else { 'C' } + [void]$commands.Append($command) + $count = if ($kind -eq 3) { 3 } else { 1 } + for ($part = 0; $part -lt $count; $part++) { + $pointIndex = $index + $part + [void]$commands.Append(($points[$pointIndex].X - $bounds.X).ToString('0.###', $culture)) + [void]$commands.Append(' ') + # Retain the font baseline so punctuation aligns with letters. + [void]$commands.Append(($points[$pointIndex].Y - 3).ToString('0.###', $culture)) + [void]$commands.Append(' ') + } + $index += $count - 1 + if (($types[$index] -band 128) -ne 0) { [void]$commands.Append('Z') } + } + $width = if ($character -eq ' ') { 5 } else { $bounds.Width + 2 } + [void]$source.AppendLine(" ['$character'] = ($($width.ToString('0.###', $culture)), `"$commands`"),") + } + finally { $path.Dispose() } + } +} +finally { + $format.Dispose() + $font.Dispose() +} +[void]$source.AppendLine(' };') +[void]$source.AppendLine('}') +$destination = Join-Path (Split-Path -Parent $PSScriptRoot) 'CodexUsageDock/ChartLabelGlyphs.cs' +[IO.File]::WriteAllText($destination, $source.ToString(), [Text.UTF8Encoding]::new($false)) From 26af8d69c643bac84892b24ebf37cb7eaa55f0f2 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:52:44 +0200 Subject: [PATCH 2/5] Link dashboard changelog entry to implementing commit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cba17..b3fd1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ### Changed -- Simplify the dashboard with shared Used/time bars for Codex and Spark quota windows, collapsed Details, concise forecast status, and a larger used-quota chart with smooth labels. Show local token bars only with Details and keep missing or stale values explicit. +- Simplify the dashboard with shared Used/time bars for Codex and Spark quota windows, collapsed Details, concise forecast status, and a larger used-quota chart with smooth labels. Show local token bars only with Details and keep missing or stale values explicit. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/f56e4ed6c2465a9f89f273412439e4735b79d2a4)) - Clarify contributor guidance on installation authorization, isolated test storage, architecture verification, and changelog links, and consolidate repeated instructions. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/0661e46a12ae9ae906a1ddbcb8fc70874fa2071d)) From 5aa8c04ed627bd1cf239bb499a6b8ec56e05589e Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:22:35 +0200 Subject: [PATCH 3/5] Filter dashboard reset expiry warnings to available credits --- CHANGELOG.md | 4 ++++ CodexUsageDock.Tests/UsageDashboardTests.cs | 23 +++++++++++++++++++++ CodexUsageDock/Pages/CodexUsageDockPage.cs | 2 +- CodexUsageDock/UsageAlerts.cs | 5 +---- CodexUsageDock/UsageData.cs | 6 +++++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fd1f4..e99c651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +### Fixed + +- Ignore unavailable reset credits when selecting dashboard expiry warnings, using the same availability rule as notifications. ([PR #28](https://github.com/TheBeems/CodexUsageDock/pull/28)) + ### Changed - Simplify the dashboard with shared Used/time bars for Codex and Spark quota windows, collapsed Details, concise forecast status, and a larger used-quota chart with smooth labels. Show local token bars only with Details and keep missing or stale values explicit. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/f56e4ed6c2465a9f89f273412439e4735b79d2a4)) diff --git a/CodexUsageDock.Tests/UsageDashboardTests.cs b/CodexUsageDock.Tests/UsageDashboardTests.cs index 8de3489..ab04856 100644 --- a/CodexUsageDock.Tests/UsageDashboardTests.cs +++ b/CodexUsageDock.Tests/UsageDashboardTests.cs @@ -215,6 +215,29 @@ public void UnrecognizedFormInputCannotToggleDetails(string payload) Assert.Null(page.Details); } + [Theory] + [InlineData("used", false)] + [InlineData("expired", false)] + [InlineData("unknown", false)] + [InlineData("available", true)] + [InlineData("AVAILABLE", true)] + [InlineData(" available ", true)] + [InlineData(null, true)] + public void ResetExpiryEmphasisOnlyUsesAvailableCredits(string? status, bool urgent) + { + using var data = Render(Snapshot() with + { + ResetCredits = new(1, + [ + new("Full reset", status, Now.AddHours(2)), + new("Full reset", "available", Now.AddDays(19)), + ]), + }); + var summary = data.RootElement.GetProperty("resetCreditsSummary").GetString()!; + Assert.Equal(urgent, summary.Contains("expires", StringComparison.Ordinal)); + Assert.Equal(urgent ? "Warning" : "Default", data.RootElement.GetProperty("resetCreditsColor").GetString()); + } + [Theory] [InlineData(19, "Default")] [InlineData(0.5, "Warning")] diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 8110cdf..7de829a 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -68,7 +68,7 @@ internal static string FormatMainDataJson( var dataAvailable = IsDataAvailable(snapshot, freshness); var maximumSampleAge = TrendFreshness(refreshInterval); var notice = FormatDashboardNotice(snapshot, now, freshness); - var nextExpiry = snapshot.ResetCredits?.Credits?.Where(credit => credit.ExpiresAt > now) + var nextExpiry = snapshot.ResetCredits?.Credits?.Where(credit => credit is { IsAvailable: true } && credit.ExpiresAt > now) .MinBy(credit => credit.ExpiresAt)?.ExpiresAt; var urgentReset = nextExpiry is { } expiry && expiry - now <= TimeSpan.FromDays(1); var resetCount = snapshot.ResetCredits?.AvailableCount ?? 0; diff --git a/CodexUsageDock/UsageAlerts.cs b/CodexUsageDock/UsageAlerts.cs index e252f1b..cf3d20d 100644 --- a/CodexUsageDock/UsageAlerts.cs +++ b/CodexUsageDock/UsageAlerts.cs @@ -363,7 +363,7 @@ private void EvaluateResetExpiry( .Where(credit => credit is not null && credit.ExpiresAt is { } expiry && expiry > now - && IsAvailableStatus(credit.Status)) + && credit.IsAvailable) .Select(credit => credit.ExpiresAt!.Value) .GroupBy(expiry => expiry.UtcTicks) .OrderBy(group => group.Key) @@ -398,9 +398,6 @@ private void EvaluateResetExpiry( } } - private static bool IsAvailableStatus(string? status) => - status is null || string.Equals(status.Trim(), "available", StringComparison.OrdinalIgnoreCase); - private static string GetWindowKey(WindowObservation observation) => observation.Key; private static string FormatWindowName(WindowObservation observation) diff --git a/CodexUsageDock/UsageData.cs b/CodexUsageDock/UsageData.cs index 41a6c1a..f546950 100644 --- a/CodexUsageDock/UsageData.cs +++ b/CodexUsageDock/UsageData.cs @@ -72,7 +72,11 @@ internal sealed record RateLimitBucket( internal sealed record CreditBalance(bool HasCredits, bool Unlimited, string? Balance); -internal sealed record RateLimitResetCredit(string? Title, string? Status, DateTimeOffset? ExpiresAt); +internal sealed record RateLimitResetCredit(string? Title, string? Status, DateTimeOffset? ExpiresAt) +{ + internal bool IsAvailable => Status is null + || string.Equals(Status.Trim(), "available", StringComparison.OrdinalIgnoreCase); +} internal sealed record RateLimitResetCredits(int AvailableCount, IReadOnlyList? Credits); From e2ec5c20f58e31933c714357aca8dc0267db92d4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:37:29 +0200 Subject: [PATCH 4/5] Release version 0.8.1 --- CHANGELOG.md | 3 +++ CodexUsageDock/CodexUsageDock.csproj | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e99c651..0a32927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,15 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +## [0.8.1] - 2026-09-15 + ### Fixed - Ignore unavailable reset credits when selecting dashboard expiry warnings, using the same availability rule as notifications. ([PR #28](https://github.com/TheBeems/CodexUsageDock/pull/28)) ### Changed +- Release version 0.8.1 with the compact dashboard and consistent quota bars. ([PR #28](https://github.com/TheBeems/CodexUsageDock/pull/28)) - Simplify the dashboard with shared Used/time bars for Codex and Spark quota windows, collapsed Details, concise forecast status, and a larger used-quota chart with smooth labels. Show local token bars only with Details and keep missing or stale values explicit. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/f56e4ed6c2465a9f89f273412439e4735b79d2a4)) - Clarify contributor guidance on installation authorization, isolated test storage, architecture verification, and changelog links, and consolidate repeated instructions. ([commit](https://github.com/TheBeems/CodexUsageDock/commit/0661e46a12ae9ae906a1ddbcb8fc70874fa2071d)) diff --git a/CodexUsageDock/CodexUsageDock.csproj b/CodexUsageDock/CodexUsageDock.csproj index e34741a..b01bab9 100644 --- a/CodexUsageDock/CodexUsageDock.csproj +++ b/CodexUsageDock/CodexUsageDock.csproj @@ -3,7 +3,7 @@ WinExe CodexUsageDock app.manifest - 0.8.0 + 0.8.1 10.0.26100.68-preview net10.0-windows10.0.26100.0 From 3a4cedf382909fcb8efb193020e887cafa3438dc Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:39:46 +0200 Subject: [PATCH 5/5] Align release version assertion with 0.8.1 --- CodexUsageDock.Tests/UsageDataTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 7389a6d..b972153 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -207,7 +207,7 @@ public void DetailsPageUsesTheProjectReleaseVersion() using var service = _environment.CreateService(); using var page = new CodexUsageDockPage(service, _environment.CreateSettings()); - Assert.Equal("0.8.0", CodexUsageDockMetadata.Version); + Assert.Equal("0.8.1", CodexUsageDockMetadata.Version); Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title); }