diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb8b76..be36a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ the [changelog workflow](AGENTS.md#documentation-and-change-quality). ## [Unreleased] +## [0.8.7] - 2026-10-09 + +### Added + +- Add a separate Time remaining bar that counts from 100% down to 0% until the scheduled quota reset, independently of remaining allowance. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34)) + +### Changed + +- Group allowance, conditional weekly forecast and Next scheduled reset into a compact dashboard. Keep the contrasting dated estimate above the chart, its quota forecast basis below it, and local token-log details in Usage information. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34)) + +### Fixed + +- Apply Show five-hour limit to dashboard windows and missing-data messages. Improve chart legends, punctuation, independent scales, reset markers and token-bar contrast; keep the dated forecast endpoint from obscuring the plot. ([PR #34](https://github.com/TheBeems/CodexUsageDock/pull/34)) + ## [0.8.6] - 2026-10-04 ### Fixed diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs index de41742..f84cd73 100644 --- a/CodexUsageDock.Tests/ProviderDockTests.cs +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -10,6 +10,47 @@ public sealed class ProviderDockTests : IDisposable private readonly TestEnvironment _environment = new(); public void Dispose() => _environment.Dispose(); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FiveHourSettingUpdatesDashboardImmediatelyWithoutReadingUsageAgain(bool reported) + { + var now = DateTimeOffset.UtcNow; + var reads = 0; + var snapshot = new CodexUsageSnapshot(reported ? new(25, 300, now.AddHours(4)) : null, + new(33, 10080, now.AddDays(6)), null, null, null, now, UsageDataSource.AppServer, null); + using var service = _environment.CreateService(_ => + { + Interlocked.Increment(ref reads); + return Task.FromResult(snapshot); + }, () => CodexUsageSnapshot.Loading, clock: () => now); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); + await service.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(5)); + var readsBeforeSettings = Volatile.Read(ref reads); + var page = Assert.IsType(provider.TopLevelCommands()[0].Command); + var content = Assert.IsType(Assert.Single(page.GetContent())); + Assert.Contains("5-hour", content.DataJson, StringComparison.Ordinal); + + SubmitSettings(settings, (ShowFiveHourLimitKey, "false")); + using (var hidden = JsonDocument.Parse(content.DataJson)) + { + var group = hidden.RootElement.GetProperty("quotaGroups")[0]; + Assert.Equal("Weekly", Assert.Single(group.GetProperty("windows").EnumerateArray()).GetProperty("title").GetString()); + Assert.False(group.GetProperty("hasInactiveWindows").GetBoolean()); + } + Assert.Equal(readsBeforeSettings, Volatile.Read(ref reads)); + + SubmitSettings(settings, (ShowFiveHourLimitKey, "true")); + using var restored = JsonDocument.Parse(content.DataJson); + var restoredGroup = restored.RootElement.GetProperty("quotaGroups")[0]; + if (reported) + Assert.Contains(restoredGroup.GetProperty("windows").EnumerateArray(), window => window.GetProperty("title").GetString() == "5-hour"); + else + Assert.Contains("5-hour", restoredGroup.GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + Assert.Equal(readsBeforeSettings, Volatile.Read(ref reads)); + } + [Fact] public async Task TogglingAlertsOffAndOnWithoutARefreshEstablishesANewBaseline() { diff --git a/CodexUsageDock.Tests/UsageDashboardTests.cs b/CodexUsageDock.Tests/UsageDashboardTests.cs index 13ef6b9..fdfe67b 100644 --- a/CodexUsageDock.Tests/UsageDashboardTests.cs +++ b/CodexUsageDock.Tests/UsageDashboardTests.cs @@ -24,17 +24,166 @@ private static CodexUsageSnapshot Snapshot() => CodexUsageSnapshot.Loading with DefaultBucketId = "codex", }; - private static JsonDocument Render(CodexUsageSnapshot snapshot, UsageHistoryEntry[]? history = null) => + private static JsonDocument Render(CodexUsageSnapshot snapshot, UsageHistoryEntry[]? history = null, + bool showFiveHourLimit = true) => JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson(snapshot, Now, false, [], history ?? [], TimeSpan.FromMinutes(1), tokenUsage: new LocalTokenUsageSnapshot([new DailyTokenUsage(new DateOnly(2026, 9, 15), 1000)], - Now, LocalTokenUsageStatus.Complete))); + Now, LocalTokenUsageStatus.Complete), showFiveHourLimit: showFiveHourLimit)); private static XDocument Chart(JsonElement data) => XDocument.Parse( Uri.UnescapeDataString(data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); + [Theory] + [InlineData(240)] + [InlineData(0)] + [InlineData(-1)] + public void HiddenFiveHourLimitOmitsReportedMissingAndExpiredDashboardWindows(int minutesLeft) + { + var snapshot = Snapshot() with + { + Primary = minutesLeft < 0 ? null : new(25, 300, Now.AddMinutes(minutesLeft)), + }; + using var hidden = Render(snapshot, showFiveHourLimit: false); + using var shown = Render(snapshot); + var group = hidden.RootElement.GetProperty("quotaGroups")[0]; + var weekly = Assert.Single(group.GetProperty("windows").EnumerateArray()); + Assert.Equal("Weekly", weekly.GetProperty("title").GetString()); + Assert.Equal("Codex · Weekly", group.GetProperty("heading").GetString()); + Assert.False(group.GetProperty("hasInactiveWindows").GetBoolean()); + Assert.Equal(string.Empty, group.GetProperty("inactiveWindows").GetString()); + var bar = XDocument.Parse(Uri.UnescapeDataString(weekly.GetProperty("remainingBarUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); + Assert.Equal(1072d, (double)bar.Root!.Attribute("width")!); + var visibleGroup = shown.RootElement.GetProperty("quotaGroups")[0]; + if (minutesLeft > 0) + Assert.Contains(visibleGroup.GetProperty("windows").EnumerateArray(), window => window.GetProperty("title").GetString() == "5-hour"); + else + Assert.Contains("5-hour", visibleGroup.GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void HidingFiveHourLimitPreservesOtherDurationsAndOmitsEmptyAdditionalGroups() + { + using var data = Render(Snapshot() with + { + Buckets = + [ + new("spark", "Spark", new(25, 300, Now.AddHours(4)), null), + new("other", "Other", new(10, 90, Now.AddMinutes(45)), null), + ], + }, showFiveHourLimit: false); + var groups = data.RootElement.GetProperty("quotaGroups"); + Assert.Equal(2, groups.GetArrayLength()); + Assert.Equal("Other · 90-minute", groups[1].GetProperty("heading").GetString()); + Assert.Equal("90-minute", Assert.Single(groups[1].GetProperty("windows").EnumerateArray()).GetProperty("title").GetString()); + } + + [Fact] + public void ForecastBasisAppearsBelowChartWithoutRestoringTokenCaption() + { + var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray(); + using var data = Render(Snapshot() with { Secondary = new(40, 10080, Now.AddDays(4)) }, history); + using var template = JsonDocument.Parse(UsageDashboardCard.TemplateJson); + var chart = Assert.Single(template.RootElement.GetProperty("body").EnumerateArray(), item => + item.TryGetProperty("items", out var items) + && items.EnumerateArray().Any(child => child.TryGetProperty("url", out var url) + && url.GetString() == "${weeklyTrendChartUrl}")); + var chartItems = chart.GetProperty("items"); + Assert.Equal(2, chartItems.GetArrayLength()); + Assert.Equal("${weeklyForecastStatus}", chartItems[1].GetProperty("text").GetString()); + Assert.Equal("${hasForecastBasis}", chartItems[1].GetProperty("$when").GetString()); + Assert.True(chartItems[1].GetProperty("wrap").GetBoolean()); + Assert.True(data.RootElement.GetProperty("hasForecastBasis").GetBoolean()); + Assert.DoesNotContain("weeklyTrendLegend", UsageDashboardCard.TemplateJson, StringComparison.Ordinal); + var details = CodexUsageDockPage.FormatForecastDetails(data.RootElement); + Assert.Contains("Quota forecast:", details, StringComparison.Ordinal); + Assert.Contains("Local token bars: available session logs.", details, StringComparison.Ordinal); + Assert.Contains("Amber markers: reset or restored allowance.", details, StringComparison.Ordinal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForecastBasisIsHiddenWithoutAFreshAvailableForecast(bool stale) + { + var history = stale + ? Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray() + : []; + using var data = Render(Snapshot() with { UpdatedAt = stale ? Now.AddHours(-1) : Now }, history); + Assert.False(data.RootElement.GetProperty("hasForecastBasis").GetBoolean()); + } + [Fact] - public void CodexAndSparkShareRemainingAndTimeBarsWithoutCombiningCategories() + public void FreshWeeklyStatusExplainsUsedQuotaAgainstElapsedTime() + { + using var data = Render(Snapshot() with + { + Primary = null, + Secondary = new(33, 10080, Now.AddDays(7).AddHours(-18)), + }); + var window = Assert.Single(data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); + Assert.Equal("67% remaining", window.GetProperty("remainingHeadline").GetString()); + Assert.Equal("33% used · 11% of week elapsed", window.GetProperty("usageComparison").GetString()); + } + + [Theory] + [InlineData(300, "100%", 1070)] + [InlineData(150, "50%", 535)] + [InlineData(3, "1%", 10.7)] + public void TimeRemainingBarCountsDownIndependentlyOfQuota(int minutesLeft, string expected, double expectedWidth) + { + using var data = Render(Snapshot() with { Primary = new(25, 300, Now.AddMinutes(minutesLeft)), Secondary = null }); + var window = Assert.Single(data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); + Assert.Equal("75% remaining", window.GetProperty("remainingHeadline").GetString()); + Assert.Equal($"Time remaining · {expected}", window.GetProperty("timeRemainingHeadline").GetString()); + Assert.Equal($"Codex, 5-hour: {expected} of time remaining until reset.", window.GetProperty("timeRemainingBarAlt").GetString()); + var bar = XDocument.Parse(Uri.UnescapeDataString(window.GetProperty("timeRemainingBarUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); + var fill = Assert.Single(bar.Descendants(Svg + "rect"), rect => rect.Attribute("fill")?.Value == "#73879B"); + Assert.Equal(expectedWidth, double.Parse(fill.Attribute("width")!.Value, CultureInfo.InvariantCulture), precision: 2); + } + + [Fact] + public void TimeRemainingBarIsEmptyAtZeroAndUnavailableOutsideTheReportedPeriod() + { + var empty = XDocument.Parse(Uri.UnescapeDataString(UsageDashboardCard.CreateProgressBarImageUrl(0, + fill: UsageDashboardCard.TimeBarFill)["data:image/svg+xml;utf8,".Length..])); + Assert.DoesNotContain(empty.Descendants(Svg + "rect"), rect => rect.Attribute("fill")?.Value == UsageDashboardCard.TimeBarFill); + using var future = Render(Snapshot() with { Primary = new(25, 300, Now.AddHours(6)), Secondary = null }); + var window = Assert.Single(future.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); + Assert.False(window.GetProperty("elapsedAvailable").GetBoolean()); + Assert.Equal(string.Empty, window.GetProperty("timeRemainingBarUrl").GetString()); + using var expired = Render(Snapshot() with { Primary = new(25, 300, Now), Secondary = null }); + Assert.Empty(expired.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows").EnumerateArray()); + Assert.Contains("Awaiting refresh", expired.RootElement.GetProperty("quotaGroups")[0].GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void StaleWeeklyStatusDoesNotCompareOldUsageWithCurrentTime() + { + using var data = Render(Snapshot() with { UpdatedAt = Now.AddHours(-1) }); + var window = data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[1]; + Assert.Equal("33% used at last observation · time comparison unavailable", window.GetProperty("usageComparison").GetString()); + Assert.DoesNotContain("of week elapsed", window.GetProperty("usageComparison").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void WeeklyForecastIsAttachedOnlyToTheDefaultWeeklyStatus() + { + var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray(); + using var data = Render(Snapshot() with { Secondary = new(40, 10080, Now.AddDays(4)) }, history); + var windows = data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows"); + Assert.False(windows[0].GetProperty("hasWeeklyForecast").GetBoolean()); + Assert.True(windows[1].GetProperty("hasWeeklyForecast").GetBoolean()); + Assert.StartsWith("At this pace, estimated to run out", windows[1].GetProperty("weeklyForecastSummary").GetString(), StringComparison.Ordinal); + Assert.Equal("Default", windows[1].GetProperty("forecastColor").GetString()); + Assert.Contains(Now.ToLocalTime().ToString("ddd d MMM", CultureInfo.InvariantCulture), + windows[1].GetProperty("weeklyForecastSummary").GetString(), StringComparison.Ordinal); + Assert.Contains(data.RootElement.GetProperty("weeklyForecastStatus").GetString()!, + CodexUsageDockPage.FormatForecastDetails(data.RootElement), StringComparison.Ordinal); + } + + [Fact] + public void CodexAndSparkShareRemainingAndTimeComparisonsWithoutCombiningCategories() { var snapshot = Snapshot(); snapshot = snapshot with @@ -58,7 +207,8 @@ public void CodexAndSparkShareRemainingAndTimeBarsWithoutCombiningCategories() Assert.Equal("75%", codex[0].GetProperty("remainingPercent").GetString()); Assert.Equal("20%", spark[0].GetProperty("elapsedPercent").GetString()); Assert.Equal(codex[0].GetProperty("remainingBarUrl").GetString(), spark[0].GetProperty("remainingBarUrl").GetString()); - Assert.Equal(codex[0].GetProperty("elapsedBarUrl").GetString(), spark[0].GetProperty("elapsedBarUrl").GetString()); + Assert.Equal(codex[0].GetProperty("usageComparison").GetString(), spark[0].GetProperty("usageComparison").GetString()); + Assert.All(spark.EnumerateArray(), window => Assert.False(window.GetProperty("hasWeeklyForecast").GetBoolean())); Assert.Equal("67%", codex[1].GetProperty("remainingPercent").GetString()); Assert.Equal("20%", spark[1].GetProperty("remainingPercent").GetString()); Assert.Contains("75% remaining", codex[0].GetProperty("remainingBarAlt").GetString()!, StringComparison.Ordinal); @@ -143,7 +293,8 @@ public void OldObservationsCannotLookLikeCurrentTimeComparisons(bool lastConfirm { Assert.Equal("—", window.GetProperty("elapsedPercent").GetString()); Assert.False(window.GetProperty("elapsedAvailable").GetBoolean()); - Assert.Equal(string.Empty, window.GetProperty("elapsedBarUrl").GetString()); + Assert.Equal(string.Empty, window.GetProperty("timeRemainingBarUrl").GetString()); + Assert.Contains("time comparison unavailable", window.GetProperty("usageComparison").GetString(), StringComparison.Ordinal); Assert.Contains("last observation", window.GetProperty("remainingBarAlt").GetString(), StringComparison.Ordinal); } Assert.Contains("paused", data.RootElement.GetProperty("weeklyForecastSummary").GetString(), StringComparison.Ordinal); @@ -157,7 +308,7 @@ public void OldHistoryDoesNotInflateTheContinuousForecastProgress() 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", + Assert.Equal("Forecast · collecting measurements (19/30 min)", data.RootElement.GetProperty("weeklyForecastSummary").GetString()); Assert.DoesNotContain("On track", data.RootElement.GetRawText(), StringComparison.Ordinal); } @@ -200,12 +351,13 @@ public void InformationIsAvailableThroughARegularCommandWithoutAFormAction() [InlineData(0, "100%", 534)] [InlineData(43, "57%", 304.38)] [InlineData(100, "0%", 0)] - public void RemainingBarDrainsWhileElapsedTimeFills(double used, string remaining, double width) + public void RemainingBarDrainsAndTimeComparisonDescribesUsedQuota(double used, string remaining, double width) { using var data = Render(Snapshot() with { Primary = new(used, 300, Now.AddHours(4)) }); var window = data.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[0]; Assert.Equal(remaining, window.GetProperty("remainingPercent").GetString()); Assert.Equal("20%", window.GetProperty("elapsedPercent").GetString()); + Assert.Equal($"{used:0}% used · 20% of window elapsed", window.GetProperty("usageComparison").GetString()); var bar = XDocument.Parse(Uri.UnescapeDataString(window.GetProperty("remainingBarUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); var fill = bar.Descendants(Svg + "rect").SingleOrDefault(rect => rect.Attribute("fill")?.Value == "#5C9EFA"); if (width == 0) Assert.Null(fill); @@ -213,9 +365,9 @@ public void RemainingBarDrainsWhileElapsedTimeFills(double used, string remainin } [Theory] - [InlineData(0, "15:00")] - [InlineData(3, "15:15")] - [InlineData(59, "16:00")] + [InlineData(0, "Tue 15 Sep 15:00")] + [InlineData(3, "Tue 15 Sep 15:15")] + [InlineData(59, "Tue 15 Sep 16:00")] public void ForecastStopsAtTheLimitWithoutExtendingToResetOrInventingEarlierMeasurements(int minutes, string expectedLabel) { var firstAt = Now.AddHours(-1); @@ -235,29 +387,35 @@ public void ForecastStopsAtTheLimitWithoutExtendingToResetOrInventingEarlierMeas Assert.True(double.Parse(endpoint[0], CultureInfo.InvariantCulture) < double.Parse(resetMarker.Attribute("x1")!.Value, CultureInfo.InvariantCulture)); var zero = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "0"); Assert.Equal(zero.Attribute("y1")!.Value, endpoint[1]); - Assert.Contains(svg.Descendants(Svg + "g"), label => label.Attribute("data-axis")?.Value == "estimated-limit" - && label.Attribute("data-axis-label")?.Value == expectedLabel); + Assert.DoesNotContain(svg.Descendants(Svg + "g"), label => label.Attribute("data-axis")?.Value == "estimated-limit"); + Assert.Contains($"Estimated limit: {expectedLabel}", chart.AltText, StringComparison.Ordinal); // Label rounding must not move the actual plotted limit point. var marker = Assert.Single(svg.Descendants(Svg + "circle"), point => point.Attribute("data-marker")?.Value == "estimated-limit"); Assert.Equal(endpoint[0], marker.Attribute("cx")!.Value); + Assert.Equal($"Estimated limit: {expectedLabel}; actual usage may differ.", marker.Element(Svg + "title")?.Value); } [Fact] - public void WeeklyRiskAppearsAtTheTopAndUnusedWindowsKeepConsistentHeadings() + public void WeeklyRiskStaysWithItsQuotaAndUnusedWindowsKeepConsistentHeadings() { var history = Enumerable.Range(0, 7).Select(index => new UsageHistoryEntry(Now.AddMinutes(-30 + index * 5), 90 - index * 5)).ToArray(); using var data = Render(Snapshot() with { Primary = null, Secondary = new(40, 10080, Now.AddDays(4)) }, history); Assert.True(data.RootElement.GetProperty("hasWeeklyForecast").GetBoolean()); - Assert.Equal("Warning", data.RootElement.GetProperty("forecastColor").GetString()); + Assert.Equal("Default", data.RootElement.GetProperty("forecastColor").GetString()); var codex = data.RootElement.GetProperty("quotaGroups")[0]; Assert.Equal("Codex", codex.GetProperty("name").GetString()); Assert.Equal("Codex · Weekly", codex.GetProperty("heading").GetString()); Assert.True(codex.GetProperty("isPrimary").GetBoolean()); Assert.Equal("Weekly", Assert.Single(codex.GetProperty("windows").EnumerateArray()).GetProperty("title").GetString()); Assert.Contains("5-hour", codex.GetProperty("inactiveWindows").GetString(), StringComparison.Ordinal); + var weekly = Assert.Single(codex.GetProperty("windows").EnumerateArray()); + Assert.Equal(data.RootElement.GetProperty("weeklyForecastSummary").GetString(), weekly.GetProperty("weeklyForecastSummary").GetString()); + Assert.True(UsageDashboardCard.TemplateJson.IndexOf("remainingHeadline", StringComparison.Ordinal) + < UsageDashboardCard.TemplateJson.IndexOf("weeklyForecastSummary", StringComparison.Ordinal)); Assert.True(UsageDashboardCard.TemplateJson.IndexOf("weeklyForecastSummary", StringComparison.Ordinal) - < UsageDashboardCard.TemplateJson.IndexOf("quotaGroups", StringComparison.Ordinal)); + < UsageDashboardCard.TemplateJson.IndexOf("${reset}", StringComparison.Ordinal)); + Assert.StartsWith("Next scheduled reset · ", weekly.GetProperty("reset").GetString(), StringComparison.Ordinal); } [Fact] @@ -269,14 +427,11 @@ public void SingleAndPairedWindowsKeepTheSameBarThicknessAtTheirRespectiveWidths var pairedWindow = paired.RootElement.GetProperty("quotaGroups")[0].GetProperty("windows")[1]; Assert.False(singleWindow.GetProperty("showTitle").GetBoolean()); Assert.True(pairedWindow.GetProperty("showTitle").GetBoolean()); - foreach (var key in new[] { "remainingBarUrl", "elapsedBarUrl" }) - { - static XElement SvgRoot(string url) => XDocument.Parse(Uri.UnescapeDataString(url["data:image/svg+xml;utf8,".Length..])).Root!; - var full = SvgRoot(singleWindow.GetProperty(key).GetString()!); - var half = SvgRoot(pairedWindow.GetProperty(key).GetString()!); - Assert.Equal((double)half.Attribute("width")! * 2, (double)full.Attribute("width")!); - Assert.Equal((double)half.Attribute("height")!, (double)full.Attribute("height")!); - } + static XElement SvgRoot(string url) => XDocument.Parse(Uri.UnescapeDataString(url["data:image/svg+xml;utf8,".Length..])).Root!; + var full = SvgRoot(singleWindow.GetProperty("remainingBarUrl").GetString()!); + var half = SvgRoot(pairedWindow.GetProperty("remainingBarUrl").GetString()!); + Assert.Equal((double)half.Attribute("width")! * 2, (double)full.Attribute("width")!); + Assert.Equal((double)half.Attribute("height")!, (double)full.Attribute("height")!); } [Fact] @@ -288,6 +443,7 @@ public void InformationOmitsMissingForecastSectionsAndRepeatedQuotaRows() Assert.Contains("### Weekly", details, StringComparison.Ordinal); Assert.Contains("independent right axis", details, StringComparison.Ordinal); Assert.Contains("conditional estimate", details, StringComparison.Ordinal); + Assert.Contains("**Time remaining:**", details, StringComparison.Ordinal); Assert.DoesNotContain("Other quota windows", CodexUsageDockPage.FormatDetailsBody(Snapshot(), Now), StringComparison.Ordinal); using var empty = Render(Snapshot() with { Primary = null, Secondary = null }); Assert.Contains("No active quota windows reported", CodexUsageDockPage.FormatForecastDetails(empty.RootElement), StringComparison.Ordinal); diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 0ce998f..dd148a0 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.6", CodexUsageDockMetadata.Version); + Assert.Equal("0.8.7", CodexUsageDockMetadata.Version); Assert.Equal($"Codex Usage - {CodexUsageDockMetadata.Version}", page.Title); } @@ -236,12 +236,14 @@ public void DashboardUsesFullWidthContentWithoutADetailsButton() Assert.Equal("AdaptiveCard", template.RootElement.GetProperty("type").GetString()); Assert.Contains("\"type\": \"Image\"", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("Remaining", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("remainingHeadline", main.TemplateJson, StringComparison.Ordinal); Assert.DoesNotContain("Action.Submit", main.TemplateJson, StringComparison.Ordinal); Assert.DoesNotContain("singleWindow", main.TemplateJson, StringComparison.Ordinal); - Assert.Contains("Time elapsed", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("usageComparison", main.TemplateJson, StringComparison.Ordinal); + Assert.DoesNotContain("elapsedBarUrl", main.TemplateJson, StringComparison.Ordinal); + Assert.Contains("timeRemainingBarUrl", 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.Contains("weeklyForecastStatus", main.TemplateJson, StringComparison.Ordinal); Assert.Same(Assert.Single(page.GetContent()), Assert.Single(page.GetContent())); } @@ -278,13 +280,13 @@ [new RateLimitResetCredit("Full reset", "available", now.AddDays(13))]), Assert.Equal("98%", DashboardWindow(root, "Weekly").GetProperty("remainingPercent").GetString()); Assert.Equal("14%", DashboardWindow(root, "Weekly").GetProperty("elapsedPercent").GetString()); Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("remainingBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "5-hour").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal); + Assert.Equal("20% used · 20% of window elapsed", DashboardWindow(root, "5-hour").GetProperty("usageComparison").GetString()); Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("remainingBarUrl").GetString(), StringComparison.Ordinal); - Assert.StartsWith("data:image/svg+xml;utf8,", DashboardWindow(root, "Weekly").GetProperty("elapsedBarUrl").GetString(), StringComparison.Ordinal); + Assert.Equal("2% used · 14% of week elapsed", DashboardWindow(root, "Weekly").GetProperty("usageComparison").GetString()); Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal); Assert.Contains("Solid line connects sampled values across measurement gaps", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); - Assert.StartsWith("Forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); + Assert.StartsWith("Quota forecast: recent 6 h only.", root.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); Assert.DoesNotContain("On track", main, StringComparison.Ordinal); Assert.Equal("Default", DashboardWindow(root, "Weekly").GetProperty("remainingColor").GetString()); Assert.Contains("Projected at reset", root.GetProperty("fiveHourProjection").GetString(), StringComparison.Ordinal); @@ -383,7 +385,7 @@ public void WeeklyDashboardUsesAdaptiveLocalHistory() using var document = JsonDocument.Parse(data); - Assert.Contains("3 usable weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); + Assert.Contains("3 past weeks", document.RootElement.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); } [Fact] @@ -479,7 +481,7 @@ public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvide [Fact] public void UsageProgressBarCreatesSvgDataUri() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(75, UsageBarPalette.Remaining); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(75); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.Contains("%3Csvg", bar, StringComparison.OrdinalIgnoreCase); @@ -489,7 +491,7 @@ public void UsageProgressBarCreatesSvgDataUri() [Fact] public void UsageProgressBarHandlesNonFinitePercentage() { - var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN, UsageBarPalette.Remaining); + var bar = UsageDashboardCard.CreateProgressBarImageUrl(double.NaN); Assert.StartsWith("data:image/svg+xml;utf8,", bar, StringComparison.Ordinal); Assert.DoesNotContain("NaN", bar, StringComparison.Ordinal); @@ -527,7 +529,7 @@ public void WeeklyTrendChartRendersObservedForecastAndDailyTokens() 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")); - Assert.Equal(["1M", "500K"], root.Descendants(Svg + "g") + Assert.Equal(["1M", "500K", "0"], root.Descendants(Svg + "g") .Where(group => group.Attribute("data-axis")?.Value == "tokens") .Select(group => group.Attribute("data-axis-label")!.Value)); Assert.Contains("Solid line connects sampled values across measurement gaps", result.AltText, StringComparison.Ordinal); @@ -569,7 +571,7 @@ [new DailyTokenUsage(new DateOnly(2026, 7, 11), 250_000)], var root = document.RootElement; var chart = ParseSvg(root.GetProperty("weeklyTrendChartUrl").GetString()!); - Assert.Contains("partial local tokens per day", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + Assert.Contains("incomplete session logs", root.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); Assert.Contains("Local token data is partial", root.GetProperty("weeklyTrendChartAlt").GetString(), StringComparison.Ordinal); Assert.Single(chart.Descendants(Svg + "rect"), rect => rect.Attribute("data-series")?.Value == "daily-tokens"); } @@ -2306,9 +2308,9 @@ public void AdaptiveForecastUsesSixHourPatternAfterThreeCompletedCycles() var adaptive = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, true, history); var currentOnly = AdaptiveWeeklyForecast.Project(latest, start, reset, 0.1, false, history); - Assert.Contains("3 usable weeks", adaptive.Status, StringComparison.Ordinal); + Assert.Contains("3 past weeks", adaptive.Status, StringComparison.Ordinal); Assert.True(adaptive.Forecast.EndsAt < currentOnly.Forecast.EndsAt); - Assert.Equal("Forecast: current pace only.", currentOnly.Status); + Assert.Equal("Quota forecast: current pace only.", currentOnly.Status); } [Fact] diff --git a/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs index eefbbee..40a995d 100644 --- a/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs +++ b/CodexUsageDock.Tests/WeeklyChartReadabilityTests.cs @@ -89,9 +89,11 @@ public void ResetAndNowLabelsFitInsideTheHeaderWithoutOverlapping() var labels = svg.Descendants(Svg + "g") .Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected") .Select(LabelBounds).ToArray(); - Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "NOW"); + Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Now"); + Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Next reset"); + Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "START"); Assert.All(labels, bounds => Assert.InRange(bounds.Top + bounds.Height, 0, PlotTop(svg))); - AssertNonOverlapping(labels); + AssertNonOverlapping([.. labels, .. LegendLabels(svg).Select(LabelBounds)]); } [Fact] @@ -105,9 +107,58 @@ public void DenseCalendarLabelsKeepEveryDateWithoutOverlapping() Assert.All(dayLabels, group => Assert.Equal(2, group.Elements(Svg + "g").Count())); var visibleLabels = dayLabels.SelectMany(group => group.Elements(Svg + "path").Any() ? new[] { group } : group.Elements(Svg + "g")).Select(LabelBounds).ToArray(); + Assert.All(visibleLabels, bounds => + { + Assert.InRange(bounds.Left, 0, WeeklyUsageTrendChartRenderer.Width - bounds.Width); + Assert.InRange(bounds.Top + bounds.Height, 0, WeeklyUsageTrendChartRenderer.Height - 4); + }); AssertNonOverlapping(visibleLabels); } + [Fact] + public void LegendExplainsObservedForecastAndIndependentTokenSeries() + { + var svg = Parse(CreateScenario()); + Assert.Equal(["Quota remaining (%)", "Forecast", "Local tokens/day (right axis)"], + LegendLabels(svg).Select(group => group.Attribute("data-axis-label")!.Value)); + var observed = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-legend-series")?.Value == "remaining"); + Assert.Null(observed.Attribute("stroke-dasharray")); + var forecast = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-legend-series")?.Value == "forecast"); + Assert.Equal("5 4", forecast.Attribute("stroke-dasharray")?.Value); + Assert.Single(svg.Descendants(Svg + "rect"), rect => rect.Attribute("data-legend-series")?.Value == "daily-tokens"); + AssertNonOverlapping(LegendLabels(svg).Select(LabelBounds).ToArray()); + } + + [Fact] + public void EveryScenarioLabelRendersItsPunctuationWithoutReplacementCharacters() + { + var svg = Parse(CreateScenario()); + Assert.All(svg.Descendants(Svg + "g").Where(group => group.Attribute("data-rendered-label") is not null), + group => Assert.DoesNotContain("?", group.Attribute("data-rendered-label")!.Value, StringComparison.Ordinal)); + } + + [Fact] + public void MissingForecastAndTokenDataDoNotAdvertiseAbsentSeries() + { + var reset = Now.AddDays(6); + var svg = Parse(Create([new(Now.AddHours(-1), 80, reset, 10080), new(Now, 75, reset, 10080)], reset)); + Assert.Equal("Quota remaining (%)", Assert.Single(LegendLabels(svg)).Attribute("data-axis-label")?.Value); + Assert.DoesNotContain(svg.Descendants(), element => element.Attribute("data-legend-series")?.Value is "forecast" or "daily-tokens"); + Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens"); + } + + [Fact] + public void FutureAreaBeginsAtNowAndKeepsTheNextResetVisible() + { + var svg = Parse(CreateScenario()); + var now = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-marker")?.Value == "now"); + var future = Assert.Single(svg.Descendants(Svg + "rect"), rect => rect.Attribute("data-area")?.Value == "future"); + Assert.Equal(now.Attribute("x1")?.Value, future.Attribute("x")?.Value); + Assert.Equal(now.Attribute("y1")?.Value, future.Attribute("y")?.Value); + Assert.Contains(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis-label")?.Value == "Next reset"); + Assert.All(ResetMarkers(svg), reset => Assert.Equal("#F2C94C", reset.Attribute("stroke")?.Value)); + } + [Fact] public void BillionTokenScaleUsesACompactLabelAtTheTopGridline() { @@ -115,6 +166,31 @@ public void BillionTokenScaleUsesACompactLabelAtTheTopGridline() var topTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens" && group.Attribute("data-axis-label")?.Value == "1B"); Assert.Equal(PlotTop(svg) - ChartLabelGlyphs.Height / 2, LabelBounds(topTokenLabel).Top); + var zeroTokenLabel = Assert.Single(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "tokens" + && group.Attribute("data-axis-label")?.Value == "0"); + var zeroGridline = Assert.Single(svg.Descendants(Svg + "line"), line => line.Attribute("data-grid")?.Value == "remaining-percent" + && line.Attribute("data-value")?.Value == "0"); + Assert.Equal(double.Parse(zeroGridline.Attribute("y1")!.Value, CultureInfo.InvariantCulture) - ChartLabelGlyphs.Height / 2, + LabelBounds(zeroTokenLabel).Top); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5.9)] + public void EstimatedLimitPointKeepsItsDateWithoutARepeatedLabelCoveringThePlot(double daysAhead) + { + var reset = Now.AddDays(6); + var svg = Parse(Create([new(Now.AddHours(-1), 67, reset, 10080), new(Now, 66, reset, 10080)], + reset, new(Now.AddDays(daysAhead), 0, true))); + Assert.DoesNotContain(svg.Descendants(Svg + "g"), group => group.Attribute("data-axis")?.Value == "estimated-limit"); + var point = Assert.Single(svg.Descendants(Svg + "circle"), circle => circle.Attribute("data-marker")?.Value == "estimated-limit"); + var forecast = Assert.Single(svg.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); + var endpoint = forecast.Attribute("points")!.Value.Split(' ')[^1].Split(','); + Assert.Equal(endpoint[0], point.Attribute("cx")!.Value); + Assert.Equal(endpoint[1], point.Attribute("cy")!.Value); + Assert.Contains("Estimated limit:", point.Element(Svg + "title")?.Value, StringComparison.Ordinal); + Assert.Contains("actual usage may differ", point.Element(Svg + "title")?.Value, StringComparison.Ordinal); } private static WeeklyUsageTrendChart CreateScenario() @@ -164,6 +240,9 @@ private static XElement[] ObservedLines(XDocument svg) => svg.Descendants(Svg + private static XElement[] ResetMarkers(XDocument svg) => svg.Descendants(Svg + "line") .Where(line => line.Attribute("data-marker")?.Value == "quota-reset").ToArray(); + private static XElement[] LegendLabels(XDocument svg) => svg.Descendants(Svg + "g") + .Where(group => group.Attribute("data-axis")?.Value == "legend").ToArray(); + private static double PlotTop(XDocument svg) => double.Parse(svg.Descendants(Svg + "line") .Single(line => line.Attribute("data-grid")?.Value == "remaining-percent" && line.Attribute("data-value")?.Value == "100") .Attribute("y1")!.Value, CultureInfo.InvariantCulture); diff --git a/CodexUsageDock.Tests/WeeklyForecastTests.cs b/CodexUsageDock.Tests/WeeklyForecastTests.cs index 3e46a3f..69dbf3c 100644 --- a/CodexUsageDock.Tests/WeeklyForecastTests.cs +++ b/CodexUsageDock.Tests/WeeklyForecastTests.cs @@ -10,6 +10,17 @@ public sealed class WeeklyForecastTests(ITestOutputHelper output) private static readonly DateTimeOffset Reset = new(2026, 9, 21, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Now = Reset.AddDays(-5); + [Fact] + public void ForecastBasisNamesQuotaHistoryCoverageSeparatelyFromTokenLogs() + { + var result = Analyze(Series(Now.AddHours(-1), Now, 80, 78), History(_ => 0.002)); + Assert.StartsWith("Quota forecast:", result.ForecastStatus, StringComparison.Ordinal); + Assert.Contains("3 past weeks", result.ForecastStatus, StringComparison.Ordinal); + Assert.Contains("100% history coverage", result.ForecastStatus, StringComparison.Ordinal); + Assert.Contains("estimate only", result.ForecastStatus, StringComparison.Ordinal); + Assert.DoesNotContain("Historical influence grows", result.ForecastStatus, StringComparison.Ordinal); + } + [Fact] public void ShortBurstDoesNotProduceAWeekProjection() { @@ -41,9 +52,9 @@ public void OlderConsumptionCannotMakeAnInsignificantRecentDecreaseForecastable( } [Theory] - [InlineData(1, "06:45")] - [InlineData(25, "Thu 17 Sep")] - public void ChartAlternativeTextUsesTheSameNearAndDistantEstimatePrecision(int hoursAhead, string expected) + [InlineData(1, "Wed 16 Sep 06:45")] + [InlineData(25, "Thu 17 Sep 06:45")] + public void ChartAlternativeTextIncludesTheDateForNearAndDistantEstimates(int hoursAhead, string expected) { var zone = TimeZoneInfo.CreateCustomTimeZone("Forecast test zone", TimeSpan.FromMinutes(330), "Test", "Test"); var samples = Series(Now.AddHours(-1), Now, 90, 80); @@ -55,6 +66,16 @@ public void ChartAlternativeTextUsesTheSameNearAndDistantEstimatePrecision(int h Assert.Contains($"Estimated limit: {expected}; actual usage may differ.", chart.AltText, StringComparison.Ordinal); } + [Theory] + [InlineData(1, "01:15", "Wed 16 Sep 01:15")] + [InlineData(25, "Thu 17 Sep", "Thu 17 Sep 01:15")] + public void ExplicitEstimateDatesPreserveRoundingAndTheCompactDefault(int hoursAhead, string compact, string dated) + { + var estimate = Now.AddHours(hoursAhead).AddMinutes(7); + Assert.Equal(compact, UsageTrendAnalyzer.FormatWeeklyLimitEstimate(estimate, Now, CultureInfo.InvariantCulture, TimeZoneInfo.Utc)); + Assert.Equal(dated, UsageTrendAnalyzer.FormatWeeklyLimitEstimate(estimate, Now, CultureInfo.InvariantCulture, TimeZoneInfo.Utc, includeDate: true)); + } + [Fact] public void EightMinutesOfHistoryDoNotCountAsEightUsableWeeks() { @@ -75,7 +96,7 @@ public void RepresentativeHistoryCanForecastAfterObservedIdleTime() Assert.NotNull(result.Forecast); Assert.InRange(result.Forecast.RemainingPercent, 60, 79); - Assert.Contains("3 usable weeks", result.ForecastStatus, StringComparison.Ordinal); + Assert.Contains("3 past weeks", result.ForecastStatus, StringComparison.Ordinal); } [Fact] @@ -138,7 +159,7 @@ public void OneFreshMeasurementCanStartAHistoryOnlyWeeklyForecast() Assert.NotNull(result.Forecast); Assert.Equal(65.6, result.Forecast.RemainingPercent, precision: 8); Assert.Contains("historical usage only", result.ForecastStatus, StringComparison.Ordinal); - Assert.Contains("3 usable weeks", result.ForecastStatus, StringComparison.Ordinal); + Assert.Contains("3 past weeks", result.ForecastStatus, StringComparison.Ordinal); Assert.Contains("0/30", result.ForecastStatus, StringComparison.Ordinal); Assert.Contains("observed history", result.Message, StringComparison.Ordinal); } diff --git a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs index 742dbad..3c1d89a 100644 --- a/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs +++ b/CodexUsageDock.Tests/WeeklyResetIntegrationTests.cs @@ -58,14 +58,14 @@ public void ResetAndRestartPreserveObservedHistoryAndBootstrapFromLearnedUsage(b var data = dashboard.RootElement; Assert.True(data.GetProperty("weeklyTrendAvailable").GetBoolean()); Assert.Contains("historical usage only", data.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); - Assert.Contains("3 usable weeks", data.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); + Assert.Contains("3 past weeks", data.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); Assert.Contains("0/30", data.GetProperty("weeklyForecastStatus").GetString(), StringComparison.Ordinal); var chart = XDocument.Parse(Uri.UnescapeDataString( data.GetProperty("weeklyTrendChartUrl").GetString()!["data:image/svg+xml;utf8,".Length..])); var observed = Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is null); Assert.Equal(2, observed.Attribute("points")!.Value.Split(' ').Length); Assert.Single(chart.Descendants(Svg + "polyline"), line => line.Attribute("stroke-dasharray") is not null); - Assert.Contains("amber: reset/restoration", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); + Assert.Contains("Amber markers: reset or restored allowance", data.GetProperty("weeklyTrendLegend").GetString(), StringComparison.Ordinal); var otherAccount = afterReset with { AccountKey = "other-account" }; restarted.RecordHistory(otherAccount, now); diff --git a/CodexUsageDock/AdaptiveWeeklyForecast.cs b/CodexUsageDock/AdaptiveWeeklyForecast.cs index 558c0cd..2d14df6 100644 --- a/CodexUsageDock/AdaptiveWeeklyForecast.cs +++ b/CodexUsageDock/AdaptiveWeeklyForecast.cs @@ -379,7 +379,7 @@ internal static AdaptiveWeeklyForecastProjection Project( { var reason = enabled ? " Weekly history is insufficient or outdated." : string.Empty; return new(ProjectAtCurrentRate(latest, resetsAt, currentRatePerMinute), - $"Forecast: {FormatRecentBasis(recentDuration)} only.{reason}"); + $"Quota forecast: {FormatRecentBasis(recentDuration)} only.{reason}"); } return ProjectUsingProfiles(latest, windowStart, resetsAt, currentRatePerMinute, profiles, recentDuration); @@ -453,8 +453,8 @@ private static AdaptiveWeeklyForecastProjection ProjectUsingProfiles( AdaptiveWeeklyForecastProjection Result(UsageTrendForecast forecast) => new(forecast, currentRatePerMinute.HasValue - ? $"Forecast: {FormatRecentBasis(recentDuration)} + {profiles.Length} usable week{(profiles.Length == 1 ? string.Empty : "s")} ({coverage:P0} observed). Historical influence grows further ahead; estimate only." - : $"Forecast: historical usage only, based on {profiles.Length} usable week{(profiles.Length == 1 ? string.Empty : "s")} ({coverage:P0} observed); estimate only.", + ? $"Quota forecast: {FormatRecentBasis(recentDuration)} + {profiles.Length} past week{(profiles.Length == 1 ? string.Empty : "s")} ({coverage:P0} history coverage); estimate only." + : $"Quota forecast: historical usage only · {profiles.Length} past week{(profiles.Length == 1 ? string.Empty : "s")} ({coverage:P0} history coverage); estimate only.", UsesHistory: true); } diff --git a/CodexUsageDock/ChartLabelGlyphs.cs b/CodexUsageDock/ChartLabelGlyphs.cs index e7b0bea..9a56024 100644 --- a/CodexUsageDock/ChartLabelGlyphs.cs +++ b/CodexUsageDock/ChartLabelGlyphs.cs @@ -46,5 +46,9 @@ internal static class ChartLabelGlyphs ['%'] = (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"), + ['('] = (6.025, "M4.008 19.289 L2.725 19.289 C0.908 17.215 0 14.657 0 11.616 C0 8.563 0.908 5.965 2.725 3.82 L4.025 3.82 C2.186 6.047 1.266 8.64 1.266 11.599 C1.266 14.534 2.18 17.098 4.008 19.289 Z"), + [')'] = (6.043, "M1.301 19.289 L0.018 19.289 C1.846 17.098 2.76 14.534 2.76 11.599 C2.76 8.64 1.84 6.047 0 3.82 L1.301 3.82 C3.129 5.965 4.043 8.563 4.043 11.616 C4.043 14.657 3.129 17.215 1.301 19.289 Z"), + [':'] = (3.969, "M0.976 9.182 C0.706 9.182 0.476 9.085 0.286 8.892 C0.095 8.698 0 8.467 0 8.197 C0 7.928 0.095 7.699 0.286 7.512 C0.476 7.324 0.706 7.23 0.976 7.23 C1.251 7.23 1.485 7.324 1.679 7.512 C1.872 7.699 1.969 7.928 1.969 8.197 C1.969 8.467 1.872 8.698 1.679 8.892 C1.485 9.085 1.251 9.182 0.976 9.182 ZM0.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"), + ['/'] = (9.392, "M7.392 3.82 L1.345 18.516 L0 18.516 L6.029 3.82 Z"), }; } diff --git a/CodexUsageDock/CodexUsageDock.csproj b/CodexUsageDock/CodexUsageDock.csproj index 5573cf9..07ab4aa 100644 --- a/CodexUsageDock/CodexUsageDock.csproj +++ b/CodexUsageDock/CodexUsageDock.csproj @@ -3,7 +3,7 @@ WinExe CodexUsageDock app.manifest - 0.8.6 + 0.8.7 10.0.26100.68-preview net10.0-windows10.0.26100.0 diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index 9514012..b8cfb2f 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -56,7 +56,8 @@ internal static string FormatMainDataJson( TimeSpan refreshInterval, bool adaptiveWeeklyForecastEnabled = true, AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, - LocalTokenUsageSnapshot? tokenUsage = null) + LocalTokenUsageSnapshot? tokenUsage = null, + bool showFiveHourLimit = true) { var freshness = GetFreshnessState(snapshot, now, refreshInterval); var dataAvailable = IsDataAvailable(snapshot, freshness); @@ -76,7 +77,7 @@ internal static string FormatMainDataJson( ? $"{resetCount} reset credit{(resetCount == 1 ? string.Empty : "s")}{(urgentReset ? $" · expires {FormatRelativeTime(nextExpiry!.Value, now)}" : string.Empty)}" : string.Empty, ["resetCreditsColor"] = urgentReset ? "Warning" : "Default", - ["quotaGroups"] = CreateQuotaGroups(snapshot, now, dataAvailable), + ["quotaGroups"] = CreateQuotaGroups(snapshot, now, dataAvailable, showFiveHourLimit), ["weeklyAvailable"] = UsageFreshness.IsValidWindow(snapshot.Secondary, now), }; @@ -89,12 +90,19 @@ internal static string FormatMainDataJson( ? FormatWeeklyBudget(weekly, now, dataAvailable) : string.Empty; data["weeklyForecastSummary"] = FormatForecastSummary(weeklyTrend, now, dataAvailable, maximumSampleAge); data["hasWeeklyForecast"] = UsageFreshness.IsValidWindow(snapshot.Secondary, now); - data["forecastColor"] = dataAvailable && weeklyTrend?.Forecast is { ReachesLimitBeforeReset: true } - ? "Warning" : "Default"; + data["hasForecastBasis"] = dataAvailable && weeklyTrend?.Forecast is not null; + // Host warning yellow is hard to read in light themes; use its contrasting foreground and bold text. + data["forecastColor"] = "Default"; AddWeeklyTrendData( data, snapshot.Secondary, weeklyHistory, weeklyTrend, dataAvailable, snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession or UsageDataSource.LastConfirmed, now, TrendMaximumGap(refreshInterval), tokenUsage); + foreach (var window in data["quotaGroups"]![0]!["windows"]!.AsArray().OfType() + .Where(window => window["hasWeeklyForecast"]!.GetValue())) + { + window["weeklyForecastSummary"] = data["weeklyForecastSummary"]!.DeepClone(); + window["forecastColor"] = data["forecastColor"]!.DeepClone(); + } return data.ToJsonString(); } @@ -113,11 +121,18 @@ private static string FormatDashboardNotice(CodexUsageSnapshot snapshot, DateTim : status; } - private static JsonArray CreateQuotaGroups(CodexUsageSnapshot snapshot, DateTimeOffset now, bool fresh) => - new(UsageQuotaGroups.Create(snapshot).Select(group => (JsonNode)CreateQuotaGroup(group, now, fresh)).ToArray()); + private static JsonArray CreateQuotaGroups( + CodexUsageSnapshot snapshot, DateTimeOffset now, bool fresh, bool showFiveHourLimit) => + new(UsageQuotaGroups.Create(snapshot) + .Select(group => group with + { + Windows = group.Windows.Where(window => showFiveHourLimit || window.Title != "5-hour").ToArray(), + }) + .Where(group => group.IsPrimary || group.Windows.Count > 0) + .Select(group => (JsonNode)CreateQuotaGroup(group, now, fresh, snapshot.Secondary)).ToArray()); private static JsonObject CreateQuotaGroup( - UsageQuotaGroup group, DateTimeOffset now, bool fresh) + UsageQuotaGroup group, DateTimeOffset now, bool fresh, RateLimitWindow? defaultWeekly) { var name = group.Name; var columns = group.Windows.Count(item => UsageFreshness.IsValidWindow(item.Window, now)); @@ -134,22 +149,34 @@ private static JsonObject CreateQuotaGroup( 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); + var timeRemaining = 100 - elapsed; active.Add((JsonNode)new JsonObject { ["title"] = title, ["showTitle"] = columns > 1, ["remainingPercent"] = $"{window.RemainingPercent:0}%", + ["remainingHeadline"] = $"{window.RemainingPercent:0}% remaining", ["remainingColor"] = !fresh ? "Default" : window.UsedPercent >= 90 ? "Attention" : window.UsedPercent >= 70 ? "Warning" : "Default", - ["remainingSize"] = window.UsedPercent == 0 ? "default" : "large", - ["remainingBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(window.RemainingPercent, UsageBarPalette.Remaining, columns), + ["remainingBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(window.RemainingPercent, columns), ["remainingBarAlt"] = $"{name}, {title}: {window.RemainingPercent:0}% remaining{(fresh ? string.Empty : " at last observation")}.", ["separate"] = active.Count > 0, ["elapsedAvailable"] = elapsedAvailable, ["elapsedPercent"] = elapsedAvailable ? $"{elapsed:0}%" : "—", - ["elapsedBarUrl"] = elapsedAvailable ? UsageDashboardCard.CreateProgressBarImageUrl(elapsed, UsageBarPalette.Time, columns) : string.Empty, - ["elapsedBarAlt"] = elapsedAvailable ? $"{name}, {title}: {elapsed:0}% of time elapsed." : "Time comparison unavailable.", - ["reset"] = $"Resets {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}", + ["timeRemainingHeadline"] = elapsedAvailable ? $"Time remaining · {timeRemaining:0}%" : string.Empty, + ["timeRemainingBarUrl"] = elapsedAvailable + ? UsageDashboardCard.CreateProgressBarImageUrl(timeRemaining, columns, UsageDashboardCard.TimeBarFill) + : string.Empty, + ["timeRemainingBarAlt"] = elapsedAvailable + ? $"{name}, {title}: {timeRemaining:0}% of time remaining until reset." + : string.Empty, + ["usageComparison"] = elapsedAvailable + ? $"{window.UsedPercent:0}% used · {elapsed:0}% of {(window.WindowMinutes == 10080 ? "week" : "window")} elapsed" + : $"{window.UsedPercent:0}% used{(fresh ? string.Empty : " at last observation")} · time comparison unavailable", + ["hasWeeklyForecast"] = group.IsPrimary && window == defaultWeekly, + ["weeklyForecastSummary"] = string.Empty, + ["forecastColor"] = "Default", + ["reset"] = $"Next scheduled reset · {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}", }); } @@ -178,13 +205,13 @@ private static string FormatForecastSummary( if (!fresh) return "Forecast paused · refresh needed"; if (trend?.Forecast is { } forecast) return forecast.ReachesLimitBeforeReset - ? $"Estimated limit · {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, CultureInfo.InvariantCulture)}" + ? $"At this pace, estimated to run out {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, CultureInfo.InvariantCulture, includeDate: true)}" : $"Forecast · {forecast.RemainingPercent:0}% remaining 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 · collecting measurements ({Math.Floor(minutes):0}/30 min)" : "Forecast · waiting for a usage change"; } @@ -431,14 +458,11 @@ private static void AddWeeklyTrendData( data["weeklyTrendAvailable"] = true; data["weeklyTrendChartUrl"] = chart.ImageUrl; data["weeklyTrendChartAlt"] = chart.AltText; - var forecastLegend = dataAvailable && trend?.Forecast is not null - ? "dashed: estimate" - : "forecast pending"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => $"Line: remaining % · amber: reset/restoration · bars: local tokens/day (right axis) · {forecastLegend}", - LocalTokenUsageStatus.Partial => $"Line: remaining % · amber: reset/restoration · bars: partial local tokens per day (right axis) · {forecastLegend}", - _ => $"Line: remaining % · amber: reset/restoration · local token data unavailable · {forecastLegend}", + LocalTokenUsageStatus.Complete => "Local token bars: available session logs. Amber markers: reset or restored allowance.", + LocalTokenUsageStatus.Partial => "Local token bars: incomplete session logs. Amber markers: reset or restored allowance.", + _ => "Local token data unavailable. Amber markers: reset or restored allowance.", }; var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); @@ -694,13 +718,15 @@ internal static string FormatForecastDetails(JsonElement data) if (string.IsNullOrEmpty(data.GetProperty("fiveHourProjection").GetString()) && !data.GetProperty("weeklyAvailable").GetBoolean()) body.Append("No active quota windows reported.\n\n"); + if (data.GetProperty("weeklyTrendAvailable").GetBoolean()) + body.Append(data.GetProperty("weeklyTrendLegend").GetString()).Append("\n\n"); return body.Append(""" ## Chart guide - **Line:** remaining allowance (%) over the past seven days, retained across resets. The timing of consumption within gaps is unknown. Resets and restored allowance break the line and are marked in amber. - **Bars:** local tokens per day, using the independent right axis. Tokens measure activity, not quota consumption. - **Dashed line:** a conditional estimate. Usable learned history can start a forecast from a fresh measurement after a reset or gap. Recent pace joins after 30 minutes of continuous measurements. - - Remaining allowance falls; elapsed time rises. Equal bar lengths do not indicate a sustainable pace. + - **Time remaining:** the separate time bar counts down to the scheduled reset, independently of quota consumption. Similar allowance and time-bar lengths do not guarantee a sustainable pace. """).ToString(); } @@ -726,7 +752,8 @@ private void UpdatePresentation() _usage.RefreshInterval, _settings.UseAdaptiveWeeklyForecast, presentation.AdaptiveWeeklyHistory, - presentation.TokenUsage); + presentation.TokenUsage, + _settings.ShowFiveHourLimit); using var mainData = JsonDocument.Parse(_mainContent.DataJson); var data = mainData.RootElement; _information.Update(FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index b0cc4f7..4f77a60 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -43,7 +43,7 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(ShowFiveHourLimitKey, true) { Label = "Show five-hour limit", - Description = "Show the five-hour usage limit in the Dock.", + Description = "Show the five-hour usage limit in the Dock and dashboard.", }); _settings.Add(new ToggleSetting(ShowWeeklyLimitKey, true) { diff --git a/CodexUsageDock/Pages/UsageDashboardCard.cs b/CodexUsageDock/Pages/UsageDashboardCard.cs index cfd2dab..87b4de8 100644 --- a/CodexUsageDock/Pages/UsageDashboardCard.cs +++ b/CodexUsageDock/Pages/UsageDashboardCard.cs @@ -3,16 +3,11 @@ namespace CodexUsageDock; -internal enum UsageBarPalette -{ - Remaining, - Time, -} - internal static class UsageDashboardCard { internal const int BarHeight = 12; internal const int BarWidth = 536; + internal const string TimeBarFill = "#73879B"; // Reuse the same windows before and after the primary chart. @@ -31,44 +26,22 @@ internal static class UsageDashboardCard "items": [ { "type": "TextBlock", "text": "${title}", "weight": "bolder", "wrap": true, "$when": "${showTitle}" }, { - "type": "ColumnSet", "spacing": "none", - "columns": [ - { - "type": "Column", "width": "stretch", - "items": [ { "type": "TextBlock", "text": "Remaining", "isSubtle": true } ] - }, - { - "type": "Column", "width": "auto", - "items": [ - { - "type": "TextBlock", "text": "${remainingPercent}", - "size": "${remainingSize}", "weight": "bolder", "color": "${remainingColor}", - "horizontalAlignment": "right" - } - ] - } - ] + "type": "TextBlock", "text": "${remainingHeadline}", + "size": "large", "weight": "bolder", "color": "${remainingColor}", "wrap": true, "spacing": "none" }, { "type": "Image", "url": "${remainingBarUrl}", "altText": "${remainingBarAlt}", "size": "stretch", "spacing": "none" }, + { "type": "TextBlock", "text": "${timeRemainingHeadline}", "isSubtle": true, "size": "small", "spacing": "small", "wrap": true, "$when": "${elapsedAvailable}" }, { - "type": "ColumnSet", "spacing": "none", - "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": "Image", "url": "${timeRemainingBarUrl}", "altText": "${timeRemainingBarAlt}", + "size": "stretch", "spacing": "none", "$when": "${elapsedAvailable}" }, + { "type": "TextBlock", "text": "${usageComparison}", "isSubtle": true, "size": "small", "spacing": "small", "wrap": true, "$when": "${!elapsedAvailable}" }, { - "type": "Image", "url": "${elapsedBarUrl}", "altText": "${elapsedBarAlt}", - "size": "stretch", "spacing": "none", "$when": "${elapsedAvailable}" + "type": "TextBlock", "text": "${weeklyForecastSummary}", "wrap": true, + "color": "${forecastColor}", "weight": "bolder", "spacing": "small", "$when": "${hasWeeklyForecast}" }, { "type": "TextBlock", "text": "${reset}", "isSubtle": true, "size": "small", "spacing": "none", "wrap": true } ] @@ -88,27 +61,18 @@ internal static class UsageDashboardCard "type": "AdaptiveCard", "body": [ { "type": "TextBlock", "text": "${notice}", "wrap": true, "color": "Warning", "$when": "${hasNotice}" }, - { - "type": "TextBlock", "text": "${weeklyForecastSummary}", "wrap": true, - "weight": "bolder", "color": "${forecastColor}", "spacing": "small", "$when": "${hasWeeklyForecast}" - }, { "type": "TextBlock", "text": "Refreshing…", "isSubtle": true, "$when": "${isLoading}" }, {{QuotaGroupTemplate}}, { "type": "Container", "spacing": "small", "$when": "${weeklyTrendAvailable}", "items": [ - { "type": "TextBlock", "text": "Codex · weekly remaining", "weight": "bolder" }, { "type": "Image", "url": "${weeklyTrendChartUrl}", "altText": "${weeklyTrendChartAlt}", - "size": "stretch", "spacing": "small" + "size": "stretch", "spacing": "none" }, { - "type": "TextBlock", "text": "${weeklyTrendLegend}", - "isSubtle": true, "size": "small", "wrap": true, "spacing": "small" - }, - { - "type": "TextBlock", "text": "${weeklyForecastStatus}", - "isSubtle": true, "size": "small", "wrap": true, "spacing": "small" + "type": "TextBlock", "text": "${weeklyForecastStatus}", "wrap": true, + "isSubtle": true, "size": "small", "spacing": "small", "$when": "${hasForecastBasis}" } ] }, @@ -123,18 +87,13 @@ internal static class UsageDashboardCard } """; - internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette palette, int columns = 2) + internal static string CreateProgressBarImageUrl(double percent, int columns = 2, string fill = "#5C9EFA") { // The host preserves SVG aspect ratio. Match intrinsic width to each column's share // so a full-width window does not render a bar twice as thick as paired windows. var width = BarWidth * 2d / Math.Max(1, columns); var normalized = double.IsFinite(percent) ? Math.Clamp(percent, 0, 100) : 0; var progressWidth = (width - 2d) * normalized / 100; - var fillColor = palette switch - { - UsageBarPalette.Time => "#8A8A8A", - _ => "#5C9EFA", - }; XNamespace svg = "http://www.w3.org/2000/svg"; var document = new XElement( @@ -163,7 +122,7 @@ internal static string CreateProgressBarImageUrl(double percent, UsageBarPalette new XAttribute("width", progressWidth.ToString("0.##", CultureInfo.InvariantCulture)), new XAttribute("height", BarHeight - 2), new XAttribute("rx", "4"), - new XAttribute("fill", fillColor))); + new XAttribute("fill", fill))); } return CreateSvgImageUrl(document); diff --git a/CodexUsageDock/UsageTrendAnalyzer.cs b/CodexUsageDock/UsageTrendAnalyzer.cs index e3c54b6..7239eeb 100644 --- a/CodexUsageDock/UsageTrendAnalyzer.cs +++ b/CodexUsageDock/UsageTrendAnalyzer.cs @@ -197,12 +197,12 @@ private static TrendAnalysis CreateWeeklyAnalysis( } internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateTimeOffset now, - CultureInfo? culture = null, TimeZoneInfo? timeZone = null) + CultureInfo? culture = null, TimeZoneInfo? timeZone = null, bool includeDate = false) { var displayCulture = culture ?? CultureInfo.InvariantCulture; var displayTimeZone = timeZone ?? TimeZoneInfo.Local; var local = TimeZoneInfo.ConvertTime(estimated, displayTimeZone); - if (estimated - now >= TimeSpan.FromDays(1)) + if (!includeDate && estimated - now >= TimeSpan.FromDays(1)) { return local.ToString("ddd d MMM", displayCulture); } @@ -210,7 +210,7 @@ internal static string FormatWeeklyLimitEstimate(DateTimeOffset estimated, DateT // Round only the presentation; alert thresholds and chart points retain the calculated instant. var quarter = TimeSpan.FromMinutes(15).Ticks; var rounded = local.AddTicks((quarter - local.Ticks % quarter) % quarter); - return rounded.Date == TimeZoneInfo.ConvertTime(now, displayTimeZone).Date + return !includeDate && rounded.Date == TimeZoneInfo.ConvertTime(now, displayTimeZone).Date ? rounded.ToString("HH:mm", displayCulture) : rounded.ToString("ddd d MMM HH:mm", displayCulture); } diff --git a/CodexUsageDock/WeeklyUsageTrendChart.cs b/CodexUsageDock/WeeklyUsageTrendChart.cs index 9b783ee..eaa993f 100644 --- a/CodexUsageDock/WeeklyUsageTrendChart.cs +++ b/CodexUsageDock/WeeklyUsageTrendChart.cs @@ -9,15 +9,17 @@ internal sealed record WeeklyUsageTrendChart(string ImageUrl, string AltText); internal static class WeeklyUsageTrendChartRenderer { internal const int Width = 960; - internal const int Height = 280; + internal const int Height = 260; internal const int MaximumRenderedPoints = 180; private const double Left = 58; private const double Right = 58; - private const double TrendTop = 48; - private const double TrendBottom = 227; + private const double TrendTop = 72; + private const double TrendBottom = 210; private const double TrendHeight = TrendBottom - TrendTop; - private const double DayLabelTop = 239; + private const double DayLabelTop = 222; + private const double LegendTop = 4; + private const double MarkerLabelTop = 28; private const string AxisLabelFill = "#D0D0D0"; private static readonly XNamespace Svg = "http://www.w3.org/2000/svg"; internal static WeeklyUsageTrendChart? Create( @@ -68,13 +70,15 @@ internal static class WeeklyUsageTrendChartRenderer : null; var document = CreateDocument(); + AddLegend(document, usableForecast is not null, tokenScaleMaximum > 0); + AddForecastArea(document, effectiveNow, window.ResetsAt, calendarScale); AddTrendGrid(document); AddTokenAxis(document, tokenScaleMaximum); AddCalendarDayGrid(document, dailyUse, calendarScale); AddDailyTokenBars(document, dailyUse, calendarScale, displayCulture, tokenScaleMaximum); AddNowMarker(document, plotStart, window.ResetsAt, effectiveNow, calendarScale); - AddDetectedResetMarkers(document, resets, calendarScale, displayCulture, displayTimeZone); AddResetMarkers(document, windowStart, window.ResetsAt, calendarScale); + AddDetectedResetMarkers(document, resets, calendarScale, displayCulture, displayTimeZone); AddRestorationMarkers(document, restorations, resets, calendarScale); AddObservedLines(document, renderedSegments, calendarScale); AddForecastLine(document, forecastSegment, usableForecast, window.ResetsAt, now, calendarScale, displayCulture, displayTimeZone); @@ -109,6 +113,54 @@ internal static class WeeklyUsageTrendChartRenderer new XElement(Svg + "rect", new XAttribute("width", Width), new XAttribute("height", Height), new XAttribute("rx", "4"), new XAttribute("fill", "#202020"))); + private static void AddLegend(XElement document, bool hasForecast, bool hasTokenBars) + { + AddLegendLine(document, Left, "Quota remaining (%)", "remaining", dashed: false); + if (hasForecast) + { + AddLegendLine(document, Left + 260, "Forecast", "forecast", dashed: true); + } + + if (hasTokenBars) + { + const string label = "Local tokens/day (right axis)"; + var labelWidth = MeasureChartLabel(NormalizeChartLabel(label)); + var x = Width - Right - labelWidth - 24; + document.Add(new XElement(Svg + "rect", new XAttribute("x", Format(x)), + new XAttribute("y", LegendTop + 4), new XAttribute("width", "14"), new XAttribute("height", "10"), + new XAttribute("fill", "#858585"), new XAttribute("data-legend-series", "daily-tokens"))); + AddChartLabel(document, label, Width - Right, LegendTop, ChartLabelAlignment.End, "legend"); + } + } + + private static void AddLegendLine(XElement document, double x, string label, string series, bool dashed) + { + var swatch = new XElement(Svg + "line", new XAttribute("x1", Format(x)), new XAttribute("x2", Format(x + 24)), + new XAttribute("y1", LegendTop + 10), new XAttribute("y2", LegendTop + 10), + new XAttribute("stroke", "#5C9EFA"), new XAttribute("stroke-width", "2"), + new XAttribute("data-legend-series", series)); + if (dashed) + { + swatch.Add(new XAttribute("stroke-dasharray", "5 4")); + } + document.Add(swatch); + AddChartLabel(document, label, x + 32, LegendTop, ChartLabelAlignment.Start, "legend"); + } + + private static void AddForecastArea(XElement document, DateTimeOffset now, DateTimeOffset windowEnd, CalendarDayScale calendarScale) + { + if (now >= windowEnd) + { + return; + } + + var x = calendarScale.GetX(now); + document.Add(new XElement(Svg + "rect", new XAttribute("x", Format(x)), new XAttribute("y", TrendTop), + new XAttribute("width", Format(Width - Right - x)), new XAttribute("height", TrendHeight), + new XAttribute("fill", "#FFFFFF"), new XAttribute("fill-opacity", "0.035"), + new XAttribute("data-area", "future"))); + } + private static UsageHistoryEntry[] Normalize( IReadOnlyList history, DateTimeOffset windowStart, @@ -376,6 +428,7 @@ private static void AddTokenAxis(XElement document, long tokenScaleMaximum) { (tokenScaleMaximum, TrendTop), (tokenScaleMaximum / 2d, TrendTop + TrendHeight / 2), + (0d, TrendBottom), }) { AddChartLabel( @@ -437,7 +490,7 @@ private static void AddMarkerLabel(XElement document, string label, double x, st var width = MeasureChartLabel(NormalizeChartLabel(label)); var center = Math.Clamp(x, Left + width / 2, Width - Right - width / 2); var left = center - width / 2; - foreach (var top in new[] { 0d, ChartLabelHeight + 6 }) + foreach (var top in new[] { MarkerLabelTop, MarkerLabelTop + ChartLabelHeight + 6 }) { var overlaps = document.Elements(Svg + "g") .Where(group => group.Attribute("data-axis")?.Value is "marker" or "reset-detected") @@ -532,11 +585,15 @@ private static void AddResetMarkers( new XAttribute("y1", TrendTop), new XAttribute("y2", TrendBottom), new XAttribute("stroke", "#C8C8C8"), - new XAttribute("stroke-opacity", "0.6"), + new XAttribute("stroke-opacity", edge == "start" ? "0.25" : "0.6"), new XAttribute("stroke-width", "1"), new XAttribute("stroke-dasharray", "2 2"), - new XAttribute("data-marker", $"reset-{edge}"))); - AddMarkerLabel(document, edge == "start" ? "START" : "RESET", x, "marker"); + new XAttribute("data-marker", $"reset-{edge}"), + new XElement(Svg + "title", edge == "start" ? "Current quota window starts" : "Next scheduled reset"))); + if (edge == "end") + { + AddMarkerLabel(document, "Next reset", x, "marker"); + } } } @@ -560,11 +617,11 @@ private static void AddNowMarker( new XAttribute("x2", Format(x)), new XAttribute("y1", TrendTop), new XAttribute("y2", TrendBottom), - new XAttribute("stroke", "#C8C8C8"), - new XAttribute("stroke-opacity", "0.45"), - new XAttribute("stroke-width", "1"), + new XAttribute("stroke", "#E6E6E6"), + new XAttribute("stroke-opacity", "0.8"), + new XAttribute("stroke-width", "1.5"), new XAttribute("data-marker", "now"))); - AddMarkerLabel(document, "NOW", x, "marker"); + AddMarkerLabel(document, "Now", x, "marker"); } private static void AddRestorationMarkers( @@ -631,7 +688,7 @@ private static void AddDetectedResetMarkers( new XAttribute("data-previous-observed-at", reset.PreviousObservedAt.ToString("O", CultureInfo.InvariantCulture)), new XAttribute("data-detected-at", reset.DetectedAt.ToString("O", CultureInfo.InvariantCulture)), new XElement(Svg + "title", label))); - AddMarkerLabel(document, "RESET", x, "reset-detected"); + AddMarkerLabel(document, "Reset", x, "reset-detected"); } } @@ -736,13 +793,12 @@ private static void AddForecastLine( if (forecast.ReachesLimitBeforeReset) { var x = calendarScale.GetX(end); + // The dashboard gives the date above the chart; repeating it in the plot can cover the forecast. + var description = $"Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(end, now, culture, timeZone, includeDate: true)}; actual usage may differ."; document.Add(new XElement(Svg + "circle", new XAttribute("cx", Format(x)), new XAttribute("cy", Format(GetTrendY(0))), new XAttribute("r", "3"), - new XAttribute("fill", "#5C9EFA"), new XAttribute("data-marker", "estimated-limit"))); - var label = UsageTrendAnalyzer.FormatWeeklyLimitEstimate(end, now, culture, timeZone); - var labelWidth = MeasureChartLabel(NormalizeChartLabel(label)); - AddChartLabel(document, label, Math.Clamp(x, Left + labelWidth / 2, Width - Right - labelWidth / 2), - TrendBottom - ChartLabelHeight - 8, ChartLabelAlignment.Center, "estimated-limit"); + new XAttribute("fill", "#5C9EFA"), new XAttribute("data-marker", "estimated-limit"), + new XElement(Svg + "title", description))); } } @@ -776,15 +832,14 @@ private static void AddDailyTokenBars( new XAttribute("width", Format(width)), new XAttribute("height", Format(height)), new XAttribute("rx", "1.5"), - new XAttribute("fill", "#7A7A7A"), - new XAttribute("fill-opacity", "0.45"), + new XAttribute("fill", "#858585"), new XAttribute("data-series", "daily-tokens"), new XAttribute("data-date", day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), new XAttribute("data-tokens", day.TotalTokens), new XAttribute("data-partial", day.IsPartial), new XAttribute("data-current", day.IsCurrent), new XAttribute("stroke", day.IsCurrent ? "#C8C8C8" : "none"), - new XAttribute("stroke-opacity", day.IsCurrent ? "0.45" : "0"), + new XAttribute("stroke-opacity", day.IsCurrent ? "0.35" : "0"), new XAttribute("stroke-width", day.IsCurrent ? "1" : "0"))); } @@ -827,7 +882,7 @@ private static string FormatAltText( 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.", + { ReachesLimitBeforeReset: true } => $" Estimated limit: {UsageTrendAnalyzer.FormatWeeklyLimitEstimate(forecast.EndsAt, now, culture, timeZone, includeDate: true)}; actual usage may differ.", { } => $" Forecast reaches about {forecast.RemainingPercent:0}% remaining at reset; actual usage may differ.", null => " Forecast is unavailable.", }; @@ -837,7 +892,7 @@ private static string FormatAltText( var resetText = resets.Length > 0 ? $" {resets.Length} quota reset or reset-schedule change{(resets.Length == 1 ? " was" : "s were")} detected. {string.Join(" ", resets.Select(reset => FormatResetDetection(reset, culture, timeZone)))} Markers show detection times; the precise event time between observations is unknown." : " No quota reset or reset-schedule changes were detected in the retained observations."; - return $"Weekly quota trend from {period}. Recorded observations from the preceding seven days are retained across quota resets. Remaining quota changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observation{(sampleCount == 1 ? string.Empty : "s")}. The left vertical scale is remaining quota from 0% to 100%.{(tokenUsage is null ? string.Empty : " The independent right scale is locally observed total tokens per calendar day.")} Horizontal labels are local calendar dates, and reset boundary markers show the current quota window. Solid line connects sampled values across measurement gaps; allowance increases break the line, as do quota cycle changes; dashed line is a conditional forecast.{restorationText}{resetText}{forecastText} {daily}"; + return $"Weekly quota trend from {period}. Recorded observations from the preceding seven days are retained across quota resets. Remaining quota changed from {first.RemainingPercent:0}% to {last.RemainingPercent:0}% across {sampleCount} observation{(sampleCount == 1 ? string.Empty : "s")}. The left vertical scale is remaining quota from 0% to 100%.{(tokenUsage is null ? string.Empty : " The independent right scale is locally observed total tokens per calendar day.")} Horizontal labels are local calendar dates. Now separates recorded observations from future dates, and Next reset marks the scheduled end of the current quota window. Amber Reset labels mark detected historical cycle changes. Solid line connects sampled values across measurement gaps; allowance increases break the line, as do quota cycle changes; dashed line is a conditional forecast.{restorationText}{resetText}{forecastText} {daily}"; } private static string FormatResetDetection(QuotaCycleChange reset, CultureInfo culture, TimeZoneInfo timeZone) => diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f98055e..29357a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,7 +64,7 @@ Tests must use `TestEnvironment` or explicitly inject both history stores and a ### 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. +The chart uses baked Segoe UI outlines because the Windows SVG image renderer does not support SVG text. `CodexUsageDock/ChartLabelGlyphs.cs` is generated source; regenerate it on Windows with `scripts/generate-chart-labels.ps1` and review the visual result. Include every character used by a new label in the generator alphabet, including punctuation; the readability regression checks the rendered labels for replacement characters. Runtime builds use the checked-in outlines and need no font loading or drawing dependency. ## Forecast evaluation @@ -158,7 +158,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. | -| Dashboard and information | Required | Required | Opening **Codex Usage** shows remaining allowance, with no Details button. Verify that 0% used gives a full 100% remaining bar and exhausted quota gives an empty bar, while elapsed time fills upward. Codex and Spark share model/window headings; a single active window uses full width, and missing/expired states remain explicit. The weekly forecast status precedes quotas and the graph precedes additional categories. The graph retains seven days of observations across resets, connects measurements across gaps, falls with consumption, and breaks at resets/restorations with amber markers. It labels the latest observation, preserves isolated points, and never invents an earlier 100% reading. Connections across gaps must not count as continuous measurements for forecasts. With usable learned history, verify a historical forecast starts from the first fresh post-reset measurement and identifies that basis; recent pace joins after 30 continuous minutes. Without usable history, verify the usual waiting status. Its dashed forecast stops at the estimated zero point with a date label. Local daily token bars and the right axis appear by default, with partial/unavailable coverage identified. **More > Usage information** must open through native command navigation and show credits, source, forecast basis, budgets, chart guide and history; verify back navigation and live refresh on that page. Check narrow/wide layouts, light/dark/high-contrast themes, the fixed dark chart background, labels, keyboard focus, and refresh without clipping or freezing. Stale observations retain age and suppress forecasts and time comparisons. | +| Dashboard and information | Required | Required | Opening **Codex Usage** shows remaining allowance, with no Details button. Verify that 0% used gives a full 100% remaining bar and exhausted quota gives an empty bar, while the separate **Time remaining** bar counts from 100% toward 0% until the scheduled reset, independently of consumption. Codex and Spark share model/window headings; a single active window uses full width, and missing/expired states remain explicit. The default weekly forecast sits with its remaining allowance and next reset; allowance and time remaining use separately labeled bars, and the graph precedes additional categories. Disabling **Show five-hour limit** hides active five-hour windows and their missing/expired messages immediately. The graph retains seven days of observations across resets, connects measurements across gaps, falls with consumption, and breaks at resets/restorations with amber markers. It labels the latest observation, preserves isolated points, and never invents an earlier 100% reading. Connections across gaps must not count as continuous measurements for forecasts. With usable learned history, verify a historical forecast starts from the first fresh post-reset measurement and identifies that basis; recent pace joins after 30 continuous minutes. Without usable history, verify the usual waiting status. Its dashed forecast stops at a baseline point; the dated exhaustion estimate appears once above the chart, with an accessible endpoint description. Verify the bold forecast remains readable in light and dark themes and its observation basis sits below the graph. Local daily token bars and the right axis appear by default; partial/unavailable local-log coverage is identified under Usage information. **More > Usage information** must open through native command navigation and show credits, source, forecast basis, budgets, chart guide and history; verify back navigation and live refresh on that page. Check narrow/wide layouts, light/dark/high-contrast themes, the fixed dark chart background, labels, keyboard focus, and refresh without clipping or freezing. Stale observations retain age and suppress forecasts and time comparisons. | | 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 8b6dc64..5d8c46d 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. | -Dock-band and dashboard percentages show **remaining allowance**. Codex and additional categories such as Spark use the same **Remaining** and **Time elapsed** bars: allowance drains from 100% toward 0%, while elapsed time increases toward 100%. Missing windows are marked **Not reported** or **Awaiting refresh**, never shown as zero. +Dock-band and dashboard percentages show **remaining allowance**. Codex and additional categories such as Spark use the same blue remaining-allowance bar and a separate blue-gray **Time remaining** bar. Both count down from 100% to 0%; the time bar measures the fraction of the reported period left until reset, independently of quota consumption. The default weekly forecast and **Next scheduled reset** date sit with its remaining percentage. Missing windows are marked **Not reported** or **Awaiting refresh**, never shown as zero. Values refresh every minute by default. Open **Usage information** from the dashboard's **More** menu for the data source, measurement time, reset-credit expirations, credits balance, and explanatory text. During an outage, **Last confirmed** retains the previous observation with its age and pauses time comparisons and forecasts. Local fallback data remains labeled. -The weekly forecast summary appears above the quotas, and the graph appears before additional categories. The line shows remaining allowance and keeps the past seven days visible across resets, with amber reset/restoration markers. Moving reset estimates while allowance remains unused do not create extra reset events. A forecast that reaches zero ends at its estimated limit time. With usable learned history, a fresh measurement can start a historical forecast immediately after a reset or gap; recent pace joins after 30 minutes of continuous measurements. Without usable history, those 30 minutes and a meaningful decrease are required. The forecast basis appears below the chart, with further explanations and the daily budget under **Usage information**. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. Daily token bars and their independent right-hand axis appear by default when available; they reflect local session activity and are **not an exact measure of quota consumption**. +The weekly forecast summary appears with its remaining allowance and next reset, and the graph appears before additional categories. The line shows remaining allowance and keeps the past seven days visible across resets, with amber reset/restoration markers. Moving reset estimates while allowance remains unused do not create extra reset events. A forecast that reaches zero ends at a point on the baseline. Its local date and rounded time appear once above the chart, with an accessible description on the point. With usable learned history, a fresh measurement can start a historical forecast immediately after a reset or gap; recent pace joins after 30 minutes of continuous measurements. Without usable history, those 30 minutes and a meaningful decrease are required. When a fresh forecast is available, its quota basis and historical coverage appear in a compact caption below the chart. These details, local token-log availability, and the daily budget are also available under **Usage information**. Adaptive estimates use sufficiently observed recent weeks, with less influence from a short burst further ahead. Daily token bars and their independent right-hand axis appear by default when available; they reflect local session activity and are **not an exact measure of quota consumption**. See the [user guide](USER_GUIDE.md#charts-and-forecasts) for chart legends, adaptive forecasts, and data handling. @@ -75,7 +75,7 @@ Open Command Palette and select **Codex Usage settings**. | Option | What it changes | | --- | --- | -| Visible metrics and reset times | Show or hide the five-hour limit, weekly limit, resets and credits, and quota reset times. | +| Visible metrics and reset times | Show or hide Dock metrics and quota reset times. **Show five-hour limit** also controls five-hour dashboard windows and their **Not reported** or **Awaiting refresh** messages. | | **Compact Dock** | Use shorter labels such as `5h47%` and `W86%`, hiding reset times while keeping source warnings. | | **Separate Dock items** | Pin metrics individually instead of using one combined band. | | Refresh interval | Choose 1, 5, or 15 minutes. | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7cbc8e4..9542824 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -6,13 +6,15 @@ For installation and first use, see the [README](README.md#quick-start). This gu ## Charts and forecasts -The dashboard groups Codex and additional quota categories such as Spark into the same compact windows. Each reported window shows **Remaining**, **Time elapsed**, and a local reset date. Blue shows remaining allowance, falling from 100% toward 0%; gray shows elapsed time, rising toward 100%. Large percentages also mean remaining allowance. Because these bars move in opposite directions, equal lengths do not indicate a sustainable pace. Missing or expired windows take one short line, without fabricated zero values. An unavailable time comparison is shown as **—**. Fresh low allowance is highlighted by the percentage. +The dashboard groups Codex and additional quota categories such as Spark into the same compact windows. Each reported window leads with its remaining percentage and a blue allowance bar, falling from 100% toward 0%. A separate blue-gray **Time remaining** bar shows the percentage of the reported quota period left until reset. It starts full and counts down toward zero, independently of quota consumption. Its percentage and accessible description identify the time measure; the two bars do not guarantee a sustainable usage pace. Before the reported period starts, or while observations are stale, the time bar is omitted. The default weekly window keeps its conditional forecast and local **Next scheduled reset** date together with its remaining allowance. Missing or expired windows take one short line, without fabricated zero values. Turning off **Show five-hour limit** hides all five-hour dashboard windows, including their missing or expired status. Stale observations identify the usage as last observed and do not compare it with current elapsed time. Fresh low allowance is highlighted by the percentage. -The weekly chart shows **remaining allowance**, with 100% at the top and 0% at the bottom. It keeps the past seven days of recorded measurements visible when the quota resets, alongside the current window's future projection. Its solid line connects measurements across gaps and falls as quota is consumed. The timing of consumption within a gap is unknown. The line breaks at reset-cycle changes and allowance restorations; a restoration increases remaining allowance. Amber markers identify these detected changes. Their accessible descriptions give the interval between observations, because an unobserved reset's exact time is unknown. Isolated measurements remain visible as points, and the latest observation is labeled; no earlier 100% observation is invented. A forecast reaching zero ends at its estimated limit time. Its label uses the same rounded estimate as the dashboard and information page; the plotted point retains the calculated time. The chart uses a fixed dark background and brighter font outlines for readable axes in either host theme. Dashboard, information, and text-view dates use English names and local time; Dock-band subtitles retain regional formatting. +The chart legend identifies the solid remaining-quota line, the dashed forecast when available, and local daily token bars on the independent right axis when available. **Now** separates observations from the shaded future area; **Next reset** identifies the scheduled end of the current window, while amber **Reset** markers identify historical cycle changes. + +The weekly chart shows **remaining allowance**, with 100% at the top and 0% at the bottom. It keeps the past seven days of recorded measurements visible when the quota resets, alongside the current window's future projection. Its solid line connects measurements across gaps and falls as quota is consumed. The timing of consumption within a gap is unknown. The line breaks at reset-cycle changes and allowance restorations; a restoration increases remaining allowance. Amber markers identify these detected changes. Their accessible descriptions give the interval between observations, because an unobserved reset's exact time is unknown. Isolated measurements remain visible as points, and the latest observation is labeled; no earlier 100% observation is invented. A forecast reaching zero ends at a point on the baseline. Its accessible description gives the local date and rounded time. The dashboard shows that dated estimate once above the chart in bold using the host's contrasting text color; compact information-page estimates retain their existing date/time precision. The plotted point retains the calculated time. The chart uses a fixed dark background and brighter font outlines for readable axes in either host theme. Dashboard, information, and text-view dates use English names and local time; Dock-band subtitles retain regional formatting. An unused quota can report 100% remaining with a reset estimate one full window after each reading. These moving estimates, including their settlement when consumption starts, do not create reset markers or break the observed line. Fixed cycle changes and allowance increases remain visible. A reset that also restores allowance has one amber event line. -Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Weekdays and dates use two rows when the full labels do not fit. **START** identifies the current window's reported start, **NOW** the current time, and **RESET** a detected change or the future reset. Marker labels occupy separate header rows to avoid overlap; crowded events retain their lines and accessible descriptions even if a label cannot fit. Local daily token bars and their independent right-hand scale appear by default when available, with compact labels such as **1B** for one billion tokens. A short legend distinguishes the two axes, identifies partial or unavailable data, and says whether an estimate is shown or pending. The forecast basis and progress toward continuous measurements appear below the chart. The weekly forecast summary appears at the top, followed by Codex quotas and the weekly chart, then additional quota categories and reset credits. A single reported window uses the full width with a combined model/window heading and bars scaled to match paired windows; unused windows retain both bars with smaller percentages. **Usage information**, in the dashboard's **More** menu, opens a regular page with forecast explanations, the daily/hourly budget, reset-credit expirations, credits, source information, and restoration history. It omits repeated quota rows and absent forecast sections and includes a short chart guide. There is no Details toggle or dependency between that information page and the token chart. +Equal-width local calendar-day columns retain dated weekday labels and partial reset-boundary days. Weekdays and dates use two rows when the full labels do not fit. **Now** identifies the current time, **Next reset** the scheduled reset, and amber **Reset** a detected historical cycle change. Marker labels occupy separate header rows to avoid overlap; crowded events retain their lines and accessible descriptions even if a label cannot fit. Local daily token bars and their independent right-hand scale appear by default when available, with compact labels such as **1B** for one billion tokens. The in-chart legend identifies the available series and the independent token axis. A compact **Quota forecast** caption below the chart gives the basis and historical quota coverage of an available fresh forecast. **Usage information** keeps these details and local session-log availability; progress toward continuous measurements appears in the weekly status. The default weekly forecast summary appears with its quota status, followed by the weekly chart, additional quota categories, and reset credits. A single reported window uses the full width with a combined model/window heading and an allowance bar scaled to match paired windows; unused windows show 100% remaining without inventing consumption. **Usage information**, in the dashboard's **More** menu, opens a regular page with forecast explanations, the daily/hourly budget, reset-credit expirations, credits, source information, and restoration history. It omits repeated quota rows and absent forecast sections and includes a short chart guide. There is no Details toggle or dependency between that information page and the token chart. With usable learned history and adaptive forecasting enabled, a weekly projection can start from a single fresh measurement, including immediately after a reset or gap. This initial estimate uses historical consumption only, anchored to the newly observed remaining allowance; its status identifies the historical basis and progress toward 30 minutes of current measurements. After 30 minutes of continuous measurements, recent pace also contributes. Recent pace uses at most the last six hours of that segment, so an old idle period cannot dilute the estimate indefinitely. Without usable history, 30 minutes and a meaningful decrease are still required, and the projection explicitly describes what would happen if that recent pace continued. The five-hour projection retains its shorter observation requirement. @@ -22,7 +24,7 @@ Six-hour patterns require at least three usable weeks with at least three observ A forecast measurement segment starts again after a reset-cycle change, an allowance increase, or a gap longer than three times the freshness allowance (the greater of five minutes and the refresh interval). Historical line connections across gaps do not count as continuous measurements for forecasts, and resets or gaps never count as observed consumption. Usable learned history can supply the initial estimate while fresh measurements accumulate; otherwise projections pause. Stale or unavailable measurements always pause projections. 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 budget under Usage information shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. Remaining/time bars show allowance and 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. +Projections are conditional estimates, not calibrated probabilities. Estimated exhaustion at least a day away shows a date; nearer estimates are rounded up to a quarter hour. The chart retains the calculated points. The budget under Usage information shows the average percentage points per day (or hour near reset) available to last until reset, even while a forecast is pending. The allowance and time-remaining bars show separate remaining proportions; neither they nor the budget guarantee future consumption. The dashboard therefore omits reassuring pace labels such as “On track”. The model is tested with deterministic scenarios and chronological synthetic benchmarks; its accuracy on your actual future weeks has not been established. The extension also reads aggregate `token_count` records from active and archived local Codex session logs to show locally observed total tokens per calendar day. Limits update first; token bars update independently when local analysis finishes. These token bars are local activity observations, not an exact accounting of allowance consumption. The Usage information page identifies the allowance source: the live route is explicitly the CLI app-server; session metadata may have been written by the desktop app, CLI, or another local Codex client and cannot be attributed more precisely. @@ -74,7 +76,7 @@ Codex is detected using the environment configuration described in [Requirements ### Quota categories -The dashboard renders separate quota categories returned by newer Codex versions with the same Remaining/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 Usage information, and credits remain usable even when there are no percentage windows. +The dashboard renders separate quota categories returned by newer Codex versions with the same allowance and time-remaining 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 Usage information, 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 index a78eb6a..d7b32c8 100644 --- a/scripts/generate-chart-labels.ps1 +++ b/scripts/generate-chart-labels.ps1 @@ -20,7 +20,7 @@ $source = [Text.StringBuilder]::new() [void]$source.AppendLine(' internal static readonly Dictionary Values = new()') [void]$source.AppendLine(' {') try { - foreach ($character in '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ %.?'.ToCharArray()) { + 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)